2.5 update
This commit is contained in:
@@ -2183,6 +2183,48 @@ namespace BumpCombat.Tests
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ModifierDefinition_ModelWrapperAppliesToCharacterModelAndHealsOnce()
|
||||
{
|
||||
GameObject owner = new("Modifier Model Wrapper Test");
|
||||
ModifierDefinition definition =
|
||||
ScriptableObject.CreateInstance<ModifierDefinition>();
|
||||
try
|
||||
{
|
||||
PlayerStats stats = owner.AddComponent<PlayerStats>();
|
||||
definition.Configure(
|
||||
"test.model-move-speed",
|
||||
"Move speed",
|
||||
CharacterStat.MoveSpeed,
|
||||
ModifierOperation.Flat,
|
||||
2f,
|
||||
1,
|
||||
7f);
|
||||
|
||||
int healCalls = 0;
|
||||
float healedAmount = 0f;
|
||||
System.Action<float> heal = amount =>
|
||||
{
|
||||
healCalls++;
|
||||
healedAmount += amount;
|
||||
};
|
||||
|
||||
Assert.That(definition.CanApplyToModel(stats), Is.True);
|
||||
Assert.That(definition.TryApplyToModel(stats, heal), Is.True);
|
||||
Assert.That(definition.CanApplyToModel(stats), Is.False);
|
||||
Assert.That(definition.TryApplyToModel(stats, heal), Is.False);
|
||||
Assert.That(healCalls, Is.EqualTo(1));
|
||||
Assert.That(healedAmount, Is.EqualTo(7f));
|
||||
Assert.That(stats.Evaluate(CharacterStat.MoveSpeed, 10f),
|
||||
Is.EqualTo(12f));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(definition);
|
||||
Object.DestroyImmediate(owner);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FormalPlayerPrefab_UsesSixItemCatalogAndThreeColorOwnershipCap()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Defense.Comparison;
|
||||
using BumpCombat.Enemies;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Tests
|
||||
{
|
||||
public sealed class Defense25DMonsterCombatAdapterTests
|
||||
{
|
||||
private const string PoolPath = "Assets/_Project/Constants/Defense25DComparison/MonsterPools.asset";
|
||||
|
||||
[Test]
|
||||
public void EveryPoolEntryResolvesItsExactAuthoredAttackAction()
|
||||
{
|
||||
Defense25DMonsterPoolDefinition pool = AssetDatabase.LoadAssetAtPath<Defense25DMonsterPoolDefinition>(PoolPath);
|
||||
Assert.That(pool, Is.Not.Null);
|
||||
Assert.That(pool.TryValidate(out string error), Is.True, error);
|
||||
|
||||
Texture2D texture = new Texture2D(1, 1);
|
||||
Sprite sourceFrame = Sprite.Create(texture, new Rect(0, 0, 1, 1), new Vector2(.5f, .5f));
|
||||
try
|
||||
{
|
||||
foreach (Defense25DMonsterPoolEntry entry in pool.Tiers.SelectMany(tier => tier.Entries))
|
||||
{
|
||||
string action = entry.CombatProfile.AttackAction;
|
||||
var actions = new Dictionary<string, Sprite[]>
|
||||
{
|
||||
[action] = new[] { sourceFrame },
|
||||
};
|
||||
var motion = new Defense25DMonsterSourceMotion(
|
||||
entry.SourceCharacterId,
|
||||
new[] { sourceFrame },
|
||||
new[] { sourceFrame },
|
||||
actions,
|
||||
null,
|
||||
.1f);
|
||||
|
||||
Assert.That(Defense25DMonsterCombatAdapter.TryResolveAttackFrames(
|
||||
entry, motion, out Sprite[] resolved, out string resolveError),
|
||||
Is.True, $"{entry.EnemyDefinitionId}: {resolveError}");
|
||||
Assert.That(resolved, Is.EqualTo(new[] { sourceFrame }), entry.EnemyDefinitionId);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(sourceFrame);
|
||||
Object.DestroyImmediate(texture);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SourceMotionRejectsWrongSpeciesAndNeverFallsBackToSkeletonFrames()
|
||||
{
|
||||
Defense25DMonsterPoolDefinition pool = AssetDatabase.LoadAssetAtPath<Defense25DMonsterPoolDefinition>(PoolPath);
|
||||
Defense25DMonsterPoolEntry entry = pool.Tiers.SelectMany(tier => tier.Entries)
|
||||
.First(value => value.EnemyDefinitionId == "Demon_A");
|
||||
Texture2D texture = new Texture2D(1, 1);
|
||||
Sprite skeletonFrame = Sprite.Create(texture, new Rect(0, 0, 1, 1), new Vector2(.5f, .5f));
|
||||
try
|
||||
{
|
||||
var wrongSpecies = new Defense25DMonsterSourceMotion(
|
||||
"Skeleton", new[] { skeletonFrame }, new[] { skeletonFrame },
|
||||
new Dictionary<string, Sprite[]> { ["Attack"] = new[] { skeletonFrame } }, null, .1f);
|
||||
Assert.That(Defense25DMonsterCombatAdapter.TryResolveAttackFrames(
|
||||
entry, wrongSpecies, out _, out string speciesError), Is.False);
|
||||
Assert.That(speciesError, Does.Contain("SourceCharacterId"));
|
||||
|
||||
var missingAction = new Defense25DMonsterSourceMotion(
|
||||
entry.SourceCharacterId, new[] { skeletonFrame }, new[] { skeletonFrame },
|
||||
new Dictionary<string, Sprite[]> { ["Attack"] = new[] { skeletonFrame } }, null, .1f);
|
||||
Assert.That(Defense25DMonsterCombatAdapter.TryResolveAttackFrames(
|
||||
entry, missingAction, out _, out string actionError), Is.False);
|
||||
Assert.That(actionError, Does.Contain(entry.CombatProfile.AttackAction));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(skeletonFrame);
|
||||
Object.DestroyImmediate(texture);
|
||||
}
|
||||
}
|
||||
|
||||
[TestCase(EnemyAttackShape.Body, 0.2f, 0f, 0f, true)]
|
||||
[TestCase(EnemyAttackShape.Body, 0.4f, 0f, 0f, false)]
|
||||
[TestCase(EnemyAttackShape.Box, 0.8f, 0.2f, 1f, true)]
|
||||
[TestCase(EnemyAttackShape.Box, -0.2f, 0f, 1f, false)]
|
||||
[TestCase(EnemyAttackShape.Cone, 1f, 0.2f, 2f, true)]
|
||||
[TestCase(EnemyAttackShape.Cone, 1f, 0.8f, 2f, false)]
|
||||
[TestCase(EnemyAttackShape.TargetCircle, 1.9f, 0.2f, .5f, true)]
|
||||
[TestCase(EnemyAttackShape.TargetCircle, .5f, 0.2f, .5f, false)]
|
||||
public void AttackGeometryUsesItsAuthoredShape(
|
||||
EnemyAttackShape shape, float targetX, float targetY, float lengthOrRadius, bool expected)
|
||||
{
|
||||
bool hit = Defense25DMonsterCombatAdapter.IntersectsAttack(
|
||||
shape,
|
||||
Vector2.zero,
|
||||
Vector2.right,
|
||||
new Vector2(2f, 0f),
|
||||
new Vector2(targetX, targetY),
|
||||
targetRadius: .1f,
|
||||
bodyRadius: .25f,
|
||||
attackLength: lengthOrRadius,
|
||||
attackWidth: .6f,
|
||||
attackRadius: lengthOrRadius);
|
||||
|
||||
Assert.That(hit, Is.EqualTo(expected));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ProjectileSweptHitUsesTheWholeSegmentAndConfiguredRadius()
|
||||
{
|
||||
Assert.That(Defense25DMonsterCombatAdapter.ProjectileSegmentHits(
|
||||
Vector2.zero, Vector2.right * 2f, new Vector2(1f, .09f), .1f), Is.True);
|
||||
Assert.That(Defense25DMonsterCombatAdapter.ProjectileSegmentHits(
|
||||
Vector2.zero, Vector2.right * 2f, new Vector2(1f, .12f), .1f), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AdapterExposesTheRunSpawnLifecycleContract()
|
||||
{
|
||||
Assert.That(typeof(Defense25DMonsterCombatAdapter).GetMethod("Bind"), Is.Not.Null);
|
||||
Assert.That(typeof(Defense25DMonsterCombatAdapter).GetMethod("Step"), Is.Not.Null);
|
||||
Assert.That(typeof(Defense25DMonsterCombatAdapter).GetMethod("Cancel"), Is.Not.Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ed0406ba7b2b4ac6a3d9e07042cb10c4
|
||||
@@ -0,0 +1,258 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Defense.Comparison;
|
||||
using BumpCombat.Enemies;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Tests
|
||||
{
|
||||
public sealed class Defense25DMonsterPoolCompositionTests
|
||||
{
|
||||
private static readonly string[][] SpeciesByTier =
|
||||
{
|
||||
new[] { "Slime", "Bat", "Skeleton", "Orc", "Hellbat", "Hellhound", "LavaSlime" },
|
||||
new[] { "ArmoredSkeleton", "SkeletonArcher", "Werewolf", "Werebear", "ArmoredOrc", "BloodMonster_A", "BloodMonster_B", "EyeballMonster", "Ghostfire" },
|
||||
new[] { "GreatswordSkeleton", "EliteOrc", "OrcRider", "Demon_A", "Demon_B", "Demon_C", "Demon_D", "Demon_E", "Demoness_A", "Demoness_B", "FlameGolem", "Minotaur" },
|
||||
};
|
||||
|
||||
private readonly List<UnityEngine.Object> created = new List<UnityEngine.Object>();
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
foreach (UnityEngine.Object item in created)
|
||||
if (item != null) UnityEngine.Object.DestroyImmediate(item);
|
||||
created.Clear();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ExplicitCatalogAndTierBoundariesValidate()
|
||||
{
|
||||
Defense25DMonsterPoolDefinition pool = MakePool();
|
||||
Assert.That(pool.TryValidate(out string error), Is.True, error);
|
||||
Assert.That(pool.Tiers.Select(tier => tier.TierId), Is.EqualTo(new[] { 0, 1, 2 }));
|
||||
Assert.That(pool.Tiers.Select(tier => tier.FromCombatSeconds), Is.EqualTo(new[] { 0f, 180f, 360f }));
|
||||
Assert.That(pool.Tiers.SelectMany(tier => tier.Entries).Count(), Is.EqualTo(28));
|
||||
Assert.That(pool.TryGetEntry("Necromancer", out _), Is.False);
|
||||
|
||||
AssertTier(0f, 0);
|
||||
AssertTier(179.999f, 0);
|
||||
AssertTier(180f, 1);
|
||||
AssertTier(359.999f, 1);
|
||||
AssertTier(360f, 2);
|
||||
AssertInvalidTier(float.NaN);
|
||||
AssertInvalidTier(float.PositiveInfinity);
|
||||
AssertInvalidTier(-1f);
|
||||
}
|
||||
|
||||
[TestCase(-1)]
|
||||
[TestCase(3)]
|
||||
[TestCase(4)]
|
||||
[TestCase(5)]
|
||||
public void DirectorAdapterRejectsNonNormalizedTierIds(int tierId)
|
||||
{
|
||||
Defense25DMonsterPoolDefinition pool = MakePool();
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => pool.GetDirectorEntriesForTier(tierId));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CompositionUsesStableSeedNominalTierThreatBudgetAndBoundedOvershoot()
|
||||
{
|
||||
Defense25DMonsterPoolDefinition pool = MakePool();
|
||||
Defense25DWaveComposition first = pool.BuildWaveComposition(8, 179.999f, 11f, 417);
|
||||
Defense25DWaveComposition replay = pool.BuildWaveComposition(8, 179.999f, 11f, 417);
|
||||
Defense25DWaveComposition changedSeed = pool.BuildWaveComposition(8, 179.999f, 11f, 418);
|
||||
|
||||
Assert.That(first.TierId, Is.EqualTo(0));
|
||||
Assert.That(first.Seed, Is.EqualTo(417));
|
||||
Assert.That(first.EnemyDefinitionIds, Is.EqualTo(replay.EnemyDefinitionIds));
|
||||
Assert.That(first.ThreatCosts, Is.EqualTo(replay.ThreatCosts));
|
||||
Assert.That(first.EnemyDefinitionIds, Is.Not.EqualTo(changedSeed.EnemyDefinitionIds));
|
||||
Assert.That(first.EnemyDefinitions.Count, Is.EqualTo(first.ThreatCosts.Count));
|
||||
Assert.That(first.ComposedThreat, Is.GreaterThanOrEqualTo(first.ThreatBudget));
|
||||
Assert.That(first.ComposedThreat - first.ThreatBudget,
|
||||
Is.LessThanOrEqualTo(first.SelectedEntries.Max(entry => entry.ThreatCost) + 0.0001f));
|
||||
Assert.That(first.SelectedEntries.All(entry => SpeciesByTier[0].Contains(entry.EnemyDefinitionId)), Is.True);
|
||||
|
||||
Defense25DWaveComposition tier2 = pool.BuildWaveComposition(9, 180f, 11f, 417);
|
||||
Defense25DWaveComposition tier3 = pool.BuildWaveComposition(10, 360f, 25f, 417);
|
||||
Assert.That(tier2.TierId, Is.EqualTo(1));
|
||||
Assert.That(tier3.TierId, Is.EqualTo(2));
|
||||
Assert.That(tier2.SelectedEntries.All(entry => SpeciesByTier[1].Contains(entry.EnemyDefinitionId)), Is.True);
|
||||
Assert.That(tier3.SelectedEntries.All(entry => SpeciesByTier[2].Contains(entry.EnemyDefinitionId)), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DiversityConstraintRelaxesAndRecordsWhenNoCandidateFits()
|
||||
{
|
||||
Defense25DMonsterPoolDefinition pool = MakePool();
|
||||
Defense25DWaveComposition composition = pool.BuildWaveComposition(0, 0f, 1f, 1);
|
||||
|
||||
Assert.That(composition.DiversityConstraintRelaxed, Is.True);
|
||||
Assert.That(composition.SelectedEntries, Is.Not.Empty);
|
||||
Assert.That(composition.ComposedThreat, Is.GreaterThanOrEqualTo(1f));
|
||||
Assert.That(composition.ComposedThreat - 1f,
|
||||
Is.LessThanOrEqualTo(composition.SelectedEntries.Max(entry => entry.ThreatCost) + 0.0001f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CompositionUsesEntryWeightsForReplacementDraws()
|
||||
{
|
||||
Defense25DMonsterPoolDefinition pool = MakePool();
|
||||
Set(pool.Tiers[0].Entries[0], "weight", 100f);
|
||||
int slimeSelections = 0;
|
||||
for (int i = 0; i < 64; i++)
|
||||
{
|
||||
Defense25DWaveComposition composition = pool.BuildWaveComposition(i, 0f, 1f, i + 1000);
|
||||
if (composition.EnemyDefinitionIds[0] == "Slime") slimeSelections++;
|
||||
}
|
||||
Assert.That(slimeSelections, Is.GreaterThan(45));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void InvalidCatalogAndArgumentsAreReportedAsConfigurationErrors()
|
||||
{
|
||||
Defense25DMonsterPoolDefinition pool = MakePool();
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => pool.BuildWaveComposition(-1, 0f, 1f, 1));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => pool.BuildWaveComposition(0, 0f, 0f, 1));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => pool.BuildWaveComposition(0, float.NaN, 1f, 1));
|
||||
|
||||
Defense25DMonsterPoolEntry entry = pool.Tiers[0].Entries[0];
|
||||
Set(entry, "threatCost", 0f);
|
||||
Assert.That(pool.TryValidate(out string error), Is.False);
|
||||
Assert.That(error, Does.Contain("finite positive Weight and ThreatCost"));
|
||||
Assert.Throws<InvalidOperationException>(() => pool.BuildWaveComposition(0, 0f, 1f, 1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CatalogRejectsExcludedHumanAndBadSourceReference()
|
||||
{
|
||||
Defense25DMonsterPoolDefinition pool = MakePool();
|
||||
Defense25DMonsterPoolEntry entry = pool.Tiers[0].Entries[0];
|
||||
Set(entry, "sourceCharacterId", "Archer");
|
||||
Assert.That(pool.TryValidate(out string error), Is.False);
|
||||
Assert.That(error, Does.Contain("Excluded"));
|
||||
|
||||
Set(entry, "sourceCharacterId", "Orc");
|
||||
Assert.That(pool.TryValidate(out error), Is.False);
|
||||
Assert.That(error, Does.Contain("mismatched SourceCharacterId"));
|
||||
|
||||
Set(entry, "sourceCharacterId", "Slime");
|
||||
Set(entry, "enemyDefinitionId", "Warlock");
|
||||
Assert.That(pool.TryValidate(out error), Is.False);
|
||||
Assert.That(error, Does.Contain("Excluded"));
|
||||
}
|
||||
|
||||
private void AssertTier(float nominalStart, int expected)
|
||||
{
|
||||
Assert.That(Defense25DMonsterPoolDefinition.TryGetTierForNominalStart(nominalStart, out int tier), Is.True);
|
||||
Assert.That(tier, Is.EqualTo(expected));
|
||||
}
|
||||
|
||||
private void AssertInvalidTier(float nominalStart)
|
||||
{
|
||||
Assert.That(Defense25DMonsterPoolDefinition.TryGetTierForNominalStart(nominalStart, out int tier), Is.False);
|
||||
Assert.That(tier, Is.EqualTo(-1));
|
||||
}
|
||||
|
||||
private Defense25DMonsterPoolDefinition MakePool()
|
||||
{
|
||||
Defense25DMonsterPoolDefinition pool = ScriptableObject.CreateInstance<Defense25DMonsterPoolDefinition>();
|
||||
created.Add(pool);
|
||||
float[] starts = { 0f, 180f, 360f };
|
||||
float[] costs = { 1.1f, 1.8f, 3f };
|
||||
Defense25DMonsterTierPool[] tiers = new Defense25DMonsterTierPool[3];
|
||||
for (int tierIndex = 0; tierIndex < tiers.Length; tierIndex++)
|
||||
{
|
||||
Defense25DMonsterTierPool tier = new Defense25DMonsterTierPool();
|
||||
Set(tier, "tierId", tierIndex);
|
||||
Set(tier, "fromCombatSeconds", starts[tierIndex]);
|
||||
Defense25DMonsterPoolEntry[] entries = new Defense25DMonsterPoolEntry[SpeciesByTier[tierIndex].Length];
|
||||
for (int itemIndex = 0; itemIndex < entries.Length; itemIndex++)
|
||||
{
|
||||
string id = SpeciesByTier[tierIndex][itemIndex];
|
||||
Defense25DMonsterAttackArchetype archetype = GetArchetype(id);
|
||||
Defense25DMonsterCombatProfile profile = new Defense25DMonsterCombatProfile();
|
||||
Set(profile, "definitionBasisId", GetBasis(tierIndex, itemIndex));
|
||||
Set(profile, "attackAction", "Attack");
|
||||
Set(profile, "archetype", archetype);
|
||||
Set(profile, "attackShape", archetype == Defense25DMonsterAttackArchetype.Contact
|
||||
? EnemyAttackShape.Body : archetype == Defense25DMonsterAttackArchetype.Melee
|
||||
? EnemyAttackShape.Box : archetype == Defense25DMonsterAttackArchetype.Charge
|
||||
? EnemyAttackShape.Cone : EnemyAttackShape.TargetCircle);
|
||||
Set(profile, "bodyRadius", 0.4f + itemIndex * 0.01f);
|
||||
Set(profile, "moveSpeed", 1.2f + itemIndex * 0.04f);
|
||||
Set(profile, "attackDamage", 6f + itemIndex);
|
||||
Set(profile, "warningSeconds", 0.5f);
|
||||
Set(profile, "activeSeconds", 0.18f);
|
||||
Set(profile, "recoverySeconds", 0.65f);
|
||||
Set(profile, "attackRange", 0.8f + itemIndex * 0.05f);
|
||||
Set(profile, "attackLength", archetype == Defense25DMonsterAttackArchetype.Charge ? 1.2f : 0.8f);
|
||||
Set(profile, "attackWidth", 0.6f);
|
||||
Set(profile, "attackRadius", archetype == Defense25DMonsterAttackArchetype.Area ? 1.1f : 0.5f);
|
||||
Set(profile, "projectileSpeed", archetype == Defense25DMonsterAttackArchetype.Projectile ? 4.5f : 0f);
|
||||
Set(profile, "projectileRadius", archetype == Defense25DMonsterAttackArchetype.Projectile ? 0.12f : 0f);
|
||||
|
||||
EnemyDefinition definition = ScriptableObject.CreateInstance<EnemyDefinition>();
|
||||
created.Add(definition);
|
||||
Defense25DMonsterPoolEntry entry = new Defense25DMonsterPoolEntry();
|
||||
Set(entry, "definition", definition);
|
||||
GameObject visualPrefab = new GameObject(id + " Visual Fixture");
|
||||
created.Add(visualPrefab);
|
||||
Set(entry, "visualPrefab", visualPrefab);
|
||||
Set(entry, "enemyDefinitionId", id);
|
||||
Set(entry, "sourceCharacterId", GetSourceId(id));
|
||||
Set(entry, "weight", 1f + itemIndex % 3 * 0.25f);
|
||||
Set(entry, "threatCost", costs[tierIndex]);
|
||||
Set(entry, "combatProfile", profile);
|
||||
entries[itemIndex] = entry;
|
||||
}
|
||||
Set(tier, "entries", entries);
|
||||
tiers[tierIndex] = tier;
|
||||
}
|
||||
Set(pool, "tiers", tiers);
|
||||
return pool;
|
||||
}
|
||||
|
||||
private static Defense25DMonsterAttackArchetype GetArchetype(string id)
|
||||
{
|
||||
return id switch
|
||||
{
|
||||
"Slime" => Defense25DMonsterAttackArchetype.Contact,
|
||||
"Bat" or "Hellbat" or "Hellhound" or "ArmoredOrc" or "OrcRider" or "Demon_B" or "Minotaur" => Defense25DMonsterAttackArchetype.Charge,
|
||||
"LavaSlime" or "Werebear" or "BloodMonster_B" or "Ghostfire" or "Demon_C" or "Demoness_B" or "FlameGolem" => Defense25DMonsterAttackArchetype.Area,
|
||||
"SkeletonArcher" or "BloodMonster_A" or "EyeballMonster" or "Demon_A" or "Demon_E" or "Demoness_A" => Defense25DMonsterAttackArchetype.Projectile,
|
||||
_ => Defense25DMonsterAttackArchetype.Melee,
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetBasis(int tier, int index)
|
||||
{
|
||||
if (tier == 0) return new[] { "Slime", "Bat", "Skeleton", "Skeleton", "Bat", "Werewolf", "Slime" }[index];
|
||||
if (tier == 1) return new[] { "ArmoredSkeleton", "SkeletonArcher", "Werewolf", "Werebear", "ArmoredSkeleton", "SkeletonArcher", "Werewolf", "SkeletonArcher", "Bat" }[index];
|
||||
return new[] { "GreatswordSkeleton", "Werebear", "Werewolf", "SkeletonArcher", "GreatswordSkeleton", "Slime", "Werewolf", "SkeletonArcher", "SkeletonArcher", "Bat", "Werebear", "Werebear" }[index];
|
||||
}
|
||||
|
||||
private static string GetSourceId(string id)
|
||||
{
|
||||
return id switch
|
||||
{
|
||||
"LavaSlime" => "Lava Slime", "ArmoredSkeleton" => "Armored Skeleton", "SkeletonArcher" => "Skeleton Archer",
|
||||
"ArmoredOrc" => "Armored Orc", "BloodMonster_A" => "Blood Monster_A", "BloodMonster_B" => "Blood Monster_B",
|
||||
"EyeballMonster" => "Eyeball Monster", "GreatswordSkeleton" => "Greatsword Skeleton", "EliteOrc" => "Elite Orc",
|
||||
"OrcRider" => "Orc rider", "FlameGolem" => "Flame Golem", _ => id,
|
||||
};
|
||||
}
|
||||
|
||||
private static void Set(object instance, string name, object value)
|
||||
{
|
||||
FieldInfo field = instance.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.That(field, Is.Not.Null, $"Missing field {instance.GetType().Name}.{name}");
|
||||
field.SetValue(instance, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a6f096c7d2214309a4d366c3833b7d58
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Linq;
|
||||
using BumpCombat.Constants;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Tests
|
||||
{
|
||||
public sealed class Defense25DMonsterPoolPrefabAssetTests
|
||||
{
|
||||
private const string PoolPath = "Assets/_Project/Constants/Defense25DComparison/MonsterPools.asset";
|
||||
private const string PrefabRoot = "Assets/_Project/Prefabs/Defense25DMonsters/Roster25D-v1/";
|
||||
|
||||
[Test]
|
||||
public void EveryAllowedSpeciesHasADistinctVisualPrefabWithASprite()
|
||||
{
|
||||
GameObject existingPrefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/_Project/Prefabs/Enemies/Slime.prefab");
|
||||
Assert.That(existingPrefab, Is.Not.Null, "The existing Slime prefab must load as the control asset.");
|
||||
Assert.That(PrefabUtility.IsPartOfPrefabAsset(existingPrefab), Is.True,
|
||||
$"The existing prefab control was not recognized: {AssetDatabase.GetAssetPath(existingPrefab)} / {PrefabUtility.GetPrefabAssetType(existingPrefab)}.");
|
||||
|
||||
Defense25DMonsterPoolDefinition pool = AssetDatabase.LoadAssetAtPath<Defense25DMonsterPoolDefinition>(PoolPath);
|
||||
Assert.That(pool, Is.Not.Null, $"Missing pool asset at {PoolPath}.");
|
||||
|
||||
Assert.That(pool.TryValidate(out string validationError), Is.True, validationError);
|
||||
var entries = pool.Tiers.SelectMany(tier => tier.Entries).ToArray();
|
||||
Assert.That(entries.Length, Is.EqualTo(28));
|
||||
Assert.That(entries.Select(entry => entry.VisualPrefab).All(prefab => prefab != null), Is.True);
|
||||
Assert.That(entries.Select(entry => entry.VisualPrefab).Distinct().Count(), Is.EqualTo(28));
|
||||
|
||||
foreach (Defense25DMonsterPoolEntry entry in entries)
|
||||
{
|
||||
GameObject prefab = entry.VisualPrefab;
|
||||
string assetPath = AssetDatabase.GetAssetPath(prefab);
|
||||
GlobalObjectId globalId = GlobalObjectId.GetGlobalObjectIdSlow(prefab);
|
||||
string assetGuid = AssetDatabase.AssetPathToGUID(assetPath);
|
||||
TestContext.WriteLine($"{entry.EnemyDefinitionId}: path={assetPath}; globalObjectId={globalId}; prefabAsset={PrefabUtility.IsPartOfPrefabAsset(prefab)}");
|
||||
Assert.That(PrefabUtility.IsPartOfPrefabAsset(prefab), Is.True,
|
||||
$"{entry.EnemyDefinitionId}: {assetPath} / {PrefabUtility.GetPrefabAssetType(prefab)} / persistent={EditorUtility.IsPersistent(prefab)} / {globalId}.");
|
||||
Assert.That(assetPath, Does.StartWith(PrefabRoot), entry.EnemyDefinitionId);
|
||||
Assert.That(globalId.ToString(), Does.Contain(assetGuid), $"GlobalObjectId doesn't identify the prefab asset for {entry.EnemyDefinitionId}.");
|
||||
|
||||
SpriteRenderer renderer = prefab.GetComponent<SpriteRenderer>();
|
||||
Assert.That(renderer, Is.Not.Null, $"{entry.EnemyDefinitionId} prefab has no root SpriteRenderer.");
|
||||
Assert.That(renderer.sprite, Is.Not.Null, $"{entry.EnemyDefinitionId} prefab has no Idle sprite.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6da0c44591fd4a8a9158d5d81b7aa382
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using BumpCombat.Defense.Comparison;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Tests
|
||||
{
|
||||
public sealed class Defense25DMonsterPoolTests
|
||||
{
|
||||
private const string PoolTypeName = "BumpCombat.Constants.Defense25DMonsterPoolDefinition";
|
||||
|
||||
[Test]
|
||||
public void MonsterPoolDefinition_ExposesDirectorEntryProvider()
|
||||
{
|
||||
Type poolType = FindPoolType();
|
||||
Assert.That(poolType, Is.Not.Null, "The runtime monster pool definition type is missing.");
|
||||
if (poolType == null) return;
|
||||
|
||||
Assert.That(typeof(ScriptableObject).IsAssignableFrom(poolType), Is.True);
|
||||
Assert.That(poolType.GetMethod("TryValidate", new[] { typeof(string).MakeByRefType() }), Is.Not.Null);
|
||||
|
||||
MethodInfo provider = poolType.GetMethod("GetDirectorEntriesForTier", new[] { typeof(int) });
|
||||
Assert.That(provider, Is.Not.Null);
|
||||
if (provider != null)
|
||||
Assert.That(provider.ReturnType, Is.EqualTo(typeof(System.Collections.Generic.IReadOnlyList<Defense25DWaveEnemyEntry>)));
|
||||
}
|
||||
|
||||
[TestCase(0f, 0)]
|
||||
[TestCase(179.999f, 0)]
|
||||
[TestCase(180f, 1)]
|
||||
[TestCase(359.999f, 1)]
|
||||
[TestCase(360f, 2)]
|
||||
public void TierForNominalStart_UsesThe180And360SecondBoundaries(float nominalStart, int expectedTierId)
|
||||
{
|
||||
Type poolType = FindPoolType();
|
||||
Assert.That(poolType, Is.Not.Null, "The runtime monster pool definition type is missing.");
|
||||
if (poolType == null) return;
|
||||
|
||||
MethodInfo getTier = poolType.GetMethod("TryGetTierForNominalStart",
|
||||
BindingFlags.Public | BindingFlags.Static, null,
|
||||
new[] { typeof(float), typeof(int).MakeByRefType() }, null);
|
||||
Assert.That(getTier, Is.Not.Null);
|
||||
if (getTier == null) return;
|
||||
|
||||
object[] arguments = { nominalStart, 0 };
|
||||
bool succeeded = (bool)getTier.Invoke(null, arguments);
|
||||
Assert.That(succeeded, Is.True);
|
||||
Assert.That(arguments[1], Is.EqualTo(expectedTierId));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DirectorEntryProvider_RejectsTierIdsOutsideZeroThroughTwo()
|
||||
{
|
||||
Type poolType = FindPoolType();
|
||||
Assert.That(poolType, Is.Not.Null, "The runtime monster pool definition type is missing.");
|
||||
if (poolType == null) return;
|
||||
|
||||
ScriptableObject pool = ScriptableObject.CreateInstance(poolType);
|
||||
try
|
||||
{
|
||||
MethodInfo provider = poolType.GetMethod("GetDirectorEntriesForTier", new[] { typeof(int) });
|
||||
Assert.That(provider, Is.Not.Null);
|
||||
if (provider == null) return;
|
||||
|
||||
TargetInvocationException exception = Assert.Throws<TargetInvocationException>(
|
||||
() => provider.Invoke(pool, new object[] { 3 }));
|
||||
Assert.That(exception.InnerException, Is.TypeOf<ArgumentOutOfRangeException>());
|
||||
}
|
||||
finally
|
||||
{
|
||||
UnityEngine.Object.DestroyImmediate(pool);
|
||||
}
|
||||
}
|
||||
|
||||
private static Type FindPoolType()
|
||||
{
|
||||
return AppDomain.CurrentDomain.GetAssemblies()
|
||||
.Select(assembly => assembly.GetType(PoolTypeName, false))
|
||||
.FirstOrDefault(type => type != null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c89cc45898cd47daaeab805385f328ac
|
||||
@@ -0,0 +1,171 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Defense.Comparison;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace BumpCombat.Tests
|
||||
{
|
||||
public sealed class Defense25DMonsterSourceMotionCatalogTests
|
||||
{
|
||||
private const string PoolPath = "Assets/_Project/Constants/Defense25DComparison/MonsterPools.asset";
|
||||
private const string CatalogPath = "Assets/_Project/Constants/Defense25DComparison/MonsterSourceMotions.asset";
|
||||
|
||||
[Test]
|
||||
public void AssetReloadLoadsExactSourceKeysAndInjectsAllPoolEntriesIntoTheCombatAdapter()
|
||||
{
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||
AssetDatabase.ImportAsset(CatalogPath, ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport);
|
||||
Defense25DMonsterPoolDefinition pool = AssetDatabase.LoadAssetAtPath<Defense25DMonsterPoolDefinition>(PoolPath);
|
||||
Defense25DMonsterSourceMotionCatalog catalog = AssetDatabase.LoadAssetAtPath<Defense25DMonsterSourceMotionCatalog>(CatalogPath);
|
||||
Assert.That(pool, Is.Not.Null);
|
||||
Assert.That(catalog, Is.Not.Null);
|
||||
Assert.That(catalog.Entries.Count, Is.EqualTo(28));
|
||||
Assert.That(catalog.TryValidateAgainst(pool, out string validationError), Is.True, validationError);
|
||||
|
||||
var actorObject = new GameObject("Source motion adapter binding fixture");
|
||||
Defense25DActor actor = actorObject.AddComponent<Defense25DActor>();
|
||||
try
|
||||
{
|
||||
int bound = 0;
|
||||
foreach (Defense25DMonsterPoolEntry entry in pool.Tiers.SelectMany(tier => tier.Entries))
|
||||
{
|
||||
Assert.That(catalog.TryResolve(entry, out Defense25DMonsterSourceMotion motion,
|
||||
out string resolveError), Is.True, $"{entry.EnemyDefinitionId}: {resolveError}");
|
||||
Assert.That(motion.SourceCharacterId, Is.EqualTo(entry.SourceCharacterId));
|
||||
Assert.That(motion.FrameSeconds, Is.GreaterThan(0f));
|
||||
Defense25DMonsterSourceMotionEntry sourceEntry = catalog.Entries
|
||||
.Single(candidate => candidate.EnemyDefinitionId == entry.EnemyDefinitionId);
|
||||
AssertAnimationStripImported(entry.EnemyDefinitionId, sourceEntry.IdleResourceKey, motion.IdleFrames);
|
||||
AssertAnimationStripImported(entry.EnemyDefinitionId, sourceEntry.WalkResourceKey, motion.WalkFrames);
|
||||
AssertAnimationStripImported(entry.EnemyDefinitionId, sourceEntry.FlyingResourceKey, motion.FlyingFrames);
|
||||
Assert.That(motion.IdleFrames.Length > 0 || motion.FlyingFrames.Length > 0, Is.True,
|
||||
$"{entry.EnemyDefinitionId} has no authored idle/flying strip.");
|
||||
Assert.That(motion.WalkFrames.Length > 0 || motion.FlyingFrames.Length > 0, Is.True,
|
||||
$"{entry.EnemyDefinitionId} has no authored movement strip.");
|
||||
Assert.That(motion.TryGetAttackFrames(entry.CombatProfile.AttackAction,
|
||||
out Sprite[] attackFrames), Is.True, entry.EnemyDefinitionId);
|
||||
Assert.That(attackFrames.Length, Is.GreaterThan(0), entry.EnemyDefinitionId);
|
||||
Assert.That(attackFrames.All(frame => frame != null), Is.True, entry.EnemyDefinitionId);
|
||||
AssertAnimationStripImported(entry.EnemyDefinitionId,
|
||||
sourceEntry.TryFindAttack(entry.CombatProfile.AttackAction, out Defense25DMonsterSourceMotionAction attackAction)
|
||||
? attackAction.ResourceKey : null,
|
||||
attackFrames);
|
||||
AssertOptionalSpriteStripImported(entry.EnemyDefinitionId,
|
||||
sourceEntry.ProjectileResourceKey, motion.ProjectileFrames);
|
||||
|
||||
var adapter = new Defense25DMonsterCombatAdapter();
|
||||
Assert.That(adapter.Bind(entry, actor, motion, () => null, out string bindError),
|
||||
Is.True, $"{entry.EnemyDefinitionId}: {bindError}");
|
||||
Assert.That(adapter.SourceCharacterId, Is.EqualTo(entry.SourceCharacterId));
|
||||
Assert.That(adapter.AttackAction, Is.EqualTo(entry.CombatProfile.AttackAction));
|
||||
adapter.Cancel();
|
||||
bound++;
|
||||
}
|
||||
Assert.That(bound, Is.EqualTo(28));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(actorObject);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CatalogStoresOnlyResourcesRelativeKeysAndExactSpeciesActions()
|
||||
{
|
||||
Defense25DMonsterSourceMotionCatalog catalog = AssetDatabase.LoadAssetAtPath<Defense25DMonsterSourceMotionCatalog>(CatalogPath);
|
||||
Assert.That(catalog, Is.Not.Null);
|
||||
foreach (Defense25DMonsterSourceMotionEntry entry in catalog.Entries)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(entry.IdleResourceKey))
|
||||
Assert.That(entry.IdleResourceKey.StartsWith("Enemies/"), Is.True, entry.EnemyDefinitionId);
|
||||
if (!string.IsNullOrEmpty(entry.FlyingResourceKey))
|
||||
Assert.That(entry.FlyingResourceKey.StartsWith("Enemies/"), Is.True, entry.EnemyDefinitionId);
|
||||
Assert.That(entry.AttackActions, Is.Not.Empty, entry.EnemyDefinitionId);
|
||||
foreach (Defense25DMonsterSourceMotionAction action in entry.AttackActions)
|
||||
{
|
||||
Assert.That(action.ResourceKey.StartsWith("Enemies/"), Is.True,
|
||||
$"{entry.EnemyDefinitionId}/{action.ActionName}");
|
||||
Assert.That(action.ResourceKey, Does.Not.EndWith(".png"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EyeballProjectileUsesTheDedicatedProjectileStripInsteadOfTheCharacterFolderCopy()
|
||||
{
|
||||
Defense25DMonsterPoolDefinition pool = AssetDatabase.LoadAssetAtPath<Defense25DMonsterPoolDefinition>(PoolPath);
|
||||
Defense25DMonsterPoolEntry entry = pool.Tiers.SelectMany(tier => tier.Entries)
|
||||
.Single(candidate => candidate.EnemyDefinitionId == "EyeballMonster");
|
||||
Defense25DMonsterSourceMotionCatalog catalog = AssetDatabase.LoadAssetAtPath<Defense25DMonsterSourceMotionCatalog>(CatalogPath);
|
||||
Defense25DMonsterSourceMotionEntry catalogEntry = catalog.Entries
|
||||
.Single(candidate => candidate.EnemyDefinitionId == "EyeballMonster");
|
||||
|
||||
Assert.That(catalogEntry.ProjectileResourceKey,
|
||||
Is.EqualTo("Enemies/Roster25D-v1/EyeballMonster/Beam(projectile)/Eyeball Monster_Beam"));
|
||||
Assert.That(catalog.TryResolve(entry, out Defense25DMonsterSourceMotion motion,
|
||||
out string error), Is.True, error);
|
||||
Assert.That(motion.ProjectileFrames, Is.Not.Empty);
|
||||
|
||||
foreach (Sprite frame in motion.ProjectileFrames)
|
||||
Assert.That(AssetDatabase.GetAssetPath(frame), Does.Contain("/Beam(projectile)/Eyeball Monster_Beam.png"));
|
||||
|
||||
Sprite[] characterFolderCopy = Resources.LoadAll<Sprite>(
|
||||
"Enemies/Roster25D-v1/EyeballMonster/Eyeball Monster/Eyeball Monster_Beam");
|
||||
Assert.That(characterFolderCopy, Is.Not.Empty);
|
||||
foreach (Sprite frame in characterFolderCopy)
|
||||
Assert.That(AssetDatabase.GetAssetPath(frame), Does.Not.Contain("/Beam(projectile)/"));
|
||||
Assert.That(motion.ProjectileFrames.Select(AssetDatabase.GetAssetPath),
|
||||
Is.All.Not.EqualTo("Assets/_Project/Resources/Enemies/Roster25D-v1/EyeballMonster/Eyeball Monster/Eyeball Monster_Beam.png"));
|
||||
}
|
||||
|
||||
private static void AssertAnimationStripImported(string enemyId, string resourceKey, Sprite[] resolvedFrames)
|
||||
{
|
||||
if (string.IsNullOrEmpty(resourceKey)) return;
|
||||
AssertImportedSpriteStrip(enemyId, resourceKey, resolvedFrames, requireMultiple: true);
|
||||
}
|
||||
|
||||
private static void AssertOptionalSpriteStripImported(string enemyId, string resourceKey, Sprite[] resolvedFrames)
|
||||
{
|
||||
if (string.IsNullOrEmpty(resourceKey))
|
||||
{
|
||||
Assert.That(resolvedFrames, Is.Empty, $"{enemyId} has projectile frames without a resource key.");
|
||||
return;
|
||||
}
|
||||
AssertImportedSpriteStrip(enemyId, resourceKey, resolvedFrames, requireMultiple: false);
|
||||
}
|
||||
|
||||
private static void AssertImportedSpriteStrip(string enemyId, string resourceKey,
|
||||
Sprite[] resolvedFrames, bool requireMultiple)
|
||||
{
|
||||
string assetPath = $"Assets/_Project/Resources/{resourceKey}.png";
|
||||
var importer = AssetImporter.GetAtPath(assetPath) as TextureImporter;
|
||||
Assert.That(importer, Is.Not.Null, $"{enemyId}: no TextureImporter at {assetPath}.");
|
||||
Assert.That(importer.textureType, Is.EqualTo(TextureImporterType.Sprite), enemyId);
|
||||
if (requireMultiple)
|
||||
Assert.That(importer.spriteImportMode, Is.EqualTo(SpriteImportMode.Multiple),
|
||||
$"{enemyId}: animation strip isn't sliced as multiple sprites: {assetPath}");
|
||||
|
||||
Sprite[] importedSprites = AssetDatabase.LoadAllAssetsAtPath(assetPath)
|
||||
.OfType<Sprite>()
|
||||
.OrderBy(frame => FrameIndex(frame.name))
|
||||
.ThenBy(frame => frame.name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
Assert.That(importedSprites.Length, Is.GreaterThan(0), $"{enemyId}: no sprite subassets at {assetPath}.");
|
||||
Assert.That(resolvedFrames.Length, Is.EqualTo(importedSprites.Length),
|
||||
$"{enemyId}: Resources.LoadAll count differs from the imported slice count at {assetPath}.");
|
||||
Assert.That(resolvedFrames.Select(frame => frame.name),
|
||||
Is.EqualTo(importedSprites.Select(frame => frame.name)),
|
||||
$"{enemyId}: resolved frame order differs from deterministic subasset order at {assetPath}.");
|
||||
}
|
||||
|
||||
private static int FrameIndex(string name)
|
||||
{
|
||||
int separator = name.LastIndexOf('_');
|
||||
return separator >= 0 && int.TryParse(name.Substring(separator + 1), out int index) ? index : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d2486364e2f043509201a9bd30635be3
|
||||
@@ -0,0 +1,170 @@
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Defense.Comparison;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Tests
|
||||
{
|
||||
public sealed class Defense25DRewardCatalogDisplayTests
|
||||
{
|
||||
private const string RewardConstantsPath =
|
||||
"Assets/_Project/Constants/Defense25DComparison/RewardConstants.asset";
|
||||
private const string ConfirmationText = "선택하면 즉시 적용됩니다.";
|
||||
|
||||
[Test]
|
||||
public void DefaultAndSavedTowerCards_ShowKoreanNamesEffectsValuesAndConfirmation()
|
||||
{
|
||||
Defense25DRewardConstants saved =
|
||||
AssetDatabase.LoadAssetAtPath<Defense25DRewardConstants>(RewardConstantsPath);
|
||||
Defense25DRewardConstants defaults =
|
||||
ScriptableObject.CreateInstance<Defense25DRewardConstants>();
|
||||
try
|
||||
{
|
||||
Assert.That(saved, Is.Not.Null);
|
||||
Assert.That(saved.TowerModifierCatalog, Has.Length.EqualTo(32));
|
||||
Assert.That(defaults.TowerModifierCatalog, Has.Length.EqualTo(32));
|
||||
Assert.That(saved.RunSeed, Is.EqualTo(defaults.RunSeed));
|
||||
Assert.That(saved.ChoiceCount, Is.EqualTo(defaults.ChoiceCount));
|
||||
Assert.That(saved.LevelUpRarityWeights.Common,
|
||||
Is.EqualTo(defaults.LevelUpRarityWeights.Common));
|
||||
Assert.That(saved.LevelUpRarityWeights.Rare,
|
||||
Is.EqualTo(defaults.LevelUpRarityWeights.Rare));
|
||||
Assert.That(saved.LevelUpRarityWeights.Epic,
|
||||
Is.EqualTo(defaults.LevelUpRarityWeights.Epic));
|
||||
Assert.That(saved.GlobalEliteRarityWeights.Common,
|
||||
Is.EqualTo(defaults.GlobalEliteRarityWeights.Common));
|
||||
Assert.That(saved.GlobalEliteRarityWeights.Rare,
|
||||
Is.EqualTo(defaults.GlobalEliteRarityWeights.Rare));
|
||||
Assert.That(saved.GlobalEliteRarityWeights.Epic,
|
||||
Is.EqualTo(defaults.GlobalEliteRarityWeights.Epic));
|
||||
Assert.That(saved.PlayerRareEffectMultiplier,
|
||||
Is.EqualTo(defaults.PlayerRareEffectMultiplier));
|
||||
Assert.That(saved.PlayerEpicEffectMultiplier,
|
||||
Is.EqualTo(defaults.PlayerEpicEffectMultiplier));
|
||||
Assert.That(saved.EmptyEpicFallbackCurrency,
|
||||
Is.EqualTo(defaults.EmptyEpicFallbackCurrency));
|
||||
|
||||
var defaultById = defaults.TowerModifierCatalog
|
||||
.ToDictionary(definition => definition.DefinitionId);
|
||||
foreach (Defense25DTowerModifierDefinition savedDefinition
|
||||
in saved.TowerModifierCatalog)
|
||||
{
|
||||
Assert.That(defaultById.TryGetValue(
|
||||
savedDefinition.DefinitionId,
|
||||
out Defense25DTowerModifierDefinition defaultDefinition), Is.True,
|
||||
savedDefinition.DefinitionId);
|
||||
Assert.That(savedDefinition.Family, Is.EqualTo(defaultDefinition.Family));
|
||||
Assert.That(savedDefinition.Effect, Is.EqualTo(defaultDefinition.Effect));
|
||||
Assert.That(savedDefinition.Rarity, Is.EqualTo(defaultDefinition.Rarity));
|
||||
Assert.That(savedDefinition.Value, Is.EqualTo(defaultDefinition.Value));
|
||||
Assert.That(savedDefinition.MaximumStacks,
|
||||
Is.EqualTo(defaultDefinition.MaximumStacks));
|
||||
Assert.That(savedDefinition.DisplayName,
|
||||
Is.EqualTo(defaultDefinition.DisplayName));
|
||||
Assert.That(savedDefinition.Description,
|
||||
Is.EqualTo(defaultDefinition.Description));
|
||||
}
|
||||
|
||||
AssertCatalogCardDisplays(saved, saved.TowerModifierCatalog);
|
||||
AssertCatalogCardDisplays(defaults, defaults.TowerModifierCatalog);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(defaults);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssertCatalogCardDisplays(
|
||||
Defense25DRewardConstants source,
|
||||
Defense25DTowerModifierDefinition[] definitions)
|
||||
{
|
||||
foreach (Defense25DTowerModifierDefinition definition in definitions)
|
||||
{
|
||||
Defense25DRewardConstants singleCard = Object.Instantiate(source);
|
||||
singleCard.ChoiceCount = 1;
|
||||
singleCard.TowerModifierCatalog = new[] { definition };
|
||||
try
|
||||
{
|
||||
Defense25DRewardChoices choices = new(
|
||||
singleCard,
|
||||
System.Array.Empty<BumpCombat.Progression.ModifierDefinition>(),
|
||||
null,
|
||||
new Defense25DTowerModifiers(singleCard),
|
||||
_ => true);
|
||||
Assert.That(choices.QueueReward(
|
||||
Defense25DRewardSource.LevelUp,
|
||||
$"display:{definition.DefinitionId}"), Is.True);
|
||||
Assert.That(choices.CurrentChoices, Has.Count.EqualTo(1));
|
||||
|
||||
Defense25DRewardChoice choice = choices.CurrentChoices[0];
|
||||
Assert.That(choice.TargetKind,
|
||||
Is.EqualTo(Defense25DRewardTargetKind.TowerModifier));
|
||||
Assert.That(choice.DefinitionId, Is.EqualTo(definition.DefinitionId));
|
||||
Assert.That(choice.DisplayData.Title, Is.EqualTo(definition.DisplayName));
|
||||
Assert.That(choice.DisplayData.Description,
|
||||
Is.EqualTo(definition.Description));
|
||||
Assert.That(Regex.IsMatch(choice.DisplayData.Title, "[A-Za-z]"), Is.False,
|
||||
$"{definition.DefinitionId} title should be Korean.");
|
||||
Assert.That(Regex.IsMatch(choice.DisplayData.Description, "[A-Za-z]"), Is.False,
|
||||
$"{definition.DefinitionId} description should be Korean.");
|
||||
Assert.That(choice.DisplayData.Description, Does.Contain(ConfirmationText),
|
||||
definition.DefinitionId);
|
||||
|
||||
AssertDisplayedValueMatchesDefinition(definition,
|
||||
choice.DisplayData.Description);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(singleCard);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssertDisplayedValueMatchesDefinition(
|
||||
Defense25DTowerModifierDefinition definition,
|
||||
string description)
|
||||
{
|
||||
switch (definition.Effect)
|
||||
{
|
||||
case Defense25DTowerModifierEffect.AttackDamageIncreased:
|
||||
case Defense25DTowerModifierEffect.AreaRadiusIncreased:
|
||||
Match percent = Regex.Match(description,
|
||||
@"\+([0-9]+(?:\.[0-9]+)?)%");
|
||||
Assert.That(percent.Success, Is.True, definition.DefinitionId);
|
||||
float displayedPercent = float.Parse(
|
||||
percent.Groups[1].Value,
|
||||
CultureInfo.InvariantCulture);
|
||||
Assert.That(displayedPercent,
|
||||
Is.EqualTo(definition.Value * 100f).Within(0.001f),
|
||||
definition.DefinitionId);
|
||||
break;
|
||||
case Defense25DTowerModifierEffect.ChainCount:
|
||||
Assert.That(description, Does.Contain(
|
||||
$"연쇄 대상 +{Mathf.RoundToInt(definition.Value)}명"));
|
||||
break;
|
||||
case Defense25DTowerModifierEffect.AdditionalProjectile:
|
||||
Assert.That(description, Does.Contain(
|
||||
$"추가 투사체 +{Mathf.RoundToInt(definition.Value)}발"));
|
||||
break;
|
||||
case Defense25DTowerModifierEffect.AdditionalTarget:
|
||||
Assert.That(description, Does.Contain(
|
||||
$"동시 타격 대상 +{Mathf.RoundToInt(definition.Value)}개체"));
|
||||
break;
|
||||
case Defense25DTowerModifierEffect.SplitProjectile:
|
||||
Assert.That(description, Does.Contain("±25°"));
|
||||
Assert.That(description, Does.Contain("자식 투사체 2개"));
|
||||
Assert.That(description, Does.Contain("자식은 재분할하거나 복귀하지 않"));
|
||||
Assert.That(description, Does.Contain("원본 피해의 50%"));
|
||||
break;
|
||||
case Defense25DTowerModifierEffect.ReturnProjectile:
|
||||
Assert.That(description, Does.Contain("1회 복귀"));
|
||||
Assert.That(description, Does.Contain("복귀 피해는 원본의 50%"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3d02cf088eea4cf19d42d5b2f95120e3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Linq;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Defense.Comparison;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine.SceneManagement;
|
||||
|
||||
namespace BumpCombat.Tests
|
||||
{
|
||||
public sealed class Defense25DRunAssetTests
|
||||
{
|
||||
private const string WaveConstantsPath = "Assets/_Project/Constants/Defense25DComparison/LongDefenseWaveDirector.asset";
|
||||
private const string RunDefinitionPath = "Assets/_Project/Constants/Defense25DComparison/LongDefenseRun.asset";
|
||||
private const string MonsterPoolPath = "Assets/_Project/Constants/Defense25DComparison/MonsterPools.asset";
|
||||
private const string ArtifactDropConstantsPath = "Assets/_Project/Constants/Defense25DComparison/ArtifactDropConstants.asset";
|
||||
private const string ScenePath = "Assets/_Project/Scenes/Defense25DRun.unity";
|
||||
|
||||
[Test]
|
||||
public void SavedRunAssetsReloadAndSceneReferencesTheRunDefinition()
|
||||
{
|
||||
Defense25DWaveDirectorConstants waveConstants =
|
||||
AssetDatabase.LoadAssetAtPath<Defense25DWaveDirectorConstants>(WaveConstantsPath);
|
||||
Defense25DRunDefinition runDefinition =
|
||||
AssetDatabase.LoadAssetAtPath<Defense25DRunDefinition>(RunDefinitionPath);
|
||||
Defense25DMonsterPoolDefinition monsterPool =
|
||||
AssetDatabase.LoadAssetAtPath<Defense25DMonsterPoolDefinition>(MonsterPoolPath);
|
||||
Defense25DArtifactDropConstants artifactDropConstants =
|
||||
AssetDatabase.LoadAssetAtPath<Defense25DArtifactDropConstants>(ArtifactDropConstantsPath);
|
||||
|
||||
Assert.That(waveConstants, Is.Not.Null, "The Wave Director constants must be a persistent standalone asset.");
|
||||
Assert.That(runDefinition, Is.Not.Null, "The long-defense RunDefinition must be a persistent asset.");
|
||||
Assert.That(runDefinition.WaveDirectorConstants, Is.SameAs(waveConstants));
|
||||
Assert.That(monsterPool, Is.Not.Null);
|
||||
Assert.That(artifactDropConstants, Is.Not.Null);
|
||||
Assert.That(runDefinition.MonsterPoolDefinition, Is.SameAs(monsterPool));
|
||||
Assert.That(runDefinition.ArtifactDropConstants, Is.SameAs(artifactDropConstants));
|
||||
Assert.That(runDefinition.PreparationSeconds, Is.EqualTo(20f));
|
||||
Assert.That(waveConstants.SpawnWindowSeconds, Is.EqualTo(18f));
|
||||
Assert.That(waveConstants.MicroBreakSeconds, Is.EqualTo(6f));
|
||||
Assert.That(waveConstants.StrategyBreakEvery, Is.EqualTo(3));
|
||||
Assert.That(waveConstants.StrategyBreakSeconds, Is.EqualTo(15f));
|
||||
Assert.That(waveConstants.TelegraphSeconds, Is.EqualTo(8f));
|
||||
Assert.That(waveConstants.EliteFirstAtSeconds, Is.EqualTo(90f));
|
||||
Assert.That(waveConstants.EliteIntervalSeconds, Is.EqualTo(90f));
|
||||
Assert.That(waveConstants.StrongEventMinimumIntervalSeconds, Is.EqualTo(1f));
|
||||
Assert.That(waveConstants.MidBossTimes, Is.EqualTo(new[] { 240f, 480f }));
|
||||
Assert.That(waveConstants.ThreatCurve.Last().ThreatBudget, Is.EqualTo(84f));
|
||||
Assert.That(waveConstants.Validate(out string constantsError), Is.True, constantsError);
|
||||
Assert.That(runDefinition.PortalDefinitions.Select(portal => portal.PortalId).Distinct().Count(), Is.EqualTo(12));
|
||||
Assert.That(runDefinition.PortalDefinitions.All(portal => portal.TelegraphSeconds == 10f), Is.True);
|
||||
Assert.That(runDefinition.PortalDefinitions.Select(portal => portal.OpenAt),
|
||||
Is.EqualTo(new[] { 0f, 0f, 120f, 120f, 240f, 240f, 360f, 360f, 480f, 480f, 600f, 600f }));
|
||||
Assert.That(runDefinition.Validate(out string error), Is.True, error);
|
||||
|
||||
Scene scene = EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Additive);
|
||||
try
|
||||
{
|
||||
Defense25DRunSceneProfile[] profiles = scene.GetRootGameObjects()
|
||||
.Select(root => root.GetComponent<Defense25DRunSceneProfile>())
|
||||
.Where(profile => profile != null)
|
||||
.ToArray();
|
||||
Assert.That(profiles.Length, Is.EqualTo(1));
|
||||
Assert.That(profiles[0].RunDefinition, Is.SameAs(runDefinition));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (scene.IsValid() && scene.isLoaded)
|
||||
EditorSceneManager.CloseScene(scene, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ef70a1fec4f74cd2b4d2507fea87cc08
|
||||
MonoImporter
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,36 @@
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Defense.Comparison;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace BumpCombat.Tests
|
||||
{
|
||||
public sealed class Defense25DRunLayoutTests
|
||||
{
|
||||
[Test]
|
||||
public void SameSeedSelectsTheSameValidNonCentralHomeAndCreatesTwelveRoutesAndEighteenPads()
|
||||
{
|
||||
Defense25DBattlefieldDefinition battlefield = Defense25DBattlefieldDefinition.CreateDefault();
|
||||
|
||||
Assert.That(battlefield.TryCreateLayout(7319, out Defense25DRunLayout first, out string firstError),
|
||||
Is.True, firstError);
|
||||
Assert.That(battlefield.TryCreateLayout(7319, out Defense25DRunLayout replay, out string replayError),
|
||||
Is.True, replayError);
|
||||
|
||||
Assert.That(replay.HomePosition, Is.EqualTo(first.HomePosition));
|
||||
Assert.That(first.HomePosition, Is.Not.EqualTo(UnityEngine.Vector2.zero));
|
||||
Assert.That(first.RouteCount, Is.EqualTo(12));
|
||||
Assert.That(first.PadCount, Is.EqualTo(18));
|
||||
Assert.That(first.PlayerStart, Is.EqualTo(first.HomePosition + first.PlayerStartOffset));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LayoutFailsWhenNoHomeCandidateHasTheRequiredBranchClearance()
|
||||
{
|
||||
Defense25DBattlefieldDefinition battlefield = Defense25DBattlefieldDefinition.CreateDefault();
|
||||
battlefield.MinBranchClearance = 1000f;
|
||||
|
||||
Assert.That(battlefield.TryCreateLayout(1, out _, out string error), Is.False);
|
||||
Assert.That(error, Is.Not.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3ee07180776344089db4fa97970ce1c2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,424 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Defense.Comparison;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Tests
|
||||
{
|
||||
public sealed class Defense25DWaveDirectorTests
|
||||
{
|
||||
private static readonly float[] PortalOpenTimes = { 0f, 0f, 120f, 120f, 240f, 240f,
|
||||
360f, 360f, 480f, 480f, 600f, 600f };
|
||||
|
||||
[Test]
|
||||
public void NominalScheduleHasTwentySevenWavesAndBudgetTotals1440Before720()
|
||||
{
|
||||
Defense25DWaveDirector director = CreateDirector(8f);
|
||||
List<Defense25DWavePlan> plans = new();
|
||||
Defense25DWavePlan preview = director.GetNextPlanPreview();
|
||||
while (preview.NominalStart < 720f)
|
||||
{
|
||||
director.AdvanceTo(preview.ActualStart, _ => { });
|
||||
plans.Add(director.GetCurrentPlan());
|
||||
preview = director.GetNextPlanPreview();
|
||||
}
|
||||
|
||||
Assert.That(plans.Count, Is.EqualTo(27));
|
||||
Assert.That(Enumerable.Range(0, 6).Select(phase =>
|
||||
plans.Count(plan => Mathf.FloorToInt(plan.NominalStart / 120f) == phase)),
|
||||
Is.EqualTo(new[] { 5, 4, 5, 4, 5, 4 }));
|
||||
Assert.That(Enumerable.Range(0, 6).Select(phase => plans
|
||||
.Where(plan => Mathf.FloorToInt(plan.NominalStart / 120f) == phase)
|
||||
.Sum(plan => plan.ThreatBudget)),
|
||||
Is.EqualTo(new[] { 120f, 144f, 240f, 240f, 360f, 336f }));
|
||||
Assert.That(plans.Take(6).Select(plan => plan.NominalStart),
|
||||
Is.EqualTo(new[] { 0f, 24f, 48f, 81f, 105f, 129f }));
|
||||
Assert.That(plans.Sum(plan => plan.ThreatBudget), Is.EqualTo(1440f));
|
||||
Assert.That(plans.Single(plan => plan.NominalStart == 129f).TierId, Is.EqualTo(0));
|
||||
Assert.That(plans.Single(plan => plan.NominalStart == 186f).TierId, Is.EqualTo(1));
|
||||
Assert.That(plans.Single(plan => plan.NominalStart == 372f).TierId, Is.EqualTo(2));
|
||||
Assert.That(plans.Single(plan => plan.NominalStart == 129f).ThreatBudget, Is.EqualTo(36f));
|
||||
Assert.That(plans.Single(plan => plan.NominalStart == 243f).ThreatBudget, Is.EqualTo(48f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MonsterRosterTierUsesNominal180And360BoundariesIndependentOfBudgetCurve()
|
||||
{
|
||||
Defense25DWaveDirectorConstants constants = ScriptableObject.CreateInstance<Defense25DWaveDirectorConstants>();
|
||||
try
|
||||
{
|
||||
Assert.That(constants.TryGetMonsterTier(179.999f, out int before180), Is.True);
|
||||
Assert.That(before180, Is.EqualTo(0));
|
||||
Assert.That(constants.TryGetMonsterTier(180f, out int at180), Is.True);
|
||||
Assert.That(at180, Is.EqualTo(1));
|
||||
Assert.That(constants.TryGetMonsterTier(359.999f, out int before360), Is.True);
|
||||
Assert.That(before360, Is.EqualTo(1));
|
||||
Assert.That(constants.TryGetMonsterTier(360f, out int at360), Is.True);
|
||||
Assert.That(at360, Is.EqualTo(2));
|
||||
|
||||
Assert.That(constants.TryGetThreat(180f, out float budgetAt180), Is.True);
|
||||
Assert.That(budgetAt180, Is.EqualTo(36f));
|
||||
Assert.That(constants.TryGetThreat(240f, out float budgetAt240), Is.True);
|
||||
Assert.That(budgetAt240, Is.EqualTo(48f));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(constants);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ProductionDirectorConsumesThePoolCompositionAndDoesNotRecomposeIt()
|
||||
{
|
||||
Defense25DRunDefinition definition = AssetDatabase.LoadAssetAtPath<Defense25DRunDefinition>(
|
||||
"Assets/_Project/Constants/Defense25DComparison/LongDefenseRun.asset");
|
||||
Assert.That(definition, Is.Not.Null);
|
||||
Assert.That(definition.MonsterPoolDefinition, Is.Not.Null);
|
||||
const int waveSeed = 1537;
|
||||
Defense25DWaveDirector director = new(definition, 7, waveSeed);
|
||||
List<Defense25DSpawnRequest> emitted = new();
|
||||
director.WaveStarted += _ => { };
|
||||
|
||||
director.AdvanceTo(18f, emitted.Add);
|
||||
|
||||
Defense25DWavePlan plan = director.GetCurrentPlan();
|
||||
Defense25DWaveComposition expected = definition.MonsterPoolDefinition.BuildWaveComposition(
|
||||
plan.WaveId, plan.NominalStart, plan.ThreatBudget, waveSeed);
|
||||
Assert.That(plan.TierId, Is.EqualTo(0));
|
||||
Assert.That(plan.Composition, Is.Not.Null);
|
||||
Assert.That(plan.Composition.EnemyDefinitionIds.OrderBy(id => id),
|
||||
Is.EqualTo(expected.EnemyDefinitionIds.OrderBy(id => id)));
|
||||
Assert.That(plan.Composition.ThreatCosts.OrderBy(cost => cost),
|
||||
Is.EqualTo(expected.ThreatCosts.OrderBy(cost => cost)));
|
||||
Assert.That(emitted.All(request => request.Source == Defense25DSpawnSource.Normal), Is.True);
|
||||
Assert.That(emitted.Select(request => request.EnemyDefinitionId).OrderBy(id => id),
|
||||
Is.EqualTo(expected.EnemyDefinitionIds.OrderBy(id => id)));
|
||||
Assert.That(emitted.Sum(request => request.ThreatCost),
|
||||
Is.EqualTo(plan.Composition.ComposedThreat).Within(.001f));
|
||||
Assert.That(plan.ThreatBudget, Is.EqualTo(24f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ThreatCostFixtureReportsCompositionOvershootSeparatelyFromNominalBudget()
|
||||
{
|
||||
Defense25DWaveDirector director = CreateDirector(8f);
|
||||
List<Defense25DSpawnRequest> emitted = new();
|
||||
director.AdvanceTo(723f, emitted.Add);
|
||||
Defense25DSpawnRequest[][] waves = emitted.Where(request => request.Source == Defense25DSpawnSource.Normal)
|
||||
.GroupBy(request => request.GlobalWaveId).Select(group => group.ToArray()).ToArray();
|
||||
|
||||
Assert.That(waves.Length, Is.EqualTo(27));
|
||||
Assert.That(waves.Sum(wave => wave[0].ThreatBudget), Is.EqualTo(1440f));
|
||||
float actualThreat = waves.Sum(wave => wave.Sum(request => request.ThreatCost));
|
||||
Assert.That(actualThreat, Is.GreaterThan(1440f));
|
||||
Assert.That(waves.All(wave => wave.Sum(request => request.ThreatCost) - wave[0].ThreatBudget <= 8f + .0001f),
|
||||
Is.True, "This fixture uses a single ThreatCost=8 entry; each wave can overshoot by at most that cost.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryNodeSpawnStaysInsideEighteenSecondWindowAndUsesAtMostOnePointFiveSecondSpacing()
|
||||
{
|
||||
Defense25DWaveDirector director = CreateDirector(1f);
|
||||
List<Defense25DSpawnRequest> emitted = new();
|
||||
director.AdvanceTo(723f, emitted.Add);
|
||||
Defense25DSpawnRequest[] normal = emitted.Where(request => request.Source == Defense25DSpawnSource.Normal).ToArray();
|
||||
|
||||
Assert.That(normal, Is.Not.Empty);
|
||||
Assert.That(normal.All(request => request.ScheduledAt >= request.ActualStart - .0001f &&
|
||||
request.ScheduledAt - request.ActualStart <= 18f + .0001f), Is.True);
|
||||
foreach (var node in normal.GroupBy(request => new { request.GlobalWaveId, request.PortalId }))
|
||||
{
|
||||
float[] schedule = node.Select(request => request.ScheduledAt).OrderBy(time => time).ToArray();
|
||||
for (int i = 1; i < schedule.Length; i++)
|
||||
Assert.That(schedule[i] - schedule[i - 1], Is.LessThanOrEqualTo(1.5f + .0001f));
|
||||
Assert.That(schedule[schedule.Length - 1] - node.First().ActualStart, Is.LessThanOrEqualTo(18f + .0001f));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReliefKeepsWaveThreeRequestsAndHoldsANewWaveUntilReliefEnds()
|
||||
{
|
||||
Defense25DWaveDirector director = CreateDirector(1f);
|
||||
List<Defense25DSpawnRequest> emitted = new();
|
||||
director.AdvanceTo(48f, emitted.Add);
|
||||
director.RegisterReliefWindow("first-artifact", 60f, 30f);
|
||||
|
||||
director.AdvanceTo(105f, emitted.Add);
|
||||
|
||||
Defense25DSpawnRequest[] waveThree = emitted.Where(request => request.Source == Defense25DSpawnSource.Normal &&
|
||||
request.GlobalWaveId == 3).ToArray();
|
||||
Assert.That(waveThree.Any(request => request.ScheduledAt > 60f && request.ScheduledAt <= 66f), Is.True,
|
||||
"Wave 3 began at 48 seconds; its existing 60–66 second requests continue during relief.");
|
||||
Defense25DSpawnRequest[] waveFour = emitted.Where(request => request.Source == Defense25DSpawnSource.Normal &&
|
||||
request.GlobalWaveId == 4).ToArray();
|
||||
Assert.That(waveFour, Is.Not.Empty);
|
||||
Assert.That(waveFour.All(request => request.ActualStart >= 90f), Is.True,
|
||||
"The nominal 81-second wave is delayed until the overlapping relief ends at 90 seconds.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReliefHoldsNewGlobalEliteAndMidBossStartsUntilTheirWindowEnds()
|
||||
{
|
||||
Defense25DWaveDirector director = CreateDirector(1f);
|
||||
List<Defense25DSpawnRequest> emitted = new();
|
||||
director.RegisterReliefWindow("elite-relief", 90f, 12f);
|
||||
director.RegisterReliefWindow("midboss-relief", 240f, 15f);
|
||||
|
||||
director.AdvanceTo(101f, emitted.Add);
|
||||
Assert.That(emitted.Any(request => request.Source == Defense25DSpawnSource.GlobalElite), Is.False);
|
||||
director.AdvanceTo(102f, emitted.Add);
|
||||
Defense25DSpawnRequest elite = emitted.Single(request => request.Source == Defense25DSpawnSource.GlobalElite);
|
||||
Assert.That(elite.NominalStart, Is.EqualTo(90f));
|
||||
Assert.That(elite.ScheduledAt, Is.EqualTo(102f));
|
||||
|
||||
director.AdvanceTo(254f, emitted.Add);
|
||||
Assert.That(emitted.Any(request => request.Source == Defense25DSpawnSource.GlobalMidBoss), Is.False);
|
||||
director.AdvanceTo(255f, emitted.Add);
|
||||
Defense25DSpawnRequest midBoss = emitted.Single(request => request.Source == Defense25DSpawnSource.GlobalMidBoss);
|
||||
Assert.That(midBoss.NominalStart, Is.EqualTo(240f));
|
||||
Assert.That(midBoss.ScheduledAt, Is.EqualTo(255f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NextStrongEventPreviewTracksReliefAndFinalBossSeparationWithoutAdvancing()
|
||||
{
|
||||
Defense25DWaveDirector director = CreateDirector(1f);
|
||||
List<Defense25DSpawnRequest> emitted = new();
|
||||
|
||||
Assert.That(director.TryGetNextStrongEventPreview(out Defense25DSpawnSource source,
|
||||
out float actualStart), Is.True);
|
||||
Assert.That(source, Is.EqualTo(Defense25DSpawnSource.GlobalElite));
|
||||
Assert.That(actualStart, Is.EqualTo(90f));
|
||||
|
||||
director.RegisterReliefWindow("elite-preview-relief", 90f, 12f);
|
||||
Assert.That(director.TryGetNextStrongEventPreview(out source, out actualStart), Is.True);
|
||||
Assert.That(source, Is.EqualTo(Defense25DSpawnSource.GlobalElite));
|
||||
Assert.That(actualStart, Is.EqualTo(102f));
|
||||
|
||||
director.AdvanceTo(101f, emitted.Add);
|
||||
Assert.That(emitted.Any(request => request.Source == Defense25DSpawnSource.GlobalElite), Is.False);
|
||||
Assert.That(director.TryGetNextStrongEventPreview(out source, out actualStart), Is.True);
|
||||
Assert.That(source, Is.EqualTo(Defense25DSpawnSource.GlobalElite));
|
||||
Assert.That(actualStart, Is.EqualTo(102f));
|
||||
|
||||
director.AdvanceTo(102f, emitted.Add);
|
||||
Assert.That(emitted.Single(request => request.Source == Defense25DSpawnSource.GlobalElite).ScheduledAt,
|
||||
Is.EqualTo(102f));
|
||||
director.AdvanceTo(720f, emitted.Add);
|
||||
Assert.That(emitted.Single(request => request.Source == Defense25DSpawnSource.FinalBoss).ScheduledAt,
|
||||
Is.EqualTo(720f));
|
||||
Assert.That(director.TryGetNextStrongEventPreview(out source, out actualStart), Is.True);
|
||||
Assert.That(source, Is.EqualTo(Defense25DSpawnSource.GlobalElite));
|
||||
Assert.That(actualStart, Is.EqualTo(721f),
|
||||
"The next Elite preview preserves the configured minimum interval after FinalBoss.");
|
||||
|
||||
director.StopAllSupply();
|
||||
Assert.That(director.TryGetNextStrongEventPreview(out _, out actualStart), Is.False);
|
||||
Assert.That(float.IsPositiveInfinity(actualStart), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EarlyCallChangesOnlyTheNextActualStartAndStopAllSupplyIsLatched()
|
||||
{
|
||||
Defense25DWaveDirector director = CreateDirector(1f);
|
||||
director.AdvanceTo(18f, _ => { });
|
||||
Defense25DWavePlan nominalPreview = director.GetNextPlanPreview();
|
||||
Assert.That(director.TryCallNextWaveEarly(18f, out EarlyCallResult result), Is.True);
|
||||
Assert.That(result.WaveId, Is.EqualTo(2));
|
||||
Assert.That(result.ActualStart, Is.EqualTo(18f));
|
||||
Assert.That(result.SkippedSeconds, Is.EqualTo(6f));
|
||||
Assert.That(result.RewardCurrency, Is.EqualTo(2));
|
||||
Assert.That(director.TryCallNextWaveEarly(18f, out _), Is.False);
|
||||
Defense25DWavePlan earlyPreview = director.GetNextPlanPreview();
|
||||
Assert.That(earlyPreview.NominalStart, Is.EqualTo(nominalPreview.NominalStart));
|
||||
Assert.That(earlyPreview.TierId, Is.EqualTo(nominalPreview.TierId));
|
||||
Assert.That(earlyPreview.ThreatBudget, Is.EqualTo(nominalPreview.ThreatBudget));
|
||||
Assert.That(earlyPreview.ActivePortalIds, Is.EqualTo(nominalPreview.ActivePortalIds));
|
||||
Assert.That(earlyPreview.NodePlans.SelectMany(node => node.Entries).Select(entry => entry.EnemyDefinitionId),
|
||||
Is.EqualTo(nominalPreview.NodePlans.SelectMany(node => node.Entries).Select(entry => entry.EnemyDefinitionId)));
|
||||
Assert.That(earlyPreview.NodePlans.SelectMany(node => node.ScheduledAt)
|
||||
.Zip(nominalPreview.NodePlans.SelectMany(node => node.ScheduledAt), (early, nominal) => early - nominal)
|
||||
.All(delta => Mathf.Abs(delta + 6f) <= .0001f), Is.True);
|
||||
|
||||
director.AdvanceTo(18f, _ => { });
|
||||
Assert.That(director.GetCurrentPlan().ActivePortalIds, Is.EqualTo(nominalPreview.ActivePortalIds));
|
||||
|
||||
director.StopAllSupply();
|
||||
List<Defense25DSpawnRequest> afterStop = new();
|
||||
director.AdvanceTo(800f, afterStop.Add);
|
||||
Assert.That(afterStop, Is.Empty);
|
||||
Assert.That(director.TryCallNextWaveEarly(800f, out _), Is.False);
|
||||
Assert.That(director.GetNextPlanPreview(), Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void StopAllSupplyFreezesPortalOpenStateAndRemovesFutureOpenSchedule()
|
||||
{
|
||||
Defense25DWaveDirector director = CreateDirector(1f, finalBossAt: 120f);
|
||||
List<Defense25DSpawnRequest> emitted = new();
|
||||
director.AdvanceTo(119f, emitted.Add);
|
||||
Assert.That(Enumerable.Range(0, 12).Where(director.IsPortalOpen), Is.EqualTo(new[] { 0, 1 }));
|
||||
Assert.That(director.GetNextPortalOpenAt(), Is.EqualTo(120f));
|
||||
|
||||
director.StopAllSupply();
|
||||
director.AdvanceTo(800f, emitted.Add);
|
||||
|
||||
Assert.That(Enumerable.Range(0, 12).Where(director.IsPortalOpen), Is.EqualTo(new[] { 0, 1 }));
|
||||
Assert.That(director.IsPortalOpen(2), Is.False);
|
||||
Assert.That(director.GetNextPortalOpenAt(), Is.EqualTo(float.PositiveInfinity));
|
||||
Assert.That(emitted.Any(request => request.Source == Defense25DSpawnSource.FinalBoss), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EarlyCallCannotUseANodeThatWasClosedAtTheEarlyStart()
|
||||
{
|
||||
Defense25DWaveDirector director = CreateDirector(1f, forceSixNodes: true);
|
||||
director.AdvanceTo(228f, _ => { });
|
||||
Defense25DWavePlan preview = director.GetNextPlanPreview();
|
||||
Assert.That(preview.NominalStart, Is.EqualTo(243f));
|
||||
Assert.That(preview.ActivePortalIds, Does.Contain(4));
|
||||
|
||||
Assert.That(director.TryCallNextWaveEarly(228f, out _), Is.False);
|
||||
Assert.That(director.GetNextPlanPreview().ActualStart, Is.EqualTo(243f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PrimaryNodeAvoidsThePreviousTwoWheneverAnotherOpenNodeExists()
|
||||
{
|
||||
Defense25DWaveDirector director = CreateDirector(1f, forceSingleNode: true);
|
||||
List<Defense25DWavePlan> plans = new();
|
||||
Defense25DWavePlan preview = director.GetNextPlanPreview();
|
||||
while (preview.NominalStart < 720f)
|
||||
{
|
||||
director.AdvanceTo(preview.ActualStart, _ => { });
|
||||
plans.Add(director.GetCurrentPlan());
|
||||
preview = director.GetNextPlanPreview();
|
||||
}
|
||||
|
||||
Assert.That(plans.All(plan => plan.NodePlans.Count == 1), Is.True);
|
||||
for (int i = 2; i < plans.Count; i++)
|
||||
{
|
||||
int current = plans[i].PrimaryPortalId;
|
||||
int priorOne = plans[i - 1].PrimaryPortalId;
|
||||
int priorTwo = plans[i - 2].PrimaryPortalId;
|
||||
bool alternativeExists = PortalOpenTimes.Select((openAt, id) => new { openAt, id })
|
||||
.Any(portal => portal.openAt <= plans[i].NominalStart &&
|
||||
portal.id != priorOne && portal.id != priorTwo);
|
||||
if (alternativeExists)
|
||||
Assert.That(current, Is.Not.EqualTo(priorOne).And.Not.EqualTo(priorTwo));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void StopAllSupplyFromEmitCallbackDoesNotInvalidateTheDispatchLoop()
|
||||
{
|
||||
Defense25DWaveDirector director = CreateDirector(1f);
|
||||
int emittedCount = 0;
|
||||
|
||||
Assert.DoesNotThrow(() => director.AdvanceTo(24f, _ =>
|
||||
{
|
||||
emittedCount++;
|
||||
director.StopAllSupply();
|
||||
}));
|
||||
|
||||
Assert.That(emittedCount, Is.EqualTo(1));
|
||||
Assert.That(director.IsSupplyStopped, Is.True);
|
||||
Assert.DoesNotThrow(() => director.AdvanceTo(800f, _ => emittedCount++));
|
||||
Assert.That(emittedCount, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void InvalidWaveConstantsRejectZeroWindowBeforeScheduling()
|
||||
{
|
||||
Defense25DWaveDirectorConstants constants = ScriptableObject.CreateInstance<Defense25DWaveDirectorConstants>();
|
||||
constants.SpawnWindowSeconds = 0f;
|
||||
|
||||
Assert.That(constants.Validate(out string error), Is.False);
|
||||
Assert.That(error, Is.Not.Empty);
|
||||
Assert.Throws<System.ArgumentException>(() => new Defense25DWaveDirector(constants,
|
||||
CreatePortals(), 1, 7, _ => new[] { new Defense25DWaveEnemyEntry("fixture", 1f) }));
|
||||
Object.DestroyImmediate(constants);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void InvalidWaveConstantsRejectZeroBreakDurations()
|
||||
{
|
||||
Defense25DWaveDirectorConstants constants = ScriptableObject.CreateInstance<Defense25DWaveDirectorConstants>();
|
||||
try
|
||||
{
|
||||
constants.MicroBreakSeconds = 0f;
|
||||
Assert.That(constants.Validate(out string microBreakError), Is.False);
|
||||
Assert.That(microBreakError, Is.Not.Empty);
|
||||
|
||||
constants.MicroBreakSeconds = 6f;
|
||||
constants.StrategyBreakSeconds = 0f;
|
||||
Assert.That(constants.Validate(out string strategyBreakError), Is.False);
|
||||
Assert.That(strategyBreakError, Is.Not.Empty);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(constants);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PortalOpeningAndGlobalStrongEventsUseTheirOwnClocks()
|
||||
{
|
||||
Defense25DWaveDirector director = CreateDirector(1f);
|
||||
List<Defense25DSpawnRequest> emitted = new();
|
||||
director.AdvanceTo(119f, emitted.Add);
|
||||
Assert.That(Enumerable.Range(0, 12).Count(director.IsPortalOpen), Is.EqualTo(2));
|
||||
Assert.That(director.GetNextPortalOpenAt(), Is.EqualTo(120f));
|
||||
|
||||
float[] portalOpenTimes = { 120f, 240f, 360f, 480f, 600f };
|
||||
for (int i = 0; i < portalOpenTimes.Length; i++)
|
||||
{
|
||||
director.AdvanceTo(portalOpenTimes[i], emitted.Add);
|
||||
Assert.That(Enumerable.Range(0, 12).Count(director.IsPortalOpen), Is.EqualTo((i + 2) * 2));
|
||||
}
|
||||
|
||||
director.AdvanceTo(720f, emitted.Add);
|
||||
Assert.That(emitted.Count(request => request.Source == Defense25DSpawnSource.GlobalElite), Is.EqualTo(7));
|
||||
Assert.That(emitted.Where(request => request.Source == Defense25DSpawnSource.GlobalElite)
|
||||
.Select(request => request.ScheduledAt).Distinct().OrderBy(time => time),
|
||||
Is.EqualTo(new[] { 90f, 180f, 270f, 360f, 450f, 540f, 630f }));
|
||||
Assert.That(emitted.Count(request => request.Source == Defense25DSpawnSource.GlobalMidBoss), Is.EqualTo(2));
|
||||
Assert.That(emitted.Count(request => request.Source == Defense25DSpawnSource.FinalBoss), Is.EqualTo(1));
|
||||
Assert.That(emitted.Single(request => request.Source == Defense25DSpawnSource.FinalBoss).ScheduledAt,
|
||||
Is.EqualTo(720f));
|
||||
|
||||
director.AdvanceTo(721f, emitted.Add);
|
||||
Defense25DSpawnRequest eighthElite = emitted.Single(request => request.Source == Defense25DSpawnSource.GlobalElite &&
|
||||
request.NominalStart == 720f);
|
||||
Assert.That(eighthElite.ScheduledAt, Is.EqualTo(721f));
|
||||
Assert.That(eighthElite.ScheduledAt - emitted.Single(request => request.Source == Defense25DSpawnSource.FinalBoss).ScheduledAt,
|
||||
Is.GreaterThanOrEqualTo(1f));
|
||||
Assert.That(Enumerable.Range(0, 12).Count(director.IsPortalOpen), Is.EqualTo(12));
|
||||
}
|
||||
|
||||
private static Defense25DWaveDirector CreateDirector(float threatCost,
|
||||
bool forceSingleNode = false, bool forceSixNodes = false, float finalBossAt = 720f)
|
||||
{
|
||||
Defense25DWaveDirectorConstants constants = ScriptableObject.CreateInstance<Defense25DWaveDirectorConstants>();
|
||||
if (forceSingleNode || forceSixNodes)
|
||||
{
|
||||
int nodeCount = forceSingleNode ? 1 : 6;
|
||||
for (int i = 0; i < constants.ActiveNodeRangeCurve.Length; i++)
|
||||
constants.ActiveNodeRangeCurve[i] = new Defense25DActiveNodeRangePoint
|
||||
{
|
||||
EndTimeSeconds = constants.ActiveNodeRangeCurve[i].EndTimeSeconds,
|
||||
Minimum = nodeCount,
|
||||
Maximum = nodeCount,
|
||||
};
|
||||
}
|
||||
return new Defense25DWaveDirector(constants, CreatePortals(), 1, 12345,
|
||||
_ => new[] { new Defense25DWaveEnemyEntry("fixture", threatCost, 1f) }, finalBossAt);
|
||||
}
|
||||
|
||||
private static Defense25DPortalDefinition[] CreatePortals() => PortalOpenTimes.Select((openAt, id) =>
|
||||
Defense25DPortalDefinition.Create(id, openAt, 10f, id, false, Defense25DTowerKindId.None)).ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4f6199daf150450386c929dd03a7cef6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -70,6 +70,17 @@ namespace BumpCombat.Tests
|
||||
Assert.That(ledger.KillEventCount, Is.Zero);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RegisterSuppliesAtomically_RejectsInvalidBatchWithoutPartialLedgerEntries()
|
||||
{
|
||||
DefenseWaveLedger ledger = new(4);
|
||||
Assert.That(ledger.RegisterSupply(8), Is.True);
|
||||
Assert.That(ledger.RegisterSuppliesAtomically(new[] { 9, 8, 10 }), Is.False);
|
||||
Assert.That(ledger.SuppliedCount, Is.EqualTo(1));
|
||||
Assert.That(ledger.RegisterSuppliesAtomically(new[] { 9, 10 }), Is.True);
|
||||
Assert.That(ledger.SuppliedCount, Is.EqualTo(3));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Reward_IsAvailableOnceAfterSupplyAndAllOwnedEnemiesResolve()
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using BumpCombat.Core;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Progression;
|
||||
@@ -37,18 +38,68 @@ namespace BumpCombat.Tests
|
||||
ModifierOperation.Increased,
|
||||
0.1f));
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
experience.AddExperience(1);
|
||||
}
|
||||
int levelEvents = 0;
|
||||
int experienceEvents = 0;
|
||||
experience.OnLevelGained += _ => levelEvents++;
|
||||
experience.OnExperienceChanged += (_, _, _) => experienceEvents++;
|
||||
|
||||
experience.AddExperience(10);
|
||||
|
||||
Assert.That(experience.Level, Is.EqualTo(2));
|
||||
Assert.That(experience.CurrentExperience, Is.EqualTo(1));
|
||||
Assert.That(levelEvents, Is.EqualTo(1));
|
||||
Assert.That(experienceEvents, Is.EqualTo(1));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(owner);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ExperienceProgress_AddEvaluatedCarriesFractionsAcrossMultipleLevels()
|
||||
{
|
||||
ExperienceProgress progress = new();
|
||||
List<int> gainedLevels = new();
|
||||
List<(int Current, int Required, int Level)> changes = new();
|
||||
progress.LevelGained += gainedLevels.Add;
|
||||
progress.ExperienceChanged += (current, required, level) =>
|
||||
changes.Add((current, required, level));
|
||||
|
||||
progress.Reset();
|
||||
progress.AddEvaluated(0.6f);
|
||||
progress.AddEvaluated(0.6f);
|
||||
|
||||
Assert.That(progress.Level, Is.EqualTo(1));
|
||||
Assert.That(progress.CurrentExperience, Is.EqualTo(1));
|
||||
Assert.That(gainedLevels, Is.Empty);
|
||||
|
||||
progress.AddEvaluated(
|
||||
ExperienceSystem.RequiredExperienceForLevel(1)
|
||||
+ ExperienceSystem.RequiredExperienceForLevel(2)
|
||||
+ 1.5f);
|
||||
|
||||
Assert.That(progress.Level, Is.EqualTo(3));
|
||||
Assert.That(progress.CurrentExperience, Is.EqualTo(2));
|
||||
Assert.That(progress.RequiredExperience,
|
||||
Is.EqualTo(ExperienceSystem.RequiredExperienceForLevel(3)));
|
||||
Assert.That(gainedLevels, Is.EqualTo(new[] { 2, 3 }));
|
||||
Assert.That(changes.Count, Is.EqualTo(3));
|
||||
Assert.That(changes[^1], Is.EqualTo((2,
|
||||
ExperienceSystem.RequiredExperienceForLevel(3), 3)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ExperienceProgress_ResetClearsFractionalExperience()
|
||||
{
|
||||
ExperienceProgress progress = new();
|
||||
progress.AddEvaluated(0.6f);
|
||||
|
||||
progress.Reset();
|
||||
progress.AddEvaluated(0.6f);
|
||||
|
||||
Assert.That(progress.Level, Is.EqualTo(1));
|
||||
Assert.That(progress.CurrentExperience, Is.EqualTo(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Defense.Comparison;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace BumpCombat.Tests
|
||||
{
|
||||
public sealed class Defense25DArtifactDropPlayModeTests
|
||||
{
|
||||
private sealed class ScheduleRecord
|
||||
{
|
||||
public readonly List<int> SpawnOrder = new();
|
||||
public readonly List<(string id, float start, float duration)> Reliefs = new();
|
||||
}
|
||||
|
||||
private GameObject worldObject;
|
||||
private Defense25DComparison world;
|
||||
private Defense25DArtifactDropConstants settings;
|
||||
private Defense25DComparisonSettings comparisonSettings;
|
||||
private Defense25DRunDefinition runDefinition;
|
||||
private float timeScaleBeforeInteractionTest;
|
||||
private bool restoreInteractionTestTimeScale;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
worldObject = new GameObject("Artifact drop test world");
|
||||
world = worldObject.AddComponent<Defense25DComparison>();
|
||||
settings = ScriptableObject.CreateInstance<Defense25DArtifactDropConstants>();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
if (restoreInteractionTestTimeScale)
|
||||
{
|
||||
Time.timeScale = timeScaleBeforeInteractionTest;
|
||||
restoreInteractionTestTimeScale = false;
|
||||
Assert.That(Time.timeScale, Is.EqualTo(timeScaleBeforeInteractionTest));
|
||||
Debug.Log($"[ArtifactDropInteraction] teardown restored timeScale={Time.timeScale}");
|
||||
}
|
||||
|
||||
if (worldObject != null) Object.DestroyImmediate(worldObject);
|
||||
if (settings != null) Object.DestroyImmediate(settings);
|
||||
if (comparisonSettings != null) Object.DestroyImmediate(comparisonSettings);
|
||||
if (runDefinition != null) Object.DestroyImmediate(runDefinition);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DropsUseFirstActualPortalOpenAndRegisterEachReliefWindowOnlyOnce()
|
||||
{
|
||||
Assert.That(Defense25DBattlefieldDefinition.CreateDefault().TryCreateLayout(
|
||||
8026, out Defense25DRunLayout layout, out string error), Is.True, error);
|
||||
Defense25DArtifactDrops drops = new();
|
||||
drops.Bind(world, layout, settings, 419);
|
||||
List<(string id, float start, float duration)> reliefs = new();
|
||||
int spawned = 0;
|
||||
drops.ReliefWindowRequested += (id, start, duration) => reliefs.Add((id, start, duration));
|
||||
drops.DropSpawned += _ => spawned++;
|
||||
|
||||
drops.NotifyPortalOpened(0, 12.5f);
|
||||
drops.NotifyPortalOpened(1, 20f);
|
||||
drops.Step(72.49f);
|
||||
Assert.That(drops.DropStates[0].Status, Is.EqualTo(Defense25DArtifactDropStatus.Scheduled));
|
||||
|
||||
drops.Step(72.5f);
|
||||
Assert.That(drops.DropStates[0].Status, Is.EqualTo(Defense25DArtifactDropStatus.Available));
|
||||
Assert.That(drops.DropStates[0].AvailableAt, Is.EqualTo(72.5f).Within(.001f));
|
||||
|
||||
drops.Step(360f);
|
||||
drops.Step(360f);
|
||||
|
||||
Assert.That(spawned, Is.EqualTo(3));
|
||||
Assert.That(drops.DropStates.Count, Is.EqualTo(3));
|
||||
Assert.That(drops.DropStates[1].Status, Is.EqualTo(Defense25DArtifactDropStatus.Available));
|
||||
Assert.That(drops.DropStates[2].Status, Is.EqualTo(Defense25DArtifactDropStatus.Available));
|
||||
Assert.That(reliefs, Is.EqualTo(new[]
|
||||
{
|
||||
("artifact-drop-0-0", 72.5f, 12f),
|
||||
("artifact-drop-0-1", 180f, 15f),
|
||||
("artifact-drop-0-2", 360f, 15f),
|
||||
}));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LargeDeltaAndOneSecondStepsKeepReliefWindowsAtTheirScheduledTimes()
|
||||
{
|
||||
Assert.That(Defense25DBattlefieldDefinition.CreateDefault().TryCreateLayout(
|
||||
6028, out Defense25DRunLayout layout, out string error), Is.True, error);
|
||||
|
||||
ScheduleRecord largeDelta = CaptureSchedule(layout, useOneSecondSteps: false);
|
||||
ScheduleRecord smallSteps = CaptureSchedule(layout, useOneSecondSteps: true);
|
||||
|
||||
Assert.That(largeDelta.SpawnOrder, Is.EqualTo(new[] { 0, 1 }));
|
||||
Assert.That(largeDelta.Reliefs, Is.EqualTo(new[]
|
||||
{
|
||||
("artifact-drop-0-0", 60f, 12f),
|
||||
("artifact-drop-0-1", 180f, 15f),
|
||||
}));
|
||||
Assert.That(smallSteps.SpawnOrder, Is.EqualTo(largeDelta.SpawnOrder));
|
||||
Assert.That(smallSteps.Reliefs, Is.EqualTo(largeDelta.Reliefs));
|
||||
Assert.That(largeDelta.Reliefs[0].start, Is.InRange(60f, 72f));
|
||||
Assert.That(largeDelta.Reliefs[1].start, Is.InRange(180f, 195f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LateFirstPortalDropIsEmittedAfterEarlierScheduledLaterDrop()
|
||||
{
|
||||
Assert.That(Defense25DBattlefieldDefinition.CreateDefault().TryCreateLayout(
|
||||
6029, out Defense25DRunLayout layout, out string error), Is.True, error);
|
||||
Defense25DArtifactDrops drops = new();
|
||||
drops.Bind(world, layout, settings, 419);
|
||||
ScheduleRecord record = new();
|
||||
drops.DropSpawned += state => record.SpawnOrder.Add(state.DropId);
|
||||
drops.ReliefWindowRequested += (id, start, duration) => record.Reliefs.Add((id, start, duration));
|
||||
|
||||
drops.NotifyPortalOpened(0, 130f);
|
||||
drops.Step(200f);
|
||||
|
||||
Assert.That(drops.DropStates[0].AvailableAt, Is.EqualTo(190f).Within(.001f));
|
||||
Assert.That(drops.DropStates[1].AvailableAt, Is.EqualTo(180f).Within(.001f));
|
||||
Assert.That(record.SpawnOrder, Is.EqualTo(new[] { 1, 0 }));
|
||||
Assert.That(record.Reliefs, Is.EqualTo(new[]
|
||||
{
|
||||
("artifact-drop-0-1", 180f, 15f),
|
||||
("artifact-drop-0-0", 190f, 12f),
|
||||
}));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator InteractedDropSnapshotsCandidatesAndClaimsOnlyTheChosenArtifactOnce()
|
||||
{
|
||||
timeScaleBeforeInteractionTest = Time.timeScale;
|
||||
restoreInteractionTestTimeScale = true;
|
||||
Debug.Log($"[ArtifactDropInteraction] start frame={Time.frameCount} timeScale={timeScaleBeforeInteractionTest}");
|
||||
Time.timeScale = 1f;
|
||||
|
||||
comparisonSettings = Object.Instantiate(AssetDatabase.LoadAssetAtPath<Defense25DComparisonSettings>(
|
||||
"Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset"));
|
||||
Assert.That(comparisonSettings, Is.Not.Null);
|
||||
runDefinition = Object.Instantiate(AssetDatabase.LoadAssetAtPath<Defense25DRunDefinition>(
|
||||
"Assets/_Project/Constants/Defense25DComparison/LongDefenseRun.asset"));
|
||||
Assert.That(runDefinition, Is.Not.Null);
|
||||
runDefinition.PreparationSeconds = 0f;
|
||||
world.InitializeLongRun(comparisonSettings, runDefinition, layoutSeed: 1741);
|
||||
world.AutoSpawn = false;
|
||||
world.ReadInput = false;
|
||||
Debug.Log($"[ArtifactDropInteraction] before null frame frame={Time.frameCount} timeScale={Time.timeScale}");
|
||||
yield return null;
|
||||
Debug.Log($"[ArtifactDropInteraction] before fixed update frame={Time.frameCount} timeScale={Time.timeScale}");
|
||||
yield return new WaitForFixedUpdate();
|
||||
Debug.Log($"[ArtifactDropInteraction] after fixed update frame={Time.frameCount} timeScale={Time.timeScale}");
|
||||
Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Running));
|
||||
|
||||
Defense25DArtifactDrops drops = new();
|
||||
drops.Bind(world, world.RunLayout, settings, 508);
|
||||
world.Player.ResetAt(drops.DropStates[0].Position);
|
||||
drops.NotifyPortalOpened(0, 0f);
|
||||
drops.Step(60f);
|
||||
|
||||
Assert.That(drops.TryInteract(0), Is.True);
|
||||
IReadOnlyList<BumpCombat.Player.ActiveArtifactDefinition> snapshot = drops.CurrentCandidates;
|
||||
int generation = drops.RunGeneration;
|
||||
int token = drops.CurrentChoiceToken;
|
||||
Assert.That(snapshot, Has.Count.EqualTo(2));
|
||||
Assert.That(snapshot[0].Effect, Is.Not.EqualTo(snapshot[1].Effect));
|
||||
Assert.That(drops.TryInteract(0), Is.False);
|
||||
Assert.That(drops.TryChoose(0, generation + 1, token), Is.False);
|
||||
|
||||
Assert.That(drops.TryChoose(0, generation, token), Is.True);
|
||||
Assert.That(drops.DropStates[0].Status, Is.EqualTo(Defense25DArtifactDropStatus.Claimed));
|
||||
Assert.That(world.Loadout.OwnedArtifactCount, Is.EqualTo(1));
|
||||
Assert.That(drops.TryChoose(1, generation, token), Is.False);
|
||||
Assert.That(drops.TryInteract(0), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SeededDropPositionsUseTheActualHomeAndStayOnClearRouteFreeGround()
|
||||
{
|
||||
Assert.That(Defense25DBattlefieldDefinition.CreateDefault().TryCreateLayout(
|
||||
2718, out Defense25DRunLayout layout, out string error), Is.True, error);
|
||||
Defense25DArtifactDrops first = new();
|
||||
Defense25DArtifactDrops replay = new();
|
||||
first.Bind(world, layout, settings, 9501);
|
||||
replay.Bind(world, layout, settings, 9501);
|
||||
|
||||
Assert.That(first.DropStates.Count, Is.EqualTo(3));
|
||||
for (int i = 0; i < first.DropStates.Count; i++)
|
||||
{
|
||||
Vector2 position = first.DropStates[i].Position;
|
||||
Assert.That(position, Is.EqualTo(replay.DropStates[i].Position));
|
||||
Assert.That(layout.Bounds.Contains(position), Is.True);
|
||||
Assert.That(position.x - layout.Bounds.xMin, Is.GreaterThan(settings.DropFootprintRadius));
|
||||
Assert.That(layout.Bounds.xMax - position.x, Is.GreaterThan(settings.DropFootprintRadius));
|
||||
Assert.That(position.y - layout.Bounds.yMin, Is.GreaterThan(settings.DropFootprintRadius));
|
||||
Assert.That(layout.Bounds.yMax - position.y, Is.GreaterThan(settings.DropFootprintRadius));
|
||||
Assert.That(DistanceToRoutes(position, layout.Routes), Is.GreaterThan(.75f));
|
||||
Assert.That(DistanceToPads(position, layout.PadPositions), Is.GreaterThan(1.25f));
|
||||
Assert.That(DistanceToPads(position, layout.PortalPositions), Is.GreaterThan(1.25f));
|
||||
|
||||
float homeDistance = Vector2.Distance(position, layout.HomePosition);
|
||||
if (i == 0)
|
||||
Assert.That(homeDistance, Is.InRange(2f, 4f));
|
||||
else
|
||||
Assert.That(homeDistance, Is.InRange(18f, 26f));
|
||||
}
|
||||
|
||||
Assert.That(Vector2.Distance(first.DropStates[1].Position, first.DropStates[2].Position),
|
||||
Is.GreaterThanOrEqualTo(12f));
|
||||
}
|
||||
|
||||
private static float DistanceToRoutes(Vector2 position, IReadOnlyList<IReadOnlyList<Vector2>> routes)
|
||||
{
|
||||
float nearest = float.PositiveInfinity;
|
||||
foreach (IReadOnlyList<Vector2> route in routes)
|
||||
for (int i = 0; i < route.Count - 1; i++)
|
||||
nearest = Mathf.Min(nearest, DistanceToSegment(position, route[i], route[i + 1]));
|
||||
return nearest;
|
||||
}
|
||||
|
||||
private ScheduleRecord CaptureSchedule(Defense25DRunLayout layout, bool useOneSecondSteps)
|
||||
{
|
||||
Defense25DArtifactDrops drops = new();
|
||||
drops.Bind(world, layout, settings, 7401);
|
||||
ScheduleRecord record = new();
|
||||
drops.DropSpawned += state => record.SpawnOrder.Add(state.DropId);
|
||||
drops.ReliefWindowRequested += (id, start, duration) => record.Reliefs.Add((id, start, duration));
|
||||
drops.NotifyPortalOpened(0, 0f);
|
||||
drops.Step(59f);
|
||||
if (useOneSecondSteps)
|
||||
{
|
||||
for (int second = 60; second <= 200; second++)
|
||||
drops.Step(second);
|
||||
}
|
||||
else
|
||||
{
|
||||
drops.Step(200f);
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
private static float DistanceToPads(Vector2 position, IReadOnlyList<Vector2> pads)
|
||||
{
|
||||
float nearest = float.PositiveInfinity;
|
||||
foreach (Vector2 pad in pads)
|
||||
nearest = Mathf.Min(nearest, Vector2.Distance(position, pad));
|
||||
return nearest;
|
||||
}
|
||||
|
||||
private static float DistanceToSegment(Vector2 point, Vector2 start, Vector2 end)
|
||||
{
|
||||
Vector2 segment = end - start;
|
||||
float t = Mathf.Clamp01(Vector2.Dot(point - start, segment) / segment.sqrMagnitude);
|
||||
return Vector2.Distance(point, start + segment * t);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bb32e4bb8f684bb789afe13362077541
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Collections;
|
||||
using System.Reflection;
|
||||
using System.Linq;
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Defense.Comparison;
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Progression;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
@@ -19,6 +21,9 @@ namespace BumpCombat.Tests
|
||||
private Defense25DComparisonSettings settings;
|
||||
private Defense25DLoadout loadout;
|
||||
private Defense25DArtifactExecutor executor;
|
||||
private GameplayConstants originalConstants;
|
||||
private GameplayConstants testConstants;
|
||||
private PassiveRelicConstants testRelicTuning;
|
||||
private float previousTimeScale;
|
||||
|
||||
[UnitySetUp]
|
||||
@@ -26,6 +31,15 @@ namespace BumpCombat.Tests
|
||||
{
|
||||
previousTimeScale = Time.timeScale;
|
||||
Time.timeScale = 1f;
|
||||
originalConstants = GameplayConstants.Current;
|
||||
testConstants = Object.Instantiate(originalConstants);
|
||||
testRelicTuning = Object.Instantiate(originalConstants.PassiveRelics);
|
||||
testRelicTuning.ResonanceEchoChance = 1f;
|
||||
testRelicTuning.ResonantCrystalChance = 1f;
|
||||
testRelicTuning.ResonantCrystalDelaySeconds = .5f;
|
||||
typeof(GameplayConstants).GetField("passiveRelics", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(testConstants, testRelicTuning);
|
||||
GameplayConstants.SetCurrentForTests(testConstants);
|
||||
settings = Object.Instantiate(AssetDatabase.LoadAssetAtPath<Defense25DComparisonSettings>(SettingsPath));
|
||||
Assert.That(settings, Is.Not.Null);
|
||||
world = new GameObject("Artifact executor test world").AddComponent<Defense25DComparison>();
|
||||
@@ -44,8 +58,12 @@ namespace BumpCombat.Tests
|
||||
public IEnumerator TearDown()
|
||||
{
|
||||
executor?.Cancel();
|
||||
world?.ArtifactExecutor?.Cancel();
|
||||
if (world != null) Object.Destroy(world.gameObject);
|
||||
if (settings != null) Object.Destroy(settings);
|
||||
if (testRelicTuning != null) Object.Destroy(testRelicTuning);
|
||||
if (testConstants != null) Object.Destroy(testConstants);
|
||||
GameplayConstants.SetCurrentForTests(originalConstants);
|
||||
Time.timeScale = previousTimeScale;
|
||||
yield return null;
|
||||
}
|
||||
@@ -239,6 +257,548 @@ namespace BumpCombat.Tests
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator CycloneCastsUseIsolatedVisualsAndCancelWithoutDuplicateLegacyTicks()
|
||||
{
|
||||
ActiveArtifactDefinition cyclone = loadout.FindArtifact(ActiveArtifactEffect.Cyclone);
|
||||
Assert.That(loadout.TryAddArtifact(cyclone), Is.True);
|
||||
Defense25DEnemy enemy = world.Enemies[0];
|
||||
Defense25DEnemy replayEnemy = world.Enemies[1];
|
||||
Vector2 initialCenter = CycloneGeometry.CenterOffset(cyclone.NormalRange, false, settings.PlayerVisualScale);
|
||||
enemy.Spawn(initialCenter);
|
||||
Physics.SyncTransforms();
|
||||
float manualHealth = enemy.Actor.CurrentHealth;
|
||||
|
||||
int cycloneHits = 0;
|
||||
void OnValidHit(CombatHitResult hit)
|
||||
{
|
||||
if ((hit.Target == enemy.gameObject || hit.Target == replayEnemy.gameObject) && hit.IsArtifactHit
|
||||
&& hit.ArtifactEffect == ActiveArtifactEffect.Cyclone)
|
||||
cycloneHits++;
|
||||
}
|
||||
CombatEvents.OnValidHit += OnValidHit;
|
||||
try
|
||||
{
|
||||
Assert.That(loadout.TryUse(false), Is.True, "The manual cast should start through the loadout.");
|
||||
executor.Step(.01f);
|
||||
Assert.That(enemy.Actor.CurrentHealth, Is.LessThan(manualHealth),
|
||||
"The manual cast should land its initial scheduled tick before the replay begins.");
|
||||
Vector2 manualCenter = world.Player.Position
|
||||
+ CycloneGeometry.CenterOffset(cyclone.NormalRange, false, settings.PlayerVisualScale);
|
||||
|
||||
Vector3 replayOrigin = Defense25DActor.OnGround(new Vector2(4f, -2f), settings.GroundY);
|
||||
Vector2 replayCenter = new Vector2(replayOrigin.x, replayOrigin.z)
|
||||
+ CycloneGeometry.CenterOffset(cyclone.NormalRange, false, settings.PlayerVisualScale);
|
||||
replayEnemy.Spawn(replayCenter);
|
||||
Physics.SyncTransforms();
|
||||
float replayHealth = replayEnemy.Actor.CurrentHealth;
|
||||
Assert.That(executor.TryStartCycloneReplay(new Defense25DArtifactUse(cyclone, false),
|
||||
replayOrigin, Vector3.right, 1f, out int replayIdentity), Is.True,
|
||||
"The executor must accept an independent Echo/Repeat Cyclone cast.");
|
||||
|
||||
executor.Step(.01f);
|
||||
Assert.That(replayEnemy.Actor.CurrentHealth, Is.LessThan(replayHealth),
|
||||
"The replay cast should tick its own target at its supplied origin.");
|
||||
manualCenter = world.Player.Position
|
||||
+ CycloneGeometry.CenterOffset(cyclone.NormalRange, false, settings.PlayerVisualScale);
|
||||
Assert.That(cycloneHits, Is.EqualTo(2),
|
||||
"One tick from each cast should land; the legacy world Cyclone path must not add a third tick.");
|
||||
Assert.That(world.CycloneActive, Is.False,
|
||||
"An active executor cast must not activate the legacy comparison Cyclone state.");
|
||||
Assert.That(world.GetComponentsInChildren<Defense25DCycloneVisual>(true)
|
||||
.Count(visual => visual.IsVisible), Is.EqualTo(2),
|
||||
"Manual and replay casts must own two independent visible Cyclone visuals.");
|
||||
yield return null;
|
||||
Defense25DCycloneVisual[] visibleVisuals = world.GetComponentsInChildren<Defense25DCycloneVisual>(true)
|
||||
.Where(visual => visual.IsVisible).ToArray();
|
||||
Vector2[] visualAnchors = visibleVisuals.Select(FixedRingAnchor).ToArray();
|
||||
Assert.That(visualAnchors.Any(anchor => Vector2.Distance(anchor, manualCenter) < .02f), Is.True,
|
||||
"The moving manual cast's fixed ring must follow its own current Cyclone center.");
|
||||
Assert.That(visualAnchors.Any(anchor => Vector2.Distance(anchor, replayCenter) < .02f), Is.True,
|
||||
"The replay fixed ring must remain at its cast origin instead of snapping to the player.");
|
||||
|
||||
Assert.That(executor.TryCancelCast(replayIdentity), Is.True,
|
||||
"Individual cast cancellation is required for independent lifetimes.");
|
||||
yield return null;
|
||||
|
||||
Assert.That(world.GetComponentsInChildren<Defense25DCycloneVisual>(true)
|
||||
.Count(visual => visual.IsVisible), Is.EqualTo(1),
|
||||
"Cancelling the replay must leave the manual cast visual active.");
|
||||
Assert.That(executor.IsExecutingArtifact, Is.True,
|
||||
"Cancelling a replay must not release the manual cast's input lock.");
|
||||
|
||||
float tickInterval = Mathf.Max(cyclone.HitInterval, Time.fixedDeltaTime);
|
||||
executor.Step(tickInterval);
|
||||
Assert.That(cycloneHits, Is.EqualTo(3),
|
||||
"The remaining manual cast should add exactly one tick after replay cancellation.");
|
||||
|
||||
executor.Cancel();
|
||||
yield return null;
|
||||
Assert.That(world.GetComponentsInChildren<Defense25DCycloneVisual>(true)
|
||||
.Count(visual => visual.IsVisible), Is.Zero,
|
||||
"Cancelling the executor must remove all cast visuals.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
CombatEvents.OnValidHit -= OnValidHit;
|
||||
}
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator CycloneTryReplayReturnsWhenScheduledAndTicksBesideManualCast()
|
||||
{
|
||||
ActiveArtifactDefinition cyclone = loadout.FindArtifact(ActiveArtifactEffect.Cyclone);
|
||||
Assert.That(loadout.TryAddArtifact(cyclone), Is.True);
|
||||
Defense25DEnemy manualEnemy = world.Enemies[0];
|
||||
Defense25DEnemy replayEnemy = world.Enemies[1];
|
||||
Vector2 manualCenter = CycloneGeometry.CenterOffset(cyclone.NormalRange, false, settings.PlayerVisualScale);
|
||||
manualEnemy.Spawn(manualCenter);
|
||||
Physics.SyncTransforms();
|
||||
float manualHealth = manualEnemy.Actor.CurrentHealth;
|
||||
int cycloneHits = 0;
|
||||
void OnValidHit(CombatHitResult hit)
|
||||
{
|
||||
if (hit.IsArtifactHit && hit.ArtifactEffect == ActiveArtifactEffect.Cyclone
|
||||
&& (hit.Target == manualEnemy.gameObject || hit.Target == replayEnemy.gameObject))
|
||||
cycloneHits++;
|
||||
}
|
||||
CombatEvents.OnValidHit += OnValidHit;
|
||||
try
|
||||
{
|
||||
Assert.That(loadout.TryUse(false), Is.True);
|
||||
executor.Step(.01f);
|
||||
Assert.That(manualEnemy.Actor.CurrentHealth, Is.LessThan(manualHealth),
|
||||
"The manual cast should establish its own first tick.");
|
||||
|
||||
Vector3 replayOrigin = Defense25DActor.OnGround(new Vector2(4f, -2f), settings.GroundY);
|
||||
Vector2 replayCenter = new Vector2(replayOrigin.x, replayOrigin.z)
|
||||
+ CycloneGeometry.CenterOffset(cyclone.NormalRange, false, settings.PlayerVisualScale);
|
||||
replayEnemy.Spawn(replayCenter);
|
||||
Physics.SyncTransforms();
|
||||
float replayHealth = replayEnemy.Actor.CurrentHealth;
|
||||
|
||||
Assert.That(executor.TryReplay(new Defense25DArtifactUse(cyclone, false),
|
||||
replayOrigin, Vector3.right, 1f), Is.True,
|
||||
"The relic callback's bool must mean the Cyclone schedule was accepted.");
|
||||
Assert.That(replayEnemy.Actor.CurrentHealth, Is.EqualTo(replayHealth),
|
||||
"Scheduling Echo/Repeat must not apply an immediate one-shot ellipse hit.");
|
||||
Assert.That(world.GetComponentsInChildren<Defense25DCycloneVisual>(true)
|
||||
.Count(visual => visual.IsVisible), Is.EqualTo(2),
|
||||
"The replay must create a visual independent from the active manual cast.");
|
||||
Assert.That(executor.IsExecutingArtifact, Is.True,
|
||||
"Scheduling the replay must not replace the manual cast's input lock.");
|
||||
|
||||
executor.Step(.01f);
|
||||
Assert.That(replayEnemy.Actor.CurrentHealth, Is.LessThan(replayHealth),
|
||||
"The replay should damage its own target on its scheduled tick.");
|
||||
Assert.That(cycloneHits, Is.EqualTo(2),
|
||||
"The manual cast and replay should tick once each without a legacy duplicate.");
|
||||
Assert.That(world.CycloneActive, Is.False);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CombatEvents.OnValidHit -= OnValidHit;
|
||||
}
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator CycloneTryReplayReturnsTrueForAnAcceptedEmptySchedule()
|
||||
{
|
||||
ActiveArtifactDefinition cyclone = loadout.FindArtifact(ActiveArtifactEffect.Cyclone);
|
||||
Assert.That(loadout.TryAddArtifact(cyclone), Is.True);
|
||||
Vector3 replayOrigin = Defense25DActor.OnGround(new Vector2(4f, -2f), settings.GroundY);
|
||||
Assert.That(executor.TryReplay(new Defense25DArtifactUse(cyclone, false),
|
||||
replayOrigin, Vector3.right, 1f), Is.True,
|
||||
"True means the independent cast was scheduled, even if no target is in its area yet.");
|
||||
Assert.That(world.GetComponentsInChildren<Defense25DCycloneVisual>(true)
|
||||
.Count(visual => visual.IsVisible), Is.EqualTo(1),
|
||||
"An accepted schedule should create its cast-owned presentation immediately.");
|
||||
executor.Step(.01f);
|
||||
Assert.That(world.GetComponentsInChildren<Defense25DCycloneVisual>(true)
|
||||
.Count(visual => visual.IsVisible), Is.EqualTo(1));
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator CycloneTryReplayUsesTheExecutorHurtGate()
|
||||
{
|
||||
ActiveArtifactDefinition cyclone = loadout.FindArtifact(ActiveArtifactEffect.Cyclone);
|
||||
Assert.That(loadout.TryAddArtifact(cyclone), Is.True);
|
||||
Assert.That(world.Player.TakeDamage(1f, Vector2.left), Is.True);
|
||||
Assert.That(world.Player.IsHurt, Is.True);
|
||||
|
||||
Vector3 replayOrigin = Defense25DActor.OnGround(new Vector2(4f, -2f), settings.GroundY);
|
||||
Assert.That(executor.TryReplay(new Defense25DArtifactUse(cyclone, false),
|
||||
replayOrigin, Vector3.right, 1f), Is.False,
|
||||
"Echo/Repeat must use the existing replay API's hurt gate rather than add an exception.");
|
||||
Assert.That(world.GetComponentsInChildren<Defense25DCycloneVisual>(true)
|
||||
.Count(visual => visual.IsVisible), Is.Zero);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator CycloneReplayAcceptedBeforeHurtIsCancelledWithoutResidualTickOrVisual()
|
||||
{
|
||||
ActiveArtifactDefinition cyclone = loadout.FindArtifact(ActiveArtifactEffect.Cyclone);
|
||||
Assert.That(loadout.TryAddArtifact(cyclone), Is.True);
|
||||
Vector3 replayOrigin = Defense25DActor.OnGround(new Vector2(4f, -2f), settings.GroundY);
|
||||
Vector2 replayCenter = new Vector2(replayOrigin.x, replayOrigin.z)
|
||||
+ CycloneGeometry.CenterOffset(cyclone.NormalRange, false, settings.PlayerVisualScale);
|
||||
Defense25DEnemy target = world.Enemies[0];
|
||||
target.Spawn(replayCenter);
|
||||
Physics.SyncTransforms();
|
||||
float health = target.Actor.CurrentHealth;
|
||||
|
||||
Assert.That(executor.TryReplay(new Defense25DArtifactUse(cyclone, false),
|
||||
replayOrigin, Vector3.right, 1f), Is.True,
|
||||
"The callback accepts the schedule before the subsequent hurt transition.");
|
||||
Assert.That(target.Actor.CurrentHealth, Is.EqualTo(health),
|
||||
"Schedule acceptance does not mean an immediate or guaranteed later hit.");
|
||||
Assert.That(world.GetComponentsInChildren<Defense25DCycloneVisual>(true)
|
||||
.Count(visual => visual.IsVisible), Is.EqualTo(1));
|
||||
|
||||
Assert.That(world.Player.TakeDamage(1f, Vector2.left), Is.True);
|
||||
executor.Step(.01f);
|
||||
Assert.That(target.Actor.CurrentHealth, Is.EqualTo(health),
|
||||
"Executor hurt cancellation must happen before the replay's first tick.");
|
||||
Assert.That(world.GetComponentsInChildren<Defense25DCycloneVisual>(true)
|
||||
.Count(visual => visual.IsVisible), Is.Zero,
|
||||
"No replay presentation may survive the hurt cancellation.");
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ResonanceCoreEchoRoutesCycloneThroughTheWorldReplayCallback()
|
||||
{
|
||||
Defense25DLoadout worldLoadout = world.Loadout;
|
||||
Defense25DArtifactExecutor worldExecutor = world.ArtifactExecutor;
|
||||
ActiveArtifactDefinition cyclone = worldLoadout.FindArtifact(ActiveArtifactEffect.Cyclone);
|
||||
Assert.That(worldLoadout.TryAddArtifact(cyclone), Is.True);
|
||||
Assert.That(worldLoadout.TryAcquireRelic(PassiveRelicId.ResonanceCore), Is.True);
|
||||
worldLoadout.AddGauge(worldLoadout.MaximumGauge);
|
||||
Defense25DEnemy source = world.Enemies[0];
|
||||
source.Spawn(CycloneGeometry.CenterOffset(cyclone.NormalRange, false, settings.PlayerVisualScale));
|
||||
Physics.SyncTransforms();
|
||||
float sourceHealth = source.Actor.CurrentHealth;
|
||||
Defense25DEnemy echoTarget = world.Enemies[1];
|
||||
float echoHealth = echoTarget.Actor.CurrentHealth;
|
||||
int echoHits = 0;
|
||||
void OnValidHit(CombatHitResult hit)
|
||||
{
|
||||
if (hit.Target == echoTarget.gameObject && hit.IsArtifactHit
|
||||
&& hit.ArtifactEffect == ActiveArtifactEffect.Cyclone
|
||||
&& hit.SourceId == Defense25DRelicRuntime.EchoSourceId)
|
||||
echoHits++;
|
||||
}
|
||||
CombatEvents.OnValidHit += OnValidHit;
|
||||
try
|
||||
{
|
||||
Assert.That(worldLoadout.TryUse(false), Is.True);
|
||||
worldExecutor.Step(.01f);
|
||||
Assert.That(source.Actor.CurrentHealth, Is.LessThan(sourceHealth),
|
||||
"The original manual Cyclone tick should trigger the configured Resonance Core callback.");
|
||||
|
||||
Vector3 echoOrigin = Defense25DActor.OnGround(
|
||||
source.Actor.ClosestGroundPoint(world.Player.Center), settings.GroundY);
|
||||
Vector2 echoCenter = new Vector2(echoOrigin.x, echoOrigin.z)
|
||||
+ CycloneGeometry.CenterOffset(cyclone.NormalRange, false, settings.PlayerVisualScale);
|
||||
echoTarget.Spawn(echoCenter);
|
||||
Physics.SyncTransforms();
|
||||
echoHealth = echoTarget.Actor.CurrentHealth;
|
||||
Assert.That(echoHits, Is.Zero,
|
||||
"The actual world Echo callback has scheduled, but not yet ticked, its Cyclone.");
|
||||
Assert.That(world.GetComponentsInChildren<Defense25DCycloneVisual>(true)
|
||||
.Count(visual => visual.IsVisible), Is.EqualTo(2));
|
||||
|
||||
worldExecutor.Step(.01f);
|
||||
Assert.That(echoTarget.Actor.CurrentHealth, Is.LessThan(echoHealth),
|
||||
"The callback-created replay applies its damage on the cast tick.");
|
||||
Assert.That(echoHits, Is.EqualTo(1));
|
||||
Assert.That(world.CycloneActive, Is.False);
|
||||
}
|
||||
finally
|
||||
{
|
||||
worldExecutor.Cancel();
|
||||
CombatEvents.OnValidHit -= OnValidHit;
|
||||
}
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ResonantCrystalRepeatSchedulesCycloneThroughTheWorldReplayCallback()
|
||||
{
|
||||
Defense25DLoadout worldLoadout = world.Loadout;
|
||||
Defense25DArtifactExecutor worldExecutor = world.ArtifactExecutor;
|
||||
ActiveArtifactDefinition cyclone = worldLoadout.FindArtifact(ActiveArtifactEffect.Cyclone);
|
||||
Assert.That(worldLoadout.TryAddArtifact(cyclone), Is.True);
|
||||
Assert.That(worldLoadout.TryAcquireRelic(PassiveRelicId.ResonantCrystal), Is.True);
|
||||
worldLoadout.AddGauge(worldLoadout.MaximumGauge);
|
||||
Defense25DEnemy target = world.Enemies[0];
|
||||
Vector2 repeatCenter = CycloneGeometry.CenterOffset(cyclone.NormalRange, false, settings.PlayerVisualScale);
|
||||
target.Spawn(repeatCenter);
|
||||
Physics.SyncTransforms();
|
||||
float health = target.Actor.CurrentHealth;
|
||||
int repeatHits = 0;
|
||||
void OnValidHit(CombatHitResult hit)
|
||||
{
|
||||
if (hit.Target == target.gameObject && hit.IsArtifactHit
|
||||
&& hit.ArtifactEffect == ActiveArtifactEffect.Cyclone
|
||||
&& hit.SourceId == Defense25DRelicRuntime.EchoSourceId)
|
||||
repeatHits++;
|
||||
}
|
||||
CombatEvents.OnValidHit += OnValidHit;
|
||||
try
|
||||
{
|
||||
Assert.That(worldLoadout.TryUse(false), Is.True);
|
||||
Assert.That(world.RelicRuntime.IsRepeatPending, Is.True);
|
||||
float repeatDeadline = world.GameTime + testRelicTuning.ResonantCrystalDelaySeconds + .001f;
|
||||
world.RelicRuntime.Advance(repeatDeadline,
|
||||
world.Player.CurrentHealth);
|
||||
Assert.That(world.RelicRuntime.IsRepeatPending, Is.False);
|
||||
Assert.That(target.Actor.CurrentHealth, Is.EqualTo(health),
|
||||
"Repeat callback acceptance schedules a Cyclone; it does not apply an immediate hit.");
|
||||
Assert.That(world.GetComponentsInChildren<Defense25DCycloneVisual>(true)
|
||||
.Count(visual => visual.IsVisible), Is.EqualTo(2));
|
||||
|
||||
worldExecutor.Step(.01f);
|
||||
Assert.That(target.Actor.CurrentHealth, Is.LessThan(health));
|
||||
Assert.That(repeatHits, Is.EqualTo(1),
|
||||
"The callback-created Repeat must retain Echo source identity at its scheduled tick.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
worldExecutor.Cancel();
|
||||
CombatEvents.OnValidHit -= OnValidHit;
|
||||
}
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator CastDrivenCycloneVisualUsesItsSnapshotRangeForSpriteAndFixedRing()
|
||||
{
|
||||
GameObject root = new("Cyclone cast snapshot scale test");
|
||||
root.transform.SetParent(world.transform, false);
|
||||
Defense25DCycloneVisual visual = root.AddComponent<Defense25DCycloneVisual>();
|
||||
visual.ConfigureForCast(world);
|
||||
|
||||
float configuredRange = settings.Cyclone.NormalRange;
|
||||
float castRange = configuredRange * 1.7f;
|
||||
float expectedScale = CycloneGeometry.VisualScale(castRange, false);
|
||||
Vector2 center = new(3f, -2f);
|
||||
Vector2 radii = CycloneGeometry.Radii(castRange, false);
|
||||
visual.SetCastState(true, .15f, center, radii, false);
|
||||
yield return null;
|
||||
|
||||
SpriteRenderer back = visual.GetComponentsInChildren<SpriteRenderer>(true)
|
||||
.Single(renderer => renderer.gameObject.name == "Existing Back frames");
|
||||
Assert.That(back.transform.localScale.x, Is.EqualTo(expectedScale).Within(.001f),
|
||||
"The cast's supplied radii, not world Cyclone settings, determine frame scale.");
|
||||
|
||||
MeshFilter filter = visual.GetComponentInChildren<MeshFilter>(true);
|
||||
Assert.That(filter, Is.Not.Null);
|
||||
Vector3[] vertices = filter.sharedMesh.vertices;
|
||||
Vector2 fixedRingCenter = AverageGround(vertices, 0);
|
||||
Assert.That(Vector2.Distance(fixedRingCenter, center), Is.LessThan(.001f),
|
||||
"The cast-driven fixed ring must share this cast's snapshot center.");
|
||||
float actualHalfWidth = 0f;
|
||||
for (int i = 0; i < 4; i++)
|
||||
actualHalfWidth = Mathf.Max(actualHalfWidth, Mathf.Abs(vertices[i].x - center.x));
|
||||
float expectedHalfWidth = Defense25DComparisonSettings.CycloneFixedRingWidthPixels / 64f * expectedScale;
|
||||
Assert.That(actualHalfWidth, Is.EqualTo(expectedHalfWidth).Within(.001f),
|
||||
"The fixed ring dimensions must use the same cast-driven scale as the sprites.");
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator DashNormalAndChargedUsesDamageAlongActualTravel()
|
||||
{
|
||||
yield return AssertNormalAndChargedImpact(ActiveArtifactEffect.Dash);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator PulseNormalAndChargedUsesPushAndReportArtifactContact()
|
||||
{
|
||||
yield return AssertNormalAndChargedImpact(ActiveArtifactEffect.Pulse);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator PhoenixNormalAndChargedUsesDamageAtRayImpact()
|
||||
{
|
||||
yield return AssertNormalAndChargedImpact(ActiveArtifactEffect.Phoenix);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator CycloneNormalAndChargedUsesDamageOnScheduledTicks()
|
||||
{
|
||||
yield return AssertNormalAndChargedImpact(ActiveArtifactEffect.Cyclone);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ThunderCrashNormalAndChargedUsesDamageOnScheduledTicks()
|
||||
{
|
||||
yield return AssertNormalAndChargedImpact(ActiveArtifactEffect.ThunderCrash);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ChainLightningNormalAndChargedUsesDamageOnScheduledImpacts()
|
||||
{
|
||||
yield return AssertNormalAndChargedImpact(ActiveArtifactEffect.ChainLightning);
|
||||
}
|
||||
|
||||
private IEnumerator AssertNormalAndChargedImpact(ActiveArtifactEffect effect)
|
||||
{
|
||||
ActiveArtifactDefinition definition = loadout.FindArtifact(effect);
|
||||
Assert.That(definition, Is.Not.Null, $"The catalog must contain {effect}.");
|
||||
Assert.That(loadout.TryAddArtifact(definition), Is.True, $"The loadout must accept {effect}.");
|
||||
Defense25DEnemy enemy = world.Enemies[0];
|
||||
|
||||
for (int mode = 0; mode < 2; mode++)
|
||||
{
|
||||
bool charged = mode == 1;
|
||||
string modeLabel = charged ? "charged" : "normal";
|
||||
executor.Cancel();
|
||||
world.Player.ResetAt(Vector2.zero);
|
||||
world.Player.Facing = Vector2.right;
|
||||
|
||||
float range = charged ? definition.ChargedRange : definition.NormalRange;
|
||||
float minimumSeparatedDistance = world.Player.Radius + enemy.Actor.Radius
|
||||
+ 2f * settings.ContactSensorMargin;
|
||||
Vector2 targetPosition = effect switch
|
||||
{
|
||||
ActiveArtifactEffect.Cyclone =>
|
||||
CycloneGeometry.CenterOffset(range, charged, settings.PlayerVisualScale),
|
||||
ActiveArtifactEffect.ThunderCrash => Vector2.right * .48f,
|
||||
ActiveArtifactEffect.Dash or ActiveArtifactEffect.Pulse =>
|
||||
Vector2.right * Mathf.Max(1f, minimumSeparatedDistance + .1f),
|
||||
_ => Vector2.right * .5f,
|
||||
};
|
||||
if (effect is ActiveArtifactEffect.Dash or ActiveArtifactEffect.Pulse)
|
||||
{
|
||||
Assert.That(targetPosition.magnitude, Is.GreaterThan(minimumSeparatedDistance),
|
||||
$"{effect} fixture actors must begin outside body and contact-sensor overlap.");
|
||||
Assert.That(targetPosition.magnitude, Is.LessThanOrEqualTo(range),
|
||||
$"{effect} fixture target must remain within the selected artifact range.");
|
||||
}
|
||||
enemy.Spawn(targetPosition);
|
||||
Physics.SyncTransforms();
|
||||
float healthBefore = enemy.Actor.CurrentHealth;
|
||||
Vector2 positionBefore = enemy.Actor.Position;
|
||||
int impactCount = 0;
|
||||
CombatHitResult observedImpact = default;
|
||||
void OnValidHit(CombatHitResult hit)
|
||||
{
|
||||
if (hit.Target != enemy.gameObject || !hit.IsArtifactHit || hit.ArtifactEffect != effect) return;
|
||||
impactCount++;
|
||||
observedImpact = hit;
|
||||
}
|
||||
|
||||
CombatEvents.OnValidHit += OnValidHit;
|
||||
try
|
||||
{
|
||||
if (charged)
|
||||
{
|
||||
Assert.That(executor.OnArtifactButtonPressed(), Is.True,
|
||||
$"{effect} must begin its real charge path.");
|
||||
executor.Step(definition.ChargeDuration);
|
||||
Assert.That(loadout.ChargeProgress, Is.EqualTo(1f).Within(.001f));
|
||||
Assert.That(executor.OnArtifactButtonReleased(), Is.True,
|
||||
$"{effect} must execute from the charged release.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.That(loadout.TryUse(false), Is.True,
|
||||
$"{effect} must execute through the normal loadout command.");
|
||||
}
|
||||
|
||||
StepToFirstImpact(effect, definition, charged);
|
||||
yield return null;
|
||||
|
||||
Assert.That(impactCount, Is.GreaterThan(0),
|
||||
$"{effect} {modeLabel} must publish a real impact; player={world.Player.Position}, "
|
||||
+ $"target={enemy.Actor.Position} (before {positionBefore}), health={enemy.Actor.CurrentHealth}/{healthBefore}, "
|
||||
+ $"running={world.IsRunning}, paused={world.Paused}.");
|
||||
Assert.That(observedImpact.IsChargedArtifact, Is.EqualTo(charged));
|
||||
Assert.That(HasVisibleArtifactPresentation(effect), Is.True,
|
||||
$"{effect} {modeLabel} must present its cast while the impact is active.");
|
||||
if (effect == ActiveArtifactEffect.Pulse)
|
||||
{
|
||||
Assert.That(Vector2.Distance(positionBefore, enemy.Actor.Position), Is.GreaterThan(.1f),
|
||||
$"{effect} {modeLabel} is a push/contact impact, not a damage hit.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.That(enemy.Actor.CurrentHealth, Is.LessThan(healthBefore),
|
||||
$"{effect} {modeLabel} must reduce target health.");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
CombatEvents.OnValidHit -= OnValidHit;
|
||||
executor.Cancel();
|
||||
}
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void StepToFirstImpact(ActiveArtifactEffect effect, ActiveArtifactDefinition definition, bool charged)
|
||||
{
|
||||
switch (effect)
|
||||
{
|
||||
case ActiveArtifactEffect.Dash:
|
||||
float dashDuration = charged
|
||||
? GameplayConstants.Current.Artifacts.ChargedDashDuration
|
||||
: GameplayConstants.Current.Artifacts.DashDuration;
|
||||
executor.Step(dashDuration * .5f);
|
||||
executor.Step(dashDuration * .5f);
|
||||
break;
|
||||
case ActiveArtifactEffect.Pulse:
|
||||
executor.Step(.01f);
|
||||
break;
|
||||
case ActiveArtifactEffect.Phoenix:
|
||||
// The ray resolves synchronously on use; keep its cast visual alive for observation.
|
||||
break;
|
||||
case ActiveArtifactEffect.Cyclone:
|
||||
case ActiveArtifactEffect.ThunderCrash:
|
||||
executor.Step(Mathf.Max(definition.HitInterval, Time.fixedDeltaTime) + .01f);
|
||||
break;
|
||||
case ActiveArtifactEffect.ChainLightning:
|
||||
executor.Step(Mathf.Max(definition.HitInterval, Time.fixedDeltaTime) + .01f);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasVisibleArtifactPresentation(ActiveArtifactEffect effect)
|
||||
{
|
||||
if (effect == ActiveArtifactEffect.Cyclone)
|
||||
return world.GetComponentsInChildren<Defense25DCycloneVisual>(true)
|
||||
.Any(visual => visual.IsVisible);
|
||||
|
||||
string rendererName = effect switch
|
||||
{
|
||||
ActiveArtifactEffect.Dash => "Dash afterimage",
|
||||
ActiveArtifactEffect.Pulse => "Artifact pulse ring",
|
||||
ActiveArtifactEffect.Phoenix => "Artifact Phoenix",
|
||||
ActiveArtifactEffect.ThunderCrash => "Artifact ThunderCrash",
|
||||
ActiveArtifactEffect.ChainLightning => "Artifact path",
|
||||
_ => string.Empty,
|
||||
};
|
||||
return world.GetComponentsInChildren<Renderer>(true).Any(renderer => renderer.enabled
|
||||
&& renderer.gameObject.activeInHierarchy && renderer.gameObject.name == rendererName);
|
||||
}
|
||||
|
||||
private static Vector2 FixedRingAnchor(Defense25DCycloneVisual visual)
|
||||
{
|
||||
MeshFilter filter = visual.GetComponentInChildren<MeshFilter>(true);
|
||||
Assert.That(filter, Is.Not.Null);
|
||||
return AverageGround(filter.sharedMesh.vertices, 0);
|
||||
}
|
||||
|
||||
private static Vector2 AverageGround(Vector3[] vertices, int start)
|
||||
{
|
||||
Vector3 center = (vertices[start] + vertices[start + 1] + vertices[start + 2] + vertices[start + 3]) * .25f;
|
||||
return new Vector2(center.x, center.z);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ThunderCrashIsRegisteredDamagesAndReleasesItsExecutionLock()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Defense;
|
||||
using BumpCombat.Defense.Comparison;
|
||||
using BumpCombat.Enemies;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace BumpCombat.Tests
|
||||
{
|
||||
public sealed class Defense25DBossPlayModeTests
|
||||
{
|
||||
private const string SettingsPath = "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset";
|
||||
private const string RunDefinitionPath = "Assets/_Project/Constants/Defense25DComparison/LongDefenseRun.asset";
|
||||
private const string BossDefinitionPath = "Assets/_Project/Constants/Enemies/Necromancer.asset";
|
||||
private Defense25DComparisonSettings settings;
|
||||
private Defense25DRunDefinition runDefinition;
|
||||
private Defense25DComparison world;
|
||||
private GameObject bossObject;
|
||||
|
||||
[UnitySetUp]
|
||||
public IEnumerator SetUp()
|
||||
{
|
||||
settings = Object.Instantiate(AssetDatabase.LoadAssetAtPath<Defense25DComparisonSettings>(SettingsPath));
|
||||
runDefinition = Object.Instantiate(AssetDatabase.LoadAssetAtPath<Defense25DRunDefinition>(RunDefinitionPath));
|
||||
world = new GameObject("Boss combat owner test world").AddComponent<Defense25DComparison>();
|
||||
yield return null;
|
||||
world.InitializeLongRun(settings, runDefinition, 17);
|
||||
world.AutoSpawn = false;
|
||||
world.ReadInput = false;
|
||||
world.AdvanceRunClock(runDefinition.PreparationSeconds + 1f);
|
||||
Assert.That(world.NotifyLongRunBossActivated(), Is.True);
|
||||
}
|
||||
|
||||
[UnityTearDown]
|
||||
public IEnumerator TearDown()
|
||||
{
|
||||
if (bossObject != null) Object.Destroy(bossObject);
|
||||
if (world != null) Object.Destroy(world.gameObject);
|
||||
if (settings != null) Object.Destroy(settings);
|
||||
if (runDefinition != null) Object.Destroy(runDefinition);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NecromancerProtectionAndDesperationDamageGateMatchesTheExistingBossContract()
|
||||
{
|
||||
Assert.That(Defense25DBossCombat.FilterNecromancerDamage(
|
||||
currentHealth: 80f,
|
||||
maximumHealth: 100f,
|
||||
requestedDamage: 40f,
|
||||
isGroggy: true,
|
||||
desperationStarted: false,
|
||||
protectionActive: false,
|
||||
desperationHealthFraction: .25f), Is.EqualTo(40f).Within(.001f));
|
||||
|
||||
Assert.That(Defense25DBossCombat.FilterNecromancerDamage(
|
||||
currentHealth: 20f,
|
||||
maximumHealth: 100f,
|
||||
requestedDamage: 40f,
|
||||
isGroggy: true,
|
||||
desperationStarted: false,
|
||||
protectionActive: false,
|
||||
desperationHealthFraction: .25f), Is.Zero);
|
||||
|
||||
Assert.That(Defense25DBossCombat.FilterNecromancerDamage(
|
||||
currentHealth: 80f,
|
||||
maximumHealth: 100f,
|
||||
requestedDamage: 40f,
|
||||
isGroggy: false,
|
||||
desperationStarted: true,
|
||||
protectionActive: true,
|
||||
desperationHealthFraction: .25f), Is.Zero);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator LowHealthWithoutAnActiveGroggyCycleDoesNotStartProtectionPhase()
|
||||
{
|
||||
EnemyDefinition definition = AssetDatabase.LoadAssetAtPath<EnemyDefinition>(BossDefinitionPath);
|
||||
Assert.That(definition, Is.Not.Null);
|
||||
Defense25DEnemy boss = CreateBoss(definition);
|
||||
int phaseSpawnRequests = 0;
|
||||
var combat = new Defense25DBossCombat();
|
||||
Assert.That(combat.Bind(world, boss, definition, _ =>
|
||||
{
|
||||
phaseSpawnRequests++;
|
||||
return new[] { 101, 102, 103 };
|
||||
}), Is.True);
|
||||
Assert.That(boss.BindBossCombat(combat), Is.True);
|
||||
|
||||
Assert.That(boss.Actor.TakeDamage(58f, Vector2.right), Is.True);
|
||||
combat.Step(.02f);
|
||||
|
||||
Assert.That(boss.Actor.CurrentHealth, Is.EqualTo(2f).Within(.01f));
|
||||
Assert.That(combat.DesperationStarted, Is.False,
|
||||
"HP alone must not trigger the original groggy-gated desperation transition.");
|
||||
Assert.That(phaseSpawnRequests, Is.Zero);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator GroggyDamageStartsOneProtectionPhaseAndOnlyItsThreeOwnedDeathsReleaseIt()
|
||||
{
|
||||
EnemyDefinition definition = AssetDatabase.LoadAssetAtPath<EnemyDefinition>(BossDefinitionPath);
|
||||
Defense25DEnemy boss = CreateBoss(definition);
|
||||
int phaseRequests = 0;
|
||||
List<bool> protectionChanges = new();
|
||||
List<bool> groggyChanges = new();
|
||||
List<bool> summonInterrupts = new();
|
||||
int[] phaseIds = { 901, 902, 903 };
|
||||
var combat = new Defense25DBossCombat();
|
||||
|
||||
Assert.That(combat.Bind(world, boss, definition, _ =>
|
||||
{
|
||||
phaseRequests++;
|
||||
return phaseIds;
|
||||
}, protectionChanges.Add, (_, _) => new[] { 990 }, groggyChanges.Add,
|
||||
summonInterrupts.Add), Is.True);
|
||||
Assert.That(boss.BindBossCombat(combat), Is.True);
|
||||
Assert.That(combat.BeginGroggy(4f), Is.True);
|
||||
|
||||
float healthBefore = boss.Actor.CurrentHealth;
|
||||
Assert.That(boss.Actor.TakeDamageWithResult(100f, Vector2.right, 0f,
|
||||
EnemyDamageSource.Player, false, out float damageApplied), Is.True);
|
||||
combat.NotifyDamageApplied(healthBefore, damageApplied);
|
||||
|
||||
Assert.That(boss.Actor.CurrentHealth, Is.EqualTo(3f).Within(.01f));
|
||||
Assert.That(combat.DesperationStarted, Is.True);
|
||||
Assert.That(combat.DesperationPending, Is.False);
|
||||
Assert.That(combat.PhaseSummonCount, Is.EqualTo(3));
|
||||
Assert.That(combat.IsDamageBlocked, Is.True);
|
||||
Assert.That(combat.IsGroggy, Is.False);
|
||||
Assert.That(phaseRequests, Is.EqualTo(1));
|
||||
Assert.That(combat.FilterIncomingDamage(boss.Actor.CurrentHealth, 20f), Is.Zero);
|
||||
Assert.That(protectionChanges, Is.EqualTo(new[] { true }));
|
||||
Assert.That(groggyChanges, Is.EqualTo(new[] { true, false }));
|
||||
Assert.That(summonInterrupts, Is.EqualTo(new[] { true }));
|
||||
|
||||
Assert.That(combat.NotifyOwnedSummonResolved(world.RunGeneration, 990, 9001), Is.False,
|
||||
"Ambient summon IDs must never enter the protected phase set.");
|
||||
Assert.That(combat.NotifyOwnedSummonResolved(world.RunGeneration + 1, phaseIds[0], 9001), Is.False);
|
||||
Assert.That(combat.NotifyOwnedSummonResolved(world.RunGeneration, phaseIds[0], 44), Is.False);
|
||||
Assert.That(combat.NotifyOwnedSummonResolved(world.RunGeneration, phaseIds[0], 9001), Is.True);
|
||||
Assert.That(combat.NotifyOwnedSummonResolved(world.RunGeneration, phaseIds[1], 9001), Is.True);
|
||||
Assert.That(combat.PhaseSummonCount, Is.EqualTo(1));
|
||||
Assert.That(combat.IsDamageBlocked, Is.True);
|
||||
Assert.That(combat.NotifyOwnedSummonResolved(world.RunGeneration, phaseIds[2], 9001), Is.True);
|
||||
|
||||
Assert.That(combat.DesperationCompleted, Is.True);
|
||||
Assert.That(combat.IsDamageBlocked, Is.False);
|
||||
Assert.That(combat.IsGroggy, Is.True);
|
||||
Assert.That(protectionChanges, Is.EqualTo(new[] { true, false }));
|
||||
Assert.That(groggyChanges, Is.EqualTo(new[] { true, false, true }));
|
||||
Assert.That(combat.FilterIncomingDamage(boss.Actor.CurrentHealth, 20f), Is.EqualTo(20f));
|
||||
|
||||
float postProtectionGroggy = combat.GroggyTimeRemaining;
|
||||
Assert.That(postProtectionGroggy, Is.GreaterThan(1f));
|
||||
combat.Step(postProtectionGroggy - .1f);
|
||||
Assert.That(combat.IsGroggy, Is.True);
|
||||
combat.Step(.2f);
|
||||
Assert.That(combat.IsGroggy, Is.False);
|
||||
Assert.That(groggyChanges, Is.EqualTo(new[] { true, false, true, false }));
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator AmbientSummonPurposesHaveSeparateCooldownsAndDoNotExtendProtection()
|
||||
{
|
||||
EnemyDefinition definition = AssetDatabase.LoadAssetAtPath<EnemyDefinition>(BossDefinitionPath);
|
||||
Defense25DEnemy boss = CreateBoss(definition);
|
||||
List<bool> crowdRequests = new();
|
||||
int phaseRequests = 0;
|
||||
var combat = new Defense25DBossCombat();
|
||||
Assert.That(combat.Bind(world, boss, definition, _ =>
|
||||
{
|
||||
phaseRequests++;
|
||||
return new[] { 901, 902, 903 };
|
||||
}, null, (_, crowd) =>
|
||||
{
|
||||
crowdRequests.Add(crowd);
|
||||
return crowd ? new[] { 202, 203, 204 } : new[] { 201 };
|
||||
}), Is.True);
|
||||
Assert.That(boss.BindBossCombat(combat), Is.True);
|
||||
|
||||
float start = combat.CombatElapsed;
|
||||
float generalCooldown = GameplayConstants.Current.Enemies.GeneralSummonCooldown;
|
||||
float crowdCooldown = GameplayConstants.Current.Enemies.CrowdSummonCooldown;
|
||||
float summonLock = GameplayConstants.Current.Enemies.SummonVisualDuration;
|
||||
combat.Step(generalCooldown);
|
||||
|
||||
Assert.That(crowdRequests, Is.EqualTo(new[] { false }));
|
||||
Assert.That(combat.IsAttackLocked, Is.True);
|
||||
Assert.That(combat.NotifyOwnedSummonResolved(world.RunGeneration, 201, 9001), Is.False);
|
||||
Assert.That(combat.PhaseSummonCount, Is.Zero);
|
||||
Assert.That(combat.IsDamageBlocked, Is.False);
|
||||
|
||||
combat.Step(10f, paused: true);
|
||||
Assert.That(combat.CombatElapsed, Is.EqualTo(start + generalCooldown).Within(.001f));
|
||||
combat.Step(Mathf.Max(0f, summonLock - .01f));
|
||||
Assert.That(combat.IsAttackLocked, Is.True);
|
||||
Assert.That(crowdRequests, Is.EqualTo(new[] { false }));
|
||||
combat.Step(.02f);
|
||||
Assert.That(combat.IsAttackLocked, Is.False);
|
||||
|
||||
float elapsedSinceBind = combat.CombatElapsed - start;
|
||||
combat.Step(Mathf.Max(.01f, crowdCooldown - elapsedSinceBind + .01f));
|
||||
Assert.That(crowdRequests, Is.EqualTo(new[] { false, true }));
|
||||
Assert.That(phaseRequests, Is.Zero);
|
||||
Assert.That(combat.PhaseSummonCount, Is.Zero);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator MultiAoeCooldownAndGroggyTimerUseOnlyUnpausedRunDelta()
|
||||
{
|
||||
EnemyDefinition definition = AssetDatabase.LoadAssetAtPath<EnemyDefinition>(BossDefinitionPath);
|
||||
Defense25DEnemy boss = CreateBoss(definition);
|
||||
var combat = new Defense25DBossCombat();
|
||||
Assert.That(combat.Bind(world, boss, definition, _ => new[] { 901, 902, 903 }), Is.True);
|
||||
Assert.That(boss.BindBossCombat(combat), Is.True);
|
||||
Assert.That(combat.CanUseAttackPattern(2), Is.True);
|
||||
Assert.That(combat.CanUseAttackPattern(0), Is.True);
|
||||
|
||||
float start = combat.CombatElapsed;
|
||||
float cooldown = combat.MultiAoeCooldown;
|
||||
combat.NotifyAttackPatternUsed(2);
|
||||
Assert.That(combat.CanUseAttackPattern(2), Is.False);
|
||||
combat.Step(20f, paused: true);
|
||||
Assert.That(combat.CombatElapsed, Is.EqualTo(start).Within(.001f));
|
||||
Assert.That(combat.CanUseAttackPattern(2), Is.False);
|
||||
combat.Step(cooldown - .01f);
|
||||
Assert.That(combat.CanUseAttackPattern(2), Is.False);
|
||||
combat.Step(.02f);
|
||||
Assert.That(combat.CanUseAttackPattern(2), Is.True);
|
||||
|
||||
Assert.That(combat.BeginGroggy(1f), Is.True);
|
||||
combat.Step(10f, paused: true);
|
||||
Assert.That(combat.GroggyTimeRemaining, Is.EqualTo(1f).Within(.001f));
|
||||
combat.Step(.5f);
|
||||
Assert.That(combat.GroggyTimeRemaining, Is.EqualTo(.5f).Within(.001f));
|
||||
combat.Step(.6f);
|
||||
Assert.That(combat.IsGroggy, Is.False);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator OwnerDeathAndGenerationChangeCancelBoundStateWithoutDeletingOtherFactoryActors()
|
||||
{
|
||||
EnemyDefinition definition = AssetDatabase.LoadAssetAtPath<EnemyDefinition>(BossDefinitionPath);
|
||||
Defense25DEnemy boss = CreateBoss(definition);
|
||||
int protectionOffCalls = 0;
|
||||
var combat = new Defense25DBossCombat();
|
||||
Assert.That(combat.Bind(world, boss, definition, _ => new[] { 901, 902, 903 },
|
||||
value => { if (!value) protectionOffCalls++; }), Is.True);
|
||||
Assert.That(boss.BindBossCombat(combat), Is.True);
|
||||
Assert.That(combat.BeginGroggy(10f), Is.True);
|
||||
|
||||
world.ResetRun();
|
||||
combat.Step(.1f);
|
||||
Assert.That(combat.IsBound, Is.False);
|
||||
Assert.That(combat.IsGroggy, Is.False);
|
||||
Assert.That(protectionOffCalls, Is.EqualTo(1));
|
||||
Assert.That(boss != null, Is.True,
|
||||
"Cancelling the owner must not destroy factory-owned actors or the boss GameObject.");
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator OwnerDeathIsObservedAndCancelsItsRunClockState()
|
||||
{
|
||||
EnemyDefinition definition = AssetDatabase.LoadAssetAtPath<EnemyDefinition>(BossDefinitionPath);
|
||||
Defense25DEnemy boss = CreateBoss(definition);
|
||||
var combat = new Defense25DBossCombat();
|
||||
Assert.That(combat.Bind(world, boss, definition, _ => new[] { 901, 902, 903 }), Is.True);
|
||||
Assert.That(boss.BindBossCombat(combat), Is.True);
|
||||
|
||||
Assert.That(boss.Actor.TakeDamage(100f, Vector2.right), Is.True);
|
||||
Assert.That(boss.Actor.IsAlive, Is.False);
|
||||
combat.Step(.1f);
|
||||
Assert.That(combat.IsBound, Is.False);
|
||||
Assert.That(combat.DesperationStarted, Is.False);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator FinalBossAmbientSummonFromWorldFixedUpdateWaitsUntilNextEnemyTick()
|
||||
{
|
||||
float previousTimeScale = Time.timeScale;
|
||||
GameplayConstants previousConstants = GameplayConstants.Current;
|
||||
GameplayConstants testConstants = null;
|
||||
EnemyConstants testEnemyConstants = null;
|
||||
Defense25DWaveDirectorConstants testWaveConstants = null;
|
||||
Time.timeScale = 0f;
|
||||
try
|
||||
{
|
||||
Object.Destroy(world.gameObject);
|
||||
world = null;
|
||||
yield return null;
|
||||
|
||||
testConstants = Object.Instantiate(previousConstants);
|
||||
testEnemyConstants = Object.Instantiate(previousConstants.Enemies);
|
||||
testEnemyConstants.GeneralSummonCooldown = 0f;
|
||||
FieldInfo enemyField = typeof(GameplayConstants).GetField("enemies",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.That(enemyField, Is.Not.Null);
|
||||
enemyField.SetValue(testConstants, testEnemyConstants);
|
||||
GameplayConstants.SetCurrentForTests(testConstants);
|
||||
|
||||
testWaveConstants = Object.Instantiate(runDefinition.WaveDirectorConstants);
|
||||
Defense25DThreatCurvePoint[] threatCurve = testWaveConstants.ThreatCurve;
|
||||
for (int i = 0; i < threatCurve.Length; i++) threatCurve[i].ThreatBudget = .8f;
|
||||
testWaveConstants.ThreatCurve = threatCurve;
|
||||
runDefinition.WaveDirectorConstants = testWaveConstants;
|
||||
world = new GameObject("Boss summon world Step integration test")
|
||||
.AddComponent<Defense25DComparison>();
|
||||
world.InitializeLongRun(settings, runDefinition, 17);
|
||||
world.AutoSpawn = false;
|
||||
world.ReadInput = false;
|
||||
world.AdvanceRunClock(runDefinition.PreparationSeconds + runDefinition.FinalBossAtSeconds);
|
||||
|
||||
Defense25DEnemy boss = world.Enemies.Single(enemy =>
|
||||
enemy.SpawnContext.Source == Defense25DSpawnSource.FinalBoss && enemy.IsAvailable);
|
||||
Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.BossActive));
|
||||
Assert.That(boss.BossCombat, Is.Not.Null);
|
||||
Assert.That(boss.BossCombat.IsBound, Is.True);
|
||||
Assert.That(world.Enemies.Any(enemy =>
|
||||
enemy.SpawnContext.Source == Defense25DSpawnSource.BossSummon), Is.False);
|
||||
|
||||
int countBeforeWorldTick = world.Enemies.Count;
|
||||
Exception firstTickError = InvokeWorldFixedUpdate(world);
|
||||
Assert.That(firstTickError, Is.Null,
|
||||
"The actual world enemy iteration must tolerate the FinalBoss ambient factory adding to Enemies. "
|
||||
+ firstTickError);
|
||||
|
||||
Defense25DEnemy[] ownedSummons = world.Enemies.Where(enemy =>
|
||||
enemy.SpawnContext.Source == Defense25DSpawnSource.BossSummon
|
||||
&& enemy.SpawnContext.SummonOwnerSpawnId == boss.SpawnContext.SpawnId).ToArray();
|
||||
Assert.That(world.Enemies.Count, Is.EqualTo(countBeforeWorldTick + 1),
|
||||
"The real FinalBoss general-summon callback should append one factory-created enemy.");
|
||||
Assert.That(ownedSummons, Has.Length.EqualTo(1));
|
||||
Assert.That(ownedSummons[0].Actor.LastMove, Is.EqualTo(Vector2.zero),
|
||||
"A summon appended during this tick must not also receive Enemy.Step in the same tick.");
|
||||
Assert.That(ownedSummons[0].MonsterCombat.IsAttackInProgress, Is.False);
|
||||
|
||||
Exception nextTickError = InvokeWorldFixedUpdate(world);
|
||||
Assert.That(nextTickError, Is.Null, nextTickError != null ? nextTickError.ToString() : string.Empty);
|
||||
Assert.That(ownedSummons[0].Actor.LastMove.sqrMagnitude > .000001f
|
||||
|| ownedSummons[0].MonsterCombat.IsAttackInProgress, Is.True,
|
||||
"The factory-created summon should begin its normal Enemy.Step behavior on the next world tick.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
GameplayConstants.SetCurrentForTests(previousConstants);
|
||||
if (testEnemyConstants != null) Object.Destroy(testEnemyConstants);
|
||||
if (testConstants != null) Object.Destroy(testConstants);
|
||||
if (testWaveConstants != null) Object.Destroy(testWaveConstants);
|
||||
Time.timeScale = previousTimeScale;
|
||||
}
|
||||
yield return null;
|
||||
}
|
||||
|
||||
private static Exception InvokeWorldFixedUpdate(Defense25DComparison comparison)
|
||||
{
|
||||
MethodInfo fixedUpdate = typeof(Defense25DComparison).GetMethod("FixedUpdate",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
if (fixedUpdate == null)
|
||||
return new MissingMethodException(typeof(Defense25DComparison).FullName, "FixedUpdate");
|
||||
try
|
||||
{
|
||||
fixedUpdate.Invoke(comparison, null);
|
||||
return null;
|
||||
}
|
||||
catch (TargetInvocationException exception)
|
||||
{
|
||||
return exception.InnerException ?? exception;
|
||||
}
|
||||
}
|
||||
|
||||
private Defense25DEnemy CreateBoss(EnemyDefinition definition)
|
||||
{
|
||||
bossObject = new GameObject("Necromancer test actor");
|
||||
Defense25DActor actor = bossObject.AddComponent<Defense25DActor>();
|
||||
actor.Configure(world, ComparisonActorKind.Enemy);
|
||||
Defense25DEnemy boss = bossObject.AddComponent<Defense25DEnemy>();
|
||||
boss.Configure(world, actor, 9001, 0);
|
||||
actor.SetEnemyDefinition(definition);
|
||||
boss.SetSpawnContext(new Defense25DEnemySpawnContext(world.RunGeneration, 9001, 0, 0,
|
||||
Defense25DSpawnSource.FinalBoss));
|
||||
boss.Spawn(world.RunLayout.Routes[0][0]);
|
||||
return boss;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1b9fb11e188f46d7b38306f9669cc945
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -189,10 +189,18 @@ namespace BumpCombat.Tests
|
||||
feedback.NotifyChargeChanged(artifact, 1f, true);
|
||||
Assert.That(feedback.IsReadyFlashVisible, Is.True,
|
||||
"The four-frame Flash sheet is validated against FlashFrameCount, not the six-frame aura count.");
|
||||
Sprite[] flashFrames = Resources.LoadAll<Sprite>(
|
||||
Texture2D flashSheet = Resources.Load<Texture2D>(
|
||||
Defense25DCombatFeedbackConstants.ChargeResourceRoot + artifact.Effect
|
||||
+ Defense25DCombatFeedbackConstants.ChargeFlashSuffix);
|
||||
Assert.That(flashFrames, Has.Length.EqualTo(ArtifactChargeVisual.FlashFrameCount));
|
||||
Assert.That(flashSheet, Is.Not.Null);
|
||||
Assert.That(flashSheet.width,
|
||||
Is.EqualTo(ArtifactChargeVisual.CellSize * ArtifactChargeVisual.FlashFrameCount));
|
||||
Assert.That(flashSheet.height, Is.EqualTo(ArtifactChargeVisual.CellSize));
|
||||
SpriteRenderer flash = Object.FindObjectsByType<SpriteRenderer>(FindObjectsSortMode.None)
|
||||
.FirstOrDefault(renderer => renderer.gameObject.name == "Artifact Charge "
|
||||
+ Defense25DCombatFeedbackConstants.ChargeFlashSuffix);
|
||||
Assert.That(flash, Is.Not.Null);
|
||||
Assert.That(flash.sprite.rect.width, Is.EqualTo(ArtifactChargeVisual.CellSize));
|
||||
|
||||
feedback.NotifyArtifactSwitched(artifact);
|
||||
SpriteRenderer switchPulse = Object.FindObjectsByType<SpriteRenderer>(FindObjectsSortMode.None)
|
||||
|
||||
@@ -53,29 +53,29 @@ namespace BumpCombat.Tests
|
||||
[UnityTest]
|
||||
public IEnumerator ValidHitPopups_MatchNormalSideBackAndArtifactContracts()
|
||||
{
|
||||
GameObject target = world.Enemies[0].gameObject;
|
||||
GameObject target = SpawnPopupTarget().gameObject;
|
||||
RaiseHit(target, HitSide.Front, 31.9f);
|
||||
AssertPopup(0, "31", Color.white);
|
||||
|
||||
world.ResetRun();
|
||||
target = world.Enemies[0].gameObject;
|
||||
target = SpawnPopupTarget().gameObject;
|
||||
RaiseHit(target, HitSide.Side, 22.8f);
|
||||
AssertPopup(0, "SIDE ×1.2 22", new Color(0.65f, 1f, 0.3f));
|
||||
|
||||
world.ResetRun();
|
||||
target = world.Enemies[0].gameObject;
|
||||
target = SpawnPopupTarget().gameObject;
|
||||
RaiseHit(target, HitSide.Back, 9.8f);
|
||||
AssertPopup(0, "BACK ×1.5 9", new Color(1f, 0.75f, 0.2f));
|
||||
|
||||
world.ResetRun();
|
||||
target = world.Enemies[0].gameObject;
|
||||
target = SpawnPopupTarget().gameObject;
|
||||
CombatEvents.RaiseValidHit(new CombatHitResult(
|
||||
world.Player.gameObject, target, true, HitSide.Front, 13f,
|
||||
Vector2.right, 0f, Vector2.zero, launchSucceeded: true));
|
||||
AssertPopup(0, "LAUNCH 13", new Color(0.95f, 0.55f, 1f));
|
||||
|
||||
world.ResetRun();
|
||||
target = world.Enemies[0].gameObject;
|
||||
target = SpawnPopupTarget().gameObject;
|
||||
ActiveArtifactDefinition dash = world.Loadout.FindArtifact(ActiveArtifactEffect.Dash);
|
||||
Assert.That(dash, Is.Not.Null);
|
||||
CombatEvents.RaiseValidHit(new CombatHitResult(
|
||||
@@ -86,7 +86,7 @@ namespace BumpCombat.Tests
|
||||
ActiveArtifactDefinition.GetArtifactPaletteColor(dash.ArtifactColor));
|
||||
|
||||
world.ResetRun();
|
||||
target = world.Enemies[0].gameObject;
|
||||
target = SpawnPopupTarget().gameObject;
|
||||
ActiveArtifactDefinition cyclone = world.Loadout.FindArtifact(ActiveArtifactEffect.Cyclone);
|
||||
Assert.That(cyclone, Is.Not.Null);
|
||||
CombatEvents.RaiseValidHit(CombatHitResult.ForArtifactContact(
|
||||
@@ -100,7 +100,12 @@ namespace BumpCombat.Tests
|
||||
[UnityTest]
|
||||
public IEnumerator PopupEvents_AreWorldScopedResettableAndRemovedOnDisable()
|
||||
{
|
||||
GameObject worldTarget = world.Enemies[0].gameObject;
|
||||
Defense25DEnemy unspawnedEnemy = world.Enemies[0];
|
||||
Assert.That(unspawnedEnemy.IsAvailable, Is.False);
|
||||
Assert.That(unspawnedEnemy.gameObject.activeInHierarchy, Is.False);
|
||||
Assert.That(unspawnedEnemy.gameObject.GetComponentInParent<Defense25DEnemy>(), Is.Null,
|
||||
"The default parent lookup excludes the inactive, unsupplied enemy fixture.");
|
||||
GameObject worldTarget = unspawnedEnemy.gameObject;
|
||||
GameObject unrelatedTarget = new("Unrelated popup target");
|
||||
GameObject unrelatedAttacker = new("Unrelated popup attacker");
|
||||
try
|
||||
@@ -113,10 +118,18 @@ namespace BumpCombat.Tests
|
||||
Assert.That(world.ActiveCombatDamagePopupCount, Is.Zero,
|
||||
"Hits from another attacker against this world's enemy are ignored.");
|
||||
|
||||
Assert.That(world.ActiveCombatDamagePopupCount, Is.Zero,
|
||||
"An event targeting an inactive, unsupplied enemy cannot create a popup.");
|
||||
Defense25DEnemy activeEnemy = SpawnPopupTarget();
|
||||
worldTarget = activeEnemy.gameObject;
|
||||
Assert.That(worldTarget.GetComponentInParent<Defense25DEnemy>(), Is.SameAs(activeEnemy));
|
||||
RaiseHit(worldTarget, HitSide.Front, 10f);
|
||||
Assert.That(world.ActiveCombatDamagePopupCount, Is.EqualTo(1));
|
||||
Assert.That(world.ActiveCombatDamagePopupCount, Is.EqualTo(1),
|
||||
"A valid hit against an active, supplied enemy must be received by this world's popup handler.");
|
||||
world.ResetRun();
|
||||
Assert.That(world.ActiveCombatDamagePopupCount, Is.Zero);
|
||||
activeEnemy = SpawnPopupTarget();
|
||||
worldTarget = activeEnemy.gameObject;
|
||||
|
||||
world.enabled = false;
|
||||
RaiseHit(worldTarget, HitSide.Front, 10f);
|
||||
@@ -125,7 +138,7 @@ namespace BumpCombat.Tests
|
||||
world.enabled = true;
|
||||
Assert.That(world.ActiveCombatDamagePopupCount, Is.Zero,
|
||||
"A disabled comparison must reject event and direct popup additions.");
|
||||
RaiseHit(world.Enemies[0].gameObject, HitSide.Front, 10f);
|
||||
RaiseHit(worldTarget, HitSide.Front, 10f);
|
||||
Assert.That(world.ActiveCombatDamagePopupCount, Is.EqualTo(1),
|
||||
"Re-enabling the comparison must restore one event subscription.");
|
||||
}
|
||||
@@ -172,6 +185,15 @@ namespace BumpCombat.Tests
|
||||
0f, Vector2.zero, isOffsetHit: false));
|
||||
}
|
||||
|
||||
private Defense25DEnemy SpawnPopupTarget()
|
||||
{
|
||||
Defense25DEnemy enemy = world.SpawnNextEnemy();
|
||||
Assert.That(enemy, Is.Not.Null, "The popup fixture needs a supplied enemy from the world factory.");
|
||||
Assert.That(enemy.IsAvailable, Is.True);
|
||||
Assert.That(enemy.gameObject.GetComponentInParent<Defense25DEnemy>(), Is.SameAs(enemy));
|
||||
return enemy;
|
||||
}
|
||||
|
||||
private void AssertPopup(int index, string text, Color color)
|
||||
{
|
||||
FieldInfo popupArrayField = typeof(Defense25DComparison).GetField(
|
||||
|
||||
@@ -522,6 +522,50 @@ namespace BumpCombat.Tests
|
||||
Assert.That(world.Gauge, Is.EqualTo(gauge).Within(.02f), "Holding into a wall must not charge movement gauge.");
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator DenseEnemyActorsDoNotHideWallWhenMovementCastBufferFills()
|
||||
{
|
||||
const int blockerActorCount = 80;
|
||||
world.Player.ResetAt(Vector2.zero);
|
||||
for (int i = 0; i < blockerActorCount; i++)
|
||||
{
|
||||
GameObject actorObject = new($"Dense movement actor {i}");
|
||||
actorObject.transform.SetParent(world.transform, false);
|
||||
actorObject.transform.position = Defense25DActor.OnGround(
|
||||
new Vector2(.55f + i * .0005f, 0f), settings.GroundY);
|
||||
actorObject.AddComponent<Defense25DActor>().Configure(world, ComparisonActorKind.Enemy);
|
||||
}
|
||||
GameObject wall = Wall(new Vector2(1.2f, 0f), new Vector3(.2f, 1.5f, 6f));
|
||||
Collider wallCollider = wall.GetComponent<Collider>();
|
||||
Physics.SyncTransforms();
|
||||
|
||||
CapsuleCollider capsule = world.Player.BodyCollider;
|
||||
float cylinder = Mathf.Max(0f, capsule.height * .5f - capsule.radius);
|
||||
Vector3 center = Defense25DActor.OnGround(world.Player.Center,
|
||||
settings.GroundY + capsule.center.y);
|
||||
RaycastHit[] initialCapacity = new RaycastHit[64];
|
||||
int initialHitCount = Physics.CapsuleCastNonAlloc(center + Vector3.up * cylinder,
|
||||
center - Vector3.up * cylinder, capsule.radius, Vector3.right,
|
||||
initialCapacity, 2f + settings.MovementSkin,
|
||||
Defense25DComparisonSettings.TerrainMask | Defense25DComparisonSettings.ActorMask,
|
||||
QueryTriggerInteraction.Ignore);
|
||||
Assert.That(initialHitCount, Is.EqualTo(initialCapacity.Length),
|
||||
"The fixture must saturate the original movement hit capacity with real actor colliders.");
|
||||
Assert.That(initialCapacity.Count(hit => hit.collider != wallCollider &&
|
||||
hit.collider.name.StartsWith("Dense movement actor", StringComparison.Ordinal)),
|
||||
Is.GreaterThanOrEqualTo(60),
|
||||
"Real actor colliders must occupy nearly all of the original movement hit capacity.");
|
||||
|
||||
world.Player.Move(Vector2.right * 2f);
|
||||
Assert.That(world.Player.MovementBlocked, Is.True,
|
||||
"Dense pass-through actors must not hide the actual wall from the expanded movement query.");
|
||||
Assert.That(world.Player.LastMovementBlocker, Is.EqualTo(wallCollider));
|
||||
Assert.That(world.Player.LastMove.x, Is.GreaterThan(.5f).And.LessThan(1f),
|
||||
"The player must advance through pass-through enemies, then stop before the wall.");
|
||||
yield return new WaitForFixedUpdate();
|
||||
Assert.That(world.Player.Position.x, Is.LessThan(1.2f - .1f + settings.MovementSkin));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator BumpDeduplicatesColliderHitsAndHonorsTargetInterval()
|
||||
{
|
||||
@@ -1062,6 +1106,181 @@ namespace BumpCombat.Tests
|
||||
Assert.That(CountCyclonePixels(exposed), Is.GreaterThan(CountCyclonePixels(occluded)),
|
||||
"Removing the wall must reveal effect pixels previously hidden by real depth.");
|
||||
wallRenderer.enabled = true;
|
||||
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator CaptureCycloneFrontBackAndOrbitAtEightFramesForNormalAndCharged()
|
||||
{
|
||||
Time.timeScale = 1f;
|
||||
Object.Destroy(world.gameObject);
|
||||
yield return null;
|
||||
yield return EditorSceneManager.LoadSceneAsyncInPlayMode(
|
||||
"Assets/_Project/Scenes/Defense25DComparison.unity", new LoadSceneParameters(LoadSceneMode.Single));
|
||||
world = Object.FindAnyObjectByType<Defense25DComparison>();
|
||||
yield return null; // Saved scene initialization runs in Start.
|
||||
Assert.That(world, Is.Not.Null);
|
||||
world.ReadInput = false;
|
||||
world.AutoSpawn = false;
|
||||
world.ShowBoundaries = false;
|
||||
world.ResetRun();
|
||||
CompleteStartupArtifactSelection(world);
|
||||
world.SetPaused(false);
|
||||
world.DebugFillGauge();
|
||||
Assert.That(world.View, Is.Not.Null);
|
||||
Camera sourceCamera = world.View.Camera;
|
||||
Vector3 originalPosition = sourceCamera.transform.position;
|
||||
Quaternion originalRotation = sourceCamera.transform.rotation;
|
||||
float originalSize = sourceCamera.orthographicSize;
|
||||
System.Reflection.FieldInfo elapsedField = typeof(Defense25DComparison).GetField(
|
||||
"<CycloneElapsed>k__BackingField", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
||||
Assert.That(elapsedField, Is.Not.Null, "Use paused exact elapsed values for synchronized frame captures.");
|
||||
System.Reflection.FieldInfo orbitRendererField = typeof(Defense25DCycloneVisual).GetField(
|
||||
"orbitRenderer", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
||||
Assert.That(orbitRendererField, Is.Not.Null);
|
||||
MeshRenderer orbitRenderer = (MeshRenderer)orbitRendererField.GetValue(world.CycloneVisual);
|
||||
SpriteRenderer bodyRenderer = world.Player.Visual.Renderer;
|
||||
Shader maskShader = Resources.Load<Shader>(
|
||||
"Artifacts/ThreeColor-v1/CycloneOrbit25D-v1/CycloneBodyMask");
|
||||
Assert.That(maskShader, Is.Not.Null);
|
||||
Material orbitMaterial = orbitRenderer.sharedMaterial;
|
||||
Assert.That(orbitMaterial.shader, Is.EqualTo(maskShader));
|
||||
Assert.That(orbitMaterial.HasProperty("_BodyMaskEnabled"), Is.True);
|
||||
|
||||
try
|
||||
{
|
||||
sourceCamera.orthographicSize = 2f;
|
||||
foreach (bool charged in new[] { false, true })
|
||||
{
|
||||
world.CancelCyclone();
|
||||
world.SetPaused(false);
|
||||
world.DebugFillGauge();
|
||||
Assert.That(world.TryUseCyclone(charged, Vector2.right), Is.True, "Each visual comparison needs a live Cyclone.");
|
||||
world.SetPaused(true);
|
||||
Vector2 lookAt = (world.Player.Position + world.CycloneCenter) * .5f;
|
||||
Vector3 target = Defense25DActor.OnGround(lookAt, world.Settings.GroundY + .45f);
|
||||
sourceCamera.transform.position = target - sourceCamera.transform.forward * 10f;
|
||||
world.CycloneVisual.Height = .15f;
|
||||
world.CycloneVisual.RadiusScale = 1f;
|
||||
world.CycloneVisual.DecorationSpeed = 1f;
|
||||
for (int frame = 0; frame < 8; frame++)
|
||||
{
|
||||
elapsedField.SetValue(world, frame * .05f);
|
||||
for (int i = 0; i < 2; i++) yield return null;
|
||||
string prefix = charged ? "charged" : "normal";
|
||||
world.CycloneVisual.Ribbon = false;
|
||||
yield return null;
|
||||
Color32[] referencePixels = CaptureCamera(sourceCamera, $"cyclone-{prefix}-front-back-frame-{frame:00}");
|
||||
world.CycloneVisual.Ribbon = true;
|
||||
yield return null;
|
||||
Color32[] orbitPixels = CaptureCamera(sourceCamera, $"cyclone-{prefix}-orbit-frame-{frame:00}");
|
||||
Assert.That(CountCyclonePixels(orbitPixels), Is.GreaterThan(100), "Orbit curves must contribute visible mint pixels.");
|
||||
Assert.That(DifferentPixels(referencePixels, orbitPixels), Is.GreaterThan(100), "Both captures must reflect their selected renderer.");
|
||||
}
|
||||
int restoredBodyPixels = 0;
|
||||
var playFrame = typeof(Defense25DSprite).GetMethod("StartTimedSequence",
|
||||
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
||||
Assert.That(playFrame, Is.Not.Null);
|
||||
foreach (bool walking in new[] { false, true })
|
||||
foreach (bool flip in new[] { false, true })
|
||||
{
|
||||
Sprite[] motion = walking ? world.Settings.PlayerArt.Walk : world.Settings.PlayerArt.Idle;
|
||||
Assert.That(motion.Length, Is.GreaterThan(0));
|
||||
playFrame.Invoke(world.Player.Visual, new object[] {
|
||||
new[] { motion[walking ? Mathf.Min(1, motion.Length - 1) : 0] }, 1f, walking ? "Walk" : "Idle" });
|
||||
world.Player.Facing = flip ? Vector2.left : Vector2.right;
|
||||
elapsedField.SetValue(world, .1f);
|
||||
string label = $"mask-{(charged ? "charged" : "normal")}-{(walking ? "walk" : "idle")}-{(flip ? "left" : "right")}";
|
||||
world.CycloneVisual.ProtectBody = false;
|
||||
yield return null;
|
||||
yield return null;
|
||||
Color32[] unmasked = CaptureCamera(sourceCamera, label + "-off");
|
||||
world.CycloneVisual.ProtectBody = true;
|
||||
yield return null;
|
||||
Color32[] masked = CaptureCamera(sourceCamera, label + "-on");
|
||||
world.CycloneVisual.SetVisible(false);
|
||||
yield return null;
|
||||
Color32[] baseline = CaptureCamera(sourceCamera, label + "-hidden");
|
||||
world.CycloneVisual.SetVisible(true);
|
||||
Assert.That(bodyRenderer.flipX, Is.EqualTo(flip));
|
||||
Assert.That(orbitMaterial.GetTexture("_BodyTex"), Is.EqualTo(bodyRenderer.sprite.texture));
|
||||
restoredBodyPixels += AssertCycloneBodyAndFeetPixels(sourceCamera, bodyRenderer,
|
||||
world.Player.Visual.FootPosition, baseline, unmasked, masked, label);
|
||||
}
|
||||
Assert.That(restoredBodyPixels, Is.GreaterThan(0), "The mask must remove an actual body overlap in both Cyclone variants.");
|
||||
world.Player.Visual.ResetVisual();
|
||||
world.Player.Facing = Vector2.right;
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (sourceCamera != null)
|
||||
{
|
||||
sourceCamera.transform.SetPositionAndRotation(originalPosition, originalRotation);
|
||||
sourceCamera.orthographicSize = originalSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int AssertCycloneBodyAndFeetPixels(Camera camera, SpriteRenderer body,
|
||||
Vector3 foot, Color32[] baseline, Color32[] unmasked, Color32[] masked, string label)
|
||||
{
|
||||
Sprite sprite = body.sprite;
|
||||
// Read alpha through a temporary GPU copy; production sprite importers
|
||||
// deliberately keep CPU readability disabled.
|
||||
Texture2D texture = new(sprite.texture.width, sprite.texture.height, TextureFormat.RGBA32, false, true);
|
||||
RenderTexture alphaTarget = RenderTexture.GetTemporary(texture.width, texture.height, 0,
|
||||
RenderTextureFormat.ARGB32, RenderTextureReadWrite.Linear);
|
||||
RenderTexture previousTarget = RenderTexture.active;
|
||||
Graphics.Blit(sprite.texture, alphaTarget);
|
||||
RenderTexture.active = alphaTarget;
|
||||
texture.ReadPixels(new Rect(0, 0, texture.width, texture.height), 0, 0);
|
||||
texture.Apply();
|
||||
RenderTexture.active = previousTarget;
|
||||
RenderTexture.ReleaseTemporary(alphaTarget);
|
||||
Rect rect = sprite.textureRect;
|
||||
Vector2 pivot = sprite.pivot - sprite.textureRectOffset;
|
||||
Vector3 footLocal = body.transform.InverseTransformPoint(foot);
|
||||
float minY = footLocal.y + Defense25DComparisonSettings.CycloneBodyMaskFootClearancePixels / sprite.pixelsPerUnit;
|
||||
Matrix4x4 inverse = body.transform.worldToLocalMatrix;
|
||||
float depth = camera.WorldToScreenPoint(body.transform.position).z;
|
||||
Vector3 footScreen = camera.WorldToScreenPoint(foot);
|
||||
float cutoffY = camera.WorldToScreenPoint(body.transform.TransformPoint(new Vector3(footLocal.x, minY, 0))).y;
|
||||
int bodyPixels = 0, bodyMismatch = 0, restored = 0, feetPixels = 0, outsidePixels = 0;
|
||||
for (int y = 0; y < 360; y++)
|
||||
for (int x = 0; x < 640; x++)
|
||||
{
|
||||
int index = y * 640 + x;
|
||||
Vector3 local = inverse.MultiplyPoint3x4(camera.ScreenToWorldPoint(new Vector3(x + .5f, y + .5f, depth)));
|
||||
float px = local.x * (body.flipX ? -1f : 1f) * sprite.pixelsPerUnit + pivot.x;
|
||||
float py = local.y * (body.flipY ? -1f : 1f) * sprite.pixelsPerUnit + pivot.y;
|
||||
bool protectedBody = false;
|
||||
if (local.y >= minY && px >= 1 && py >= 1 && px < rect.width - 1 && py < rect.height - 1)
|
||||
{
|
||||
int tx = Mathf.FloorToInt(rect.x + px), ty = Mathf.FloorToInt(rect.y + py);
|
||||
// Check interior opaque pixels; exclude the rasterized silhouette boundary.
|
||||
protectedBody = texture.GetPixel(tx, ty).a >= .4f
|
||||
&& texture.GetPixel(tx - 1, ty).a >= .4f && texture.GetPixel(tx + 1, ty).a >= .4f
|
||||
&& texture.GetPixel(tx, ty - 1).a >= .4f && texture.GetPixel(tx, ty + 1).a >= .4f;
|
||||
}
|
||||
if (protectedBody)
|
||||
{
|
||||
bodyPixels++;
|
||||
if (!masked[index].Equals(baseline[index])) bodyMismatch++;
|
||||
if (!unmasked[index].Equals(baseline[index]) && masked[index].Equals(baseline[index])) restored++;
|
||||
}
|
||||
else if (!masked[index].Equals(baseline[index])) outsidePixels++;
|
||||
if (y < cutoffY && y > footScreen.y - 32 && Mathf.Abs(x - footScreen.x) < 48
|
||||
&& !masked[index].Equals(baseline[index])) feetPixels++;
|
||||
}
|
||||
Object.Destroy(texture);
|
||||
Assert.That(bodyPixels, Is.GreaterThan(20), label + " must sample the opaque torso/head.");
|
||||
Assert.That(bodyMismatch, Is.Zero, label + " protected body must match effect-hidden pixels.");
|
||||
Assert.That(feetPixels, Is.GreaterThan(10), label + " foreground feet arcs must remain visible.");
|
||||
Assert.That(outsidePixels, Is.GreaterThan(100), label + " outside orbit must remain visible.");
|
||||
Debug.Log($"CYCLONE_BODY_MASK {label} body={bodyPixels} mismatch={bodyMismatch} restored={restored} feet={feetPixels} outside={outsidePixels}");
|
||||
return restored;
|
||||
}
|
||||
|
||||
private static int CountCyclonePixels(Color32[] pixels) => pixels.Count(p => p.g > 90 && p.g > p.r * 1.7f && p.b > p.r * 1.2f && p.b < p.g);
|
||||
@@ -1100,6 +1319,30 @@ namespace BumpCombat.Tests
|
||||
return pixels;
|
||||
}
|
||||
|
||||
private Color32[] CaptureCamera(Camera camera, string label)
|
||||
{
|
||||
string directory = Environment.GetEnvironmentVariable("DEFENSE25D_OUTPUT")
|
||||
?? Path.Combine(Application.temporaryCachePath, "Defense25DComparison");
|
||||
Directory.CreateDirectory(directory);
|
||||
RenderTexture target = new(640, 360, 24, RenderTextureFormat.ARGB32);
|
||||
RenderTexture previous = RenderTexture.active;
|
||||
RenderTexture originalTarget = camera.targetTexture;
|
||||
camera.targetTexture = target;
|
||||
camera.Render();
|
||||
RenderTexture.active = target;
|
||||
Texture2D image = new(640, 360, TextureFormat.RGB24, false);
|
||||
image.ReadPixels(new Rect(0, 0, 640, 360), 0, 0);
|
||||
image.Apply();
|
||||
Color32[] pixels = image.GetPixels32();
|
||||
File.WriteAllBytes(Path.Combine(directory, label + "-640x360.png"), image.EncodeToPNG());
|
||||
RenderTexture.active = previous;
|
||||
camera.targetTexture = originalTarget;
|
||||
target.Release();
|
||||
Object.Destroy(target);
|
||||
Object.Destroy(image);
|
||||
return pixels;
|
||||
}
|
||||
|
||||
private void AssertCapturedGeometry(Color32[] pixels)
|
||||
{
|
||||
Color32 background = pixels[0];
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
using System.Collections;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Defense;
|
||||
using BumpCombat.Defense.Comparison;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace BumpCombat.Tests
|
||||
{
|
||||
public sealed class Defense25DFacilityVisualPlayModeTests
|
||||
{
|
||||
private const string ComparisonSettingsPath = "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset";
|
||||
private const string FacilitySettingsPath = "Assets/_Project/Constants/Defense25DComparison/FacilityVisualConstants.asset";
|
||||
private Defense25DComparison world;
|
||||
private Defense25DComparison integratedWorld;
|
||||
private Defense25DComparisonSettings comparisonSettings;
|
||||
private Defense25DRunDefinition runDefinition;
|
||||
private Defense25DFacilityVisualConstants facilitySettings;
|
||||
private Defense25DFacilityVisuals visuals;
|
||||
private float previousTimeScale;
|
||||
|
||||
[UnitySetUp]
|
||||
public IEnumerator SetUp()
|
||||
{
|
||||
previousTimeScale = Time.timeScale;
|
||||
Time.timeScale = 1f;
|
||||
comparisonSettings = Object.Instantiate(
|
||||
AssetDatabase.LoadAssetAtPath<Defense25DComparisonSettings>(ComparisonSettingsPath));
|
||||
facilitySettings = AssetDatabase.LoadAssetAtPath<Defense25DFacilityVisualConstants>(FacilitySettingsPath);
|
||||
Assert.That(comparisonSettings, Is.Not.Null);
|
||||
Assert.That(facilitySettings, Is.Not.Null);
|
||||
|
||||
world = new GameObject("Facility visual test world").AddComponent<Defense25DComparison>();
|
||||
world.Initialize(comparisonSettings);
|
||||
world.AutoSpawn = false;
|
||||
world.ReadInput = false;
|
||||
visuals = new GameObject("Facility visuals under test").AddComponent<Defense25DFacilityVisuals>();
|
||||
Assert.That(visuals.Bind(world, facilitySettings, 17, out string error), Is.True, error);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTearDown]
|
||||
public IEnumerator TearDown()
|
||||
{
|
||||
Time.timeScale = 1f;
|
||||
if (visuals != null) visuals.Clear();
|
||||
if (visuals != null) Object.Destroy(visuals.gameObject);
|
||||
if (integratedWorld != null) Object.Destroy(integratedWorld.gameObject);
|
||||
if (world != null) Object.Destroy(world.gameObject);
|
||||
if (comparisonSettings != null) Object.Destroy(comparisonSettings);
|
||||
if (runDefinition != null) Object.Destroy(runDefinition);
|
||||
Time.timeScale = previousTimeScale;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator PlaybackTimingCanScaleAfterPlayAndPlayResetsToPausedClock()
|
||||
{
|
||||
const string key = "Effects/Lightning/ImpactWave";
|
||||
Sprite[] frames = DefenseTowerArt.Load(key);
|
||||
Assert.That(frames, Is.Not.Empty);
|
||||
Time.timeScale = 0f;
|
||||
|
||||
var spedUp = CreateSequence("Unscaled sequence");
|
||||
spedUp.Sequence.Play(spedUp.Renderer, key, frames, false);
|
||||
spedUp.Sequence.SetPlaybackTiming(.5f, useUnscaledTime: true);
|
||||
yield return new WaitForSecondsRealtime(.22f);
|
||||
Assert.That(spedUp.Sequence.IsFinished, Is.True,
|
||||
"The 0.30 second source strip must finish in 0.15 unscaled seconds at durationScale .5.");
|
||||
|
||||
spedUp.Sequence.Play(spedUp.Renderer, key, frames, false);
|
||||
yield return new WaitForSecondsRealtime(.22f);
|
||||
Assert.That(spedUp.Sequence.IsFinished, Is.False,
|
||||
"A later Play on a completed sequence must restore its default pause-aware clock and unit duration scale.");
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator SpawnGateClearAndUpgradeReplacementUseOnlyDepthTestedSpriteLayers()
|
||||
{
|
||||
Vector3 anchor = new(2f, comparisonSettings.GroundY, -3f);
|
||||
visuals.PlayPortalSpawn(4, anchor);
|
||||
visuals.PlayPortalSpawn(4, anchor);
|
||||
visuals.PlayPortalSpawn(5, anchor);
|
||||
yield return null;
|
||||
|
||||
Assert.That(ActiveRootCount(), Is.EqualTo(2), "A portal event is once per portal ID and generation.");
|
||||
Assert.That(visuals.GetComponentsInChildren<Collider>(true), Is.Empty,
|
||||
"Facility art cannot add hitboxes or gameplay physics.");
|
||||
SpriteRenderer[] renderers = visuals.GetComponentsInChildren<SpriteRenderer>(true);
|
||||
Assert.That(renderers, Is.Not.Empty);
|
||||
Assert.That(renderers.All(renderer => renderer.sharedMaterial == comparisonSettings.CutoutMaterial), Is.True,
|
||||
"Facility layers must use the world's existing depth-tested cutout material.");
|
||||
Assert.That(renderers.All(renderer => renderer.color == Color.white), Is.True,
|
||||
"The original sprites retain their authored palette without a tint override.");
|
||||
Transform portalRoot = ActiveRoots().First(root => root.name.Contains("Portal Spawn")).transform;
|
||||
Transform groundRing = portalRoot.Find("Portal Spawn 4 Ground");
|
||||
Transform verticalStrip = portalRoot.Find("Portal Spawn 4 Vertical");
|
||||
Assert.That(groundRing.position.x, Is.EqualTo(anchor.x).Within(.001f));
|
||||
Assert.That(groundRing.position.z, Is.EqualTo(anchor.z).Within(.001f));
|
||||
Assert.That(groundRing.position.y, Is.EqualTo(anchor.y + facilitySettings.PortalSpawn.GroundYOffset).Within(.001f));
|
||||
Assert.That(verticalStrip.position.y, Is.EqualTo(anchor.y + facilitySettings.PortalSpawn.VerticalHeight).Within(.001f));
|
||||
|
||||
visuals.Clear();
|
||||
Assert.That(ActiveRootCount(), Is.Zero);
|
||||
Assert.That(visuals.Bind(world, facilitySettings, 18, out string error), Is.True, error);
|
||||
visuals.PlayPortalSpawn(4, anchor);
|
||||
Assert.That(ActiveRootCount(), Is.EqualTo(1), "Clear and rebind start a fresh generation gate.");
|
||||
|
||||
visuals.Clear();
|
||||
Assert.That(visuals.Bind(world, facilitySettings, 19, out error), Is.True, error);
|
||||
visuals.PlayTowerUpgrade(2, 2, anchor);
|
||||
GameObject priorUpgrade = ActiveRoots().Single();
|
||||
visuals.PlayTowerUpgrade(2, 3, anchor);
|
||||
GameObject replacement = ActiveRoots().Single();
|
||||
Assert.That(replacement, Is.Not.SameAs(priorUpgrade),
|
||||
"A successful upgrade replaces that pad's previous in-flight display instead of stacking it.");
|
||||
Assert.That(replacement.name, Does.Contain("Tower Upgrade"));
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator NormalEffectsPauseButTerminalHomeBurstCompletesOnUnscaledTime()
|
||||
{
|
||||
Vector3 anchor = new(1f, comparisonSettings.GroundY, 2f);
|
||||
Time.timeScale = 0f;
|
||||
visuals.PlayPortalSpawn(1, anchor);
|
||||
visuals.PlayHomeBurst(anchor);
|
||||
visuals.PlayPortalBurst(10, anchor);
|
||||
|
||||
yield return new WaitForSecondsRealtime(.95f);
|
||||
Assert.That(ActiveRoots().Any(root => root.name.Contains("Portal Spawn")), Is.True,
|
||||
"The normal portal opening effect follows the paused game clock.");
|
||||
Assert.That(ActiveRoots().Any(root => root.name.Contains("Home Burst")), Is.False,
|
||||
"The terminal home burst finishes while gameplay is paused.");
|
||||
Assert.That(ActiveRoots().Any(root => root.name.Contains("Portal Burst")), Is.False,
|
||||
"The terminal portal burst also finishes while gameplay is paused.");
|
||||
|
||||
Time.timeScale = 1f;
|
||||
yield return new WaitForSeconds(.7f);
|
||||
Assert.That(ActiveRoots().Any(root => root.name.Contains("Portal Spawn")), Is.False);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator AllFacilityEventApisUseTheirAuthoredOncePerRunRules()
|
||||
{
|
||||
Assert.That(facilitySettings.PortalSpawn.TargetDurationSeconds, Is.EqualTo(.6f).Within(.001f));
|
||||
Assert.That(facilitySettings.PortalBurst.TargetDurationSeconds, Is.EqualTo(.5f).Within(.001f));
|
||||
Assert.That(facilitySettings.HomeSpawn.TargetDurationSeconds, Is.EqualTo(.8f).Within(.001f));
|
||||
Assert.That(facilitySettings.HomeBurst.TargetDurationSeconds, Is.EqualTo(.8f).Within(.001f));
|
||||
Assert.That(facilitySettings.TowerPadSpawn.TargetDurationSeconds, Is.EqualTo(.4f).Within(.001f));
|
||||
Assert.That(facilitySettings.TowerUpgrade.TargetDurationSeconds, Is.EqualTo(.45f).Within(.001f));
|
||||
Vector3 anchor = new(-2f, comparisonSettings.GroundY, 1f);
|
||||
visuals.PlayPortalBurst(3, anchor);
|
||||
visuals.PlayPortalBurst(3, anchor);
|
||||
visuals.PlayPortalBurst(4, anchor);
|
||||
visuals.PlayHomeSpawn(anchor);
|
||||
visuals.PlayHomeSpawn(anchor);
|
||||
visuals.PlayPadSpawn(8, anchor);
|
||||
visuals.PlayPadSpawn(8, anchor);
|
||||
yield return null;
|
||||
|
||||
Assert.That(ActiveRootCount(), Is.EqualTo(4),
|
||||
"Portal bursts are once per portal, while home and pad spawn are once per run and pad ID.");
|
||||
Assert.That(ActiveRoots().Count(root => root.name.Contains("Portal Burst")), Is.EqualTo(2));
|
||||
Assert.That(ActiveRoots().Count(root => root.name.Contains("Home Spawn")), Is.EqualTo(1));
|
||||
Assert.That(ActiveRoots().Count(root => root.name.Contains("Tower Pad Spawn")), Is.EqualTo(1));
|
||||
Assert.That(ActiveRoots().Single(root => root.name.Contains("Home Spawn")).transform.childCount,
|
||||
Is.EqualTo(2), "Home spawn combines its authored ground ring and vertical strip.");
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ActualLongRunSetupPortalOpenTowerUpgradeAndVictoryDriveFacilityEffects()
|
||||
{
|
||||
Time.timeScale = 0f;
|
||||
InitializeIntegratedLongRun();
|
||||
Defense25DFacilityVisuals integratedVisuals = integratedWorld.GetComponent<Defense25DFacilityVisuals>();
|
||||
Assert.That(integratedVisuals, Is.Not.Null);
|
||||
Assert.That(integratedWorld.RunPhase, Is.EqualTo(Defense25DRunPhase.Preparing));
|
||||
Assert.That(ActiveRoots(integratedVisuals).Count(root => root.name.Contains("Home Spawn")), Is.EqualTo(1));
|
||||
Assert.That(ActiveRoots(integratedVisuals).Count(root => root.name.Contains("Tower Pad Spawn")), Is.EqualTo(18));
|
||||
|
||||
integratedWorld.TowerManager.AddCurrency(10000);
|
||||
Vector2 pad = integratedWorld.TowerManager.GetPadPosition(0);
|
||||
integratedWorld.Player.ResetAt(pad);
|
||||
Assert.That(integratedWorld.TowerManager.TryBuildTowerAtPad(0, DefenseTowerKind.Direct), Is.True);
|
||||
Assert.That(integratedWorld.TowerManager.TryUpgradeTowerAtPad(0, DefenseTowerBranch.None), Is.True);
|
||||
Assert.That(ActiveRoots(integratedVisuals).Any(root => root.name.Contains("Tower Upgrade Pad 0 T2")), Is.True,
|
||||
"The real tower manager success path must call the bound facility visual owner.");
|
||||
|
||||
integratedWorld.AdvanceRunClock(runDefinition.PreparationSeconds);
|
||||
Assert.That(integratedWorld.RunPhase, Is.EqualTo(Defense25DRunPhase.Running),
|
||||
"The 0.8 second setup effects cannot extend preparation.");
|
||||
Assert.That(integratedWorld.WaveDirector.IsPortalOpen(0), Is.True);
|
||||
Assert.That(integratedWorld.WaveDirector.IsPortalOpen(1), Is.True);
|
||||
Assert.That(ActiveRoots(integratedVisuals).Count(root => root.name.Contains("Portal Spawn")), Is.EqualTo(2),
|
||||
"The actual portal-open timeline must start each opening effect once.");
|
||||
|
||||
integratedWorld.WaveDirector.AdvanceTo(18f, integratedWorld.SpawnScheduledEnemy);
|
||||
Assert.That(integratedWorld.Enemies, Is.Not.Empty,
|
||||
"The first real wave composition must spawn while the opening effects are still paused.");
|
||||
Assert.That(integratedWorld.NotifyLongRunBossActivated(), Is.True);
|
||||
Assert.That(integratedWorld.BeginLongRunClearing(), Is.True);
|
||||
foreach (Defense25DEnemy enemy in integratedWorld.Enemies.ToArray())
|
||||
integratedWorld.ResolveEnemy(enemy, DefenseEnemyResolution.Death);
|
||||
|
||||
for (int choice = 0; integratedWorld.Progression.HasPendingReward && choice < 32; choice++)
|
||||
{
|
||||
string rewardToken = integratedWorld.Progression.CurrentRewardToken;
|
||||
Assert.That(integratedWorld.Progression.TryChooseReward(0, rewardToken), Is.True,
|
||||
$"The integrated victory path must resolve pending reward {rewardToken} through its public choice API.");
|
||||
}
|
||||
Assert.That(integratedWorld.Progression.HasPendingReward, Is.False,
|
||||
"All queued level-up choices must be completed before the victory gate can close.");
|
||||
|
||||
string pendingMissions = string.Join(",", integratedWorld.PersonalMissionStates
|
||||
.Where(state => state.Status == Defense25DPersonalMissionStatus.RewardPending)
|
||||
.Select(state => $"{state.MissionId}:{state.MissionInstanceId}"));
|
||||
Assert.That(integratedWorld.RunCleared, Is.True,
|
||||
$"Victory gate state: phase={integratedWorld.RunPhase}, remaining={integratedWorld.RemainingEnemies}, " +
|
||||
$"supplied={integratedWorld.Ledger.SuppliedCount}, resolved={integratedWorld.Ledger.ResolvedCount}, " +
|
||||
$"level={integratedWorld.Progression.Level}, xp={integratedWorld.Progression.CurrentExperience}/" +
|
||||
$"{integratedWorld.Progression.RequiredExperience}, levelChoice={integratedWorld.Progression.HasPendingLevelChoice}, " +
|
||||
$"rewardCount={integratedWorld.Progression.RewardChoices.PendingRewardCount}, " +
|
||||
$"fieldRelic={integratedWorld.HasPendingFieldRelicReward}, dropChoice={integratedWorld.HasPendingArtifactDropChoice}, " +
|
||||
$"missionRewards={pendingMissions}.");
|
||||
Assert.That(integratedWorld.RunPhase, Is.EqualTo(Defense25DRunPhase.Victory));
|
||||
Assert.That(ActiveRoots(integratedVisuals).Count(root => root.name.Contains("Portal Burst")), Is.EqualTo(2));
|
||||
Assert.That(ActiveRoots(integratedVisuals).Any(root => root.name.Contains("Home Burst")), Is.False,
|
||||
"Victory bursts open portals but does not destroy the home.");
|
||||
yield return new WaitForSecondsRealtime(.7f);
|
||||
Assert.That(ActiveRoots(integratedVisuals).Any(root => root.name.Contains("Portal Burst")), Is.False,
|
||||
"Victory portal bursts complete on the unscaled terminal clock.");
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ActualBaseArrivalDefeatStartsHomeAndOpenPortalBurstsImmediately()
|
||||
{
|
||||
Time.timeScale = 0f;
|
||||
InitializeIntegratedLongRun();
|
||||
integratedWorld.AdvanceRunClock(runDefinition.PreparationSeconds);
|
||||
integratedWorld.WaveDirector.AdvanceTo(18f, integratedWorld.SpawnScheduledEnemy);
|
||||
Assert.That(integratedWorld.Enemies, Is.Not.Empty);
|
||||
|
||||
float arrivalDamage = GameplayConstants.Current.Defense.ArrivalBaseDamage;
|
||||
FieldInfo baseHealth = typeof(Defense25DComparison).GetField(
|
||||
"<BaseHealth>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.That(baseHealth, Is.Not.Null, "The fixture sets a boundary HP to exercise one actual arrival terminal transition.");
|
||||
baseHealth.SetValue(integratedWorld, arrivalDamage);
|
||||
integratedWorld.ResolveEnemy(integratedWorld.Enemies[0], DefenseEnemyResolution.Arrival);
|
||||
|
||||
Defense25DFacilityVisuals integratedVisuals = integratedWorld.GetComponent<Defense25DFacilityVisuals>();
|
||||
Assert.That(integratedWorld.RunFailed, Is.True);
|
||||
Assert.That(integratedWorld.RunPhase, Is.EqualTo(Defense25DRunPhase.Defeat));
|
||||
Assert.That(integratedWorld.BaseHealth, Is.Zero);
|
||||
Assert.That(ActiveRoots(integratedVisuals).Count(root => root.name.Contains("Home Burst")), Is.EqualTo(1));
|
||||
Assert.That(ActiveRoots(integratedVisuals).Count(root => root.name.Contains("Portal Burst")), Is.EqualTo(2));
|
||||
|
||||
yield return new WaitForSecondsRealtime(.9f);
|
||||
Assert.That(ActiveRoots(integratedVisuals).Any(root => root.name.Contains("Home Burst")), Is.False,
|
||||
"Defeat is immediate while the home burst finishes on its unscaled display clock.");
|
||||
Assert.That(ActiveRoots(integratedVisuals).Any(root => root.name.Contains("Portal Burst")), Is.False);
|
||||
}
|
||||
|
||||
private (DefenseSpriteSequence Sequence, SpriteRenderer Renderer) CreateSequence(string name)
|
||||
{
|
||||
var gameObject = new GameObject(name);
|
||||
SpriteRenderer renderer = gameObject.AddComponent<SpriteRenderer>();
|
||||
DefenseSpriteSequence sequence = gameObject.AddComponent<DefenseSpriteSequence>();
|
||||
return (sequence, renderer);
|
||||
}
|
||||
|
||||
private int ActiveRootCount() => ActiveRoots().Length;
|
||||
|
||||
private GameObject[] ActiveRoots() => ActiveRoots(visuals);
|
||||
|
||||
private static GameObject[] ActiveRoots(Defense25DFacilityVisuals owner) => owner.transform.Cast<Transform>()
|
||||
.Where(child => child.gameObject.activeInHierarchy)
|
||||
.Select(child => child.gameObject)
|
||||
.ToArray();
|
||||
|
||||
private void InitializeIntegratedLongRun()
|
||||
{
|
||||
runDefinition = Object.Instantiate(AssetDatabase.LoadAssetAtPath<Defense25DRunDefinition>(
|
||||
"Assets/_Project/Constants/Defense25DComparison/LongDefenseRun.asset"));
|
||||
Assert.That(runDefinition, Is.Not.Null);
|
||||
integratedWorld = new GameObject("Facility integrated long run under test")
|
||||
.AddComponent<Defense25DComparison>();
|
||||
integratedWorld.InitializeLongRun(comparisonSettings, runDefinition, 23);
|
||||
integratedWorld.AutoSpawn = false;
|
||||
integratedWorld.ReadInput = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b6bdf7dd5a8c4116a41c87c2292294b5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7ad63a7a2b654bca8fd97abc2a252e0c
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Defense.Comparison;
|
||||
using BumpCombat.Enemies;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace BumpCombat.Tests
|
||||
{
|
||||
public sealed class Defense25DMonsterCombatAdapterPlayModeTests
|
||||
{
|
||||
private const string SettingsPath = "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset";
|
||||
private const string PoolPath = "Assets/_Project/Constants/Defense25DComparison/MonsterPools.asset";
|
||||
private Defense25DComparison world;
|
||||
private Defense25DComparisonSettings settings;
|
||||
private readonly List<Object> temporaryObjects = new();
|
||||
private float previousTimeScale;
|
||||
|
||||
[UnitySetUp]
|
||||
public IEnumerator SetUp()
|
||||
{
|
||||
previousTimeScale = Time.timeScale;
|
||||
Time.timeScale = 1f;
|
||||
settings = Object.Instantiate(AssetDatabase.LoadAssetAtPath<Defense25DComparisonSettings>(SettingsPath));
|
||||
Assert.That(settings, Is.Not.Null);
|
||||
world = new GameObject("Monster combat adapter under test").AddComponent<Defense25DComparison>();
|
||||
world.Initialize(settings);
|
||||
world.AutoSpawn = false;
|
||||
world.ReadInput = false;
|
||||
world.SetPaused(false);
|
||||
Time.timeScale = 0f;
|
||||
SetGameTime(0f);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTearDown]
|
||||
public IEnumerator TearDown()
|
||||
{
|
||||
for (int i = temporaryObjects.Count - 1; i >= 0; i--)
|
||||
if (temporaryObjects[i] != null) Object.Destroy(temporaryObjects[i]);
|
||||
temporaryObjects.Clear();
|
||||
if (world != null) Object.Destroy(world.gameObject);
|
||||
if (settings != null) Object.Destroy(settings);
|
||||
Time.timeScale = previousTimeScale;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ContactAttackWarnsBeforeApplyingOneRealActorDamageEvent()
|
||||
{
|
||||
Defense25DMonsterPoolEntry entry = FindEntry("Slime");
|
||||
Assert.That(entry.CombatProfile.Archetype, Is.EqualTo(Defense25DMonsterAttackArchetype.Contact));
|
||||
Defense25DEnemy enemy = world.Enemies[0];
|
||||
enemy.Spawn(Vector2.zero);
|
||||
world.Player.ResetAt(enemy.Actor.Center + Vector2.right * .05f);
|
||||
Physics.SyncTransforms();
|
||||
|
||||
Sprite frame = CreateFrame();
|
||||
var motion = CreateMotion(entry, frame);
|
||||
var warningEvents = new List<Defense25DMonsterCombatWarning>();
|
||||
var adapter = new Defense25DMonsterCombatAdapter();
|
||||
Assert.That(adapter.Bind(entry, enemy.Actor, motion, () => world.Player, out string error,
|
||||
setWarning: warningEvents.Add), Is.True, error);
|
||||
|
||||
Assert.That(adapter.Step(.001f, moving: false), Is.True);
|
||||
Assert.That(warningEvents.Last().Visible, Is.True);
|
||||
Assert.That(world.Player.DamageEvents, Is.Zero, "Starting a telegraph cannot apply damage.");
|
||||
|
||||
adapter.Step(entry.CombatProfile.WarningSeconds - .002f, moving: false);
|
||||
Assert.That(world.Player.DamageEvents, Is.Zero, "The configured warning duration must remain safe.");
|
||||
|
||||
adapter.Step(.003f, moving: false);
|
||||
Assert.That(warningEvents.Last().Visible, Is.False);
|
||||
Assert.That(world.Player.DamageEvents, Is.EqualTo(1));
|
||||
float damagedHealth = world.Player.CurrentHealth;
|
||||
Assert.That(damagedHealth, Is.LessThan(world.Player.MaxHealth));
|
||||
adapter.Step(.02f, moving: false);
|
||||
Assert.That(world.Player.DamageEvents, Is.EqualTo(1), "One contact attack can damage only once.");
|
||||
Assert.That(world.Player.CurrentHealth, Is.EqualTo(damagedHealth),
|
||||
"The later attack frame cannot reapply its contact damage.");
|
||||
|
||||
adapter.Cancel();
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ContactAttackAppliesTheAttackerAttackDamageModifiers()
|
||||
{
|
||||
Defense25DMonsterPoolEntry entry = FindEntry("Slime");
|
||||
Defense25DEnemy enemy = world.Enemies[0];
|
||||
enemy.Spawn(Vector2.zero);
|
||||
Assert.That(enemy.Actor.AddModifier(new StatModifier("test-attack-flat",
|
||||
CharacterStat.AttackDamage, ModifierOperation.Flat, 7f)), Is.True);
|
||||
float expectedDamage = enemy.Actor.CalculateAttackDamage(
|
||||
entry.CombatProfile.AttackDamage * entry.Definition.GetAttackPattern(0).DamageMultiplier);
|
||||
|
||||
world.Player.ResetAt(enemy.Actor.Center + Vector2.right * .05f);
|
||||
Physics.SyncTransforms();
|
||||
var adapter = new Defense25DMonsterCombatAdapter();
|
||||
Assert.That(adapter.Bind(entry, enemy.Actor, CreateMotion(entry, CreateFrame()),
|
||||
() => world.Player, out string error), Is.True, error);
|
||||
|
||||
adapter.Step(.001f, moving: false);
|
||||
adapter.Step(entry.CombatProfile.WarningSeconds + .002f, moving: false);
|
||||
|
||||
Assert.That(world.Player.DamageEvents, Is.EqualTo(1));
|
||||
Assert.That(world.Player.MaxHealth - world.Player.CurrentHealth,
|
||||
Is.EqualTo(expectedDamage).Within(.01f));
|
||||
adapter.Cancel();
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ProjectileAttackOnlyDamagesAfterItsTravelSegmentReachesThePlayer()
|
||||
{
|
||||
Defense25DMonsterPoolEntry entry = FindEntry("SkeletonArcher");
|
||||
Defense25DMonsterCombatProfile profile = entry.CombatProfile;
|
||||
Assert.That(profile.Archetype, Is.EqualTo(Defense25DMonsterAttackArchetype.Projectile));
|
||||
Assert.That(profile.ProjectileSpeed, Is.GreaterThan(0f));
|
||||
Defense25DEnemy enemy = world.Enemies[0];
|
||||
enemy.Spawn(Vector2.zero);
|
||||
float targetDistance = Mathf.Min(profile.AttackRange * .75f, 2f);
|
||||
world.Player.ResetAt(enemy.Actor.Center + Vector2.right * targetDistance);
|
||||
Physics.SyncTransforms();
|
||||
|
||||
Sprite frame = CreateFrame();
|
||||
var motion = CreateMotion(entry, frame);
|
||||
var projectileEvents = new List<Defense25DMonsterProjectileVisual>();
|
||||
var adapter = new Defense25DMonsterCombatAdapter();
|
||||
Assert.That(adapter.Bind(entry, enemy.Actor, motion, () => world.Player, out string error,
|
||||
setProjectile: projectileEvents.Add), Is.True, error);
|
||||
|
||||
adapter.Step(.001f, moving: false);
|
||||
adapter.Step(profile.WarningSeconds + .001f, moving: false);
|
||||
Assert.That(projectileEvents.Any(projectile => projectile.Active), Is.True,
|
||||
"The authored projectile starts at the end of the warning.");
|
||||
Assert.That(world.Player.DamageEvents, Is.Zero, "Firing is not an immediate hit.");
|
||||
|
||||
adapter.Step(.02f, moving: false);
|
||||
Assert.That(world.Player.DamageEvents, Is.Zero,
|
||||
"The projectile must not damage before its swept path reaches the target.");
|
||||
|
||||
adapter.Step(1f, moving: false);
|
||||
Assert.That(world.Player.DamageEvents, Is.EqualTo(1));
|
||||
Assert.That(projectileEvents.Last().Active, Is.False);
|
||||
adapter.Cancel();
|
||||
yield return null;
|
||||
}
|
||||
|
||||
private Defense25DMonsterPoolEntry FindEntry(string id)
|
||||
{
|
||||
Defense25DMonsterPoolDefinition pool = AssetDatabase.LoadAssetAtPath<Defense25DMonsterPoolDefinition>(PoolPath);
|
||||
Assert.That(pool, Is.Not.Null);
|
||||
Assert.That(pool.TryValidate(out string error), Is.True, error);
|
||||
return pool.Tiers.SelectMany(tier => tier.Entries).Single(entry => entry.EnemyDefinitionId == id);
|
||||
}
|
||||
|
||||
private Defense25DMonsterSourceMotion CreateMotion(Defense25DMonsterPoolEntry entry, Sprite frame)
|
||||
{
|
||||
return new Defense25DMonsterSourceMotion(
|
||||
entry.SourceCharacterId,
|
||||
new[] { frame },
|
||||
new[] { frame },
|
||||
new Dictionary<string, Sprite[]> { [entry.CombatProfile.AttackAction] = new[] { frame } },
|
||||
entry.CombatProfile.IsFlying ? new[] { frame } : null,
|
||||
.1f,
|
||||
new[] { frame });
|
||||
}
|
||||
|
||||
private Sprite CreateFrame()
|
||||
{
|
||||
Texture2D texture = new Texture2D(1, 1);
|
||||
temporaryObjects.Add(texture);
|
||||
Sprite frame = Sprite.Create(texture, new Rect(0f, 0f, 1f, 1f), new Vector2(.5f, .5f));
|
||||
temporaryObjects.Add(frame);
|
||||
return frame;
|
||||
}
|
||||
|
||||
private void SetGameTime(float value)
|
||||
{
|
||||
typeof(Defense25DComparison).GetProperty(nameof(Defense25DComparison.GameTime))
|
||||
.GetSetMethod(true).Invoke(world, new object[] { value });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6cd917352a47481cb3b2ba6004dcbd65
|
||||
@@ -0,0 +1,504 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 88443d55797541cd826090fb3567d776
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using BumpCombat.Defense;
|
||||
using BumpCombat.Defense.Comparison;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace BumpCombat.PlayModeTests
|
||||
{
|
||||
public sealed class Defense25DPersonalMissionPlayModeTests
|
||||
{
|
||||
private readonly List<int> openPortalIds = new();
|
||||
private readonly List<Defense25DPersonalMissionSpawnRequest> spawnedRequests = new();
|
||||
private readonly List<string> queuedRewardTokens = new();
|
||||
private Defense25DPersonalMissions missions;
|
||||
private bool runCanAcceptMissions;
|
||||
private bool allowRewardQueue;
|
||||
private int nextSpawnId;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
openPortalIds.Clear();
|
||||
spawnedRequests.Clear();
|
||||
queuedRewardTokens.Clear();
|
||||
runCanAcceptMissions = true;
|
||||
allowRewardQueue = true;
|
||||
nextSpawnId = 100;
|
||||
missions = new Defense25DPersonalMissions();
|
||||
missions.Bind(
|
||||
new[] { 3, 6, 3 },
|
||||
() => runCanAcceptMissions,
|
||||
() => openPortalIds,
|
||||
SpawnTargets,
|
||||
QueueReward);
|
||||
missions.Reset(7, 12345);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void StartRequiresEnoughOpenPortalsAndRejectsConcurrentMission()
|
||||
{
|
||||
openPortalIds.AddRange(new[] { 2, 0, 2 });
|
||||
|
||||
Assert.That(missions.CanStart(1, out string reason), Is.False);
|
||||
Assert.That(reason, Does.Contain("3"));
|
||||
Assert.That(missions.TryStart(1), Is.False);
|
||||
Assert.That(spawnedRequests, Is.Empty);
|
||||
|
||||
openPortalIds.AddRange(new[] { 1, 3 });
|
||||
Assert.That(missions.TryStart(1), Is.True);
|
||||
Assert.That(spawnedRequests, Has.Count.EqualTo(3));
|
||||
Assert.That(spawnedRequests.Select(request => request.PortalId).Distinct().Count(), Is.EqualTo(3));
|
||||
Assert.That(spawnedRequests.All(request =>
|
||||
request.RunGeneration == 7
|
||||
&& request.MissionId == 1
|
||||
&& request.MissionInstanceId == 1
|
||||
&& request.Source == Defense25DSpawnSource.PersonalMission), Is.True);
|
||||
Assert.That(missions.TryStart(2), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MissionTargetsAreThreeSixAndThreeAndOnlyDeathsQueueOneReward()
|
||||
{
|
||||
for (int missionId = 1; missionId <= 3; missionId++)
|
||||
{
|
||||
openPortalIds.Clear();
|
||||
for (int portalId = 0; portalId < 6; portalId++)
|
||||
openPortalIds.Add(portalId);
|
||||
|
||||
int firstSpawnIndex = spawnedRequests.Count;
|
||||
Assert.That(missions.TryStart(missionId), Is.True);
|
||||
int targetCount = new[] { 3, 6, 3 }[missionId - 1];
|
||||
IReadOnlyList<int> targetIds = missions.States[missionId - 1].TargetSpawnIds;
|
||||
Assert.That(targetIds, Has.Count.EqualTo(targetCount));
|
||||
|
||||
for (int i = 0; i < targetIds.Count - 1; i++)
|
||||
{
|
||||
Assert.That(missions.ResolveTarget(7, targetIds[i], Defense25DSpawnSource.PersonalMission,
|
||||
missionId, missions.States[missionId - 1].MissionInstanceId, DefenseEnemyResolution.Death), Is.True);
|
||||
Assert.That(missions.States[missionId - 1].Status, Is.EqualTo(Defense25DPersonalMissionStatus.Active));
|
||||
}
|
||||
|
||||
Assert.That(missions.ResolveTarget(7, targetIds[targetIds.Count - 1],
|
||||
Defense25DSpawnSource.PersonalMission, missionId,
|
||||
missions.States[missionId - 1].MissionInstanceId, DefenseEnemyResolution.Death), Is.True);
|
||||
Assert.That(missions.States[missionId - 1].Status, Is.EqualTo(Defense25DPersonalMissionStatus.RewardPending));
|
||||
Assert.That(missions.ResolveTarget(7, targetIds[0], DefenseEnemyResolution.Death), Is.False);
|
||||
Assert.That(queuedRewardTokens, Has.Count.EqualTo(missionId));
|
||||
Assert.That(queuedRewardTokens[missionId - 1], Is.EqualTo($"personal-mission:7:{missionId}"));
|
||||
if (missionId < 3)
|
||||
Assert.That(missions.TryStart(missionId + 1), Is.False);
|
||||
Assert.That(missions.TryCompleteReward(7, missions.States[missionId - 1].MissionInstanceId), Is.True);
|
||||
Assert.That(missions.States[missionId - 1].Status, Is.EqualTo(Defense25DPersonalMissionStatus.Completed));
|
||||
Assert.That(missions.TryCompleteReward(7, missions.States[missionId - 1].MissionInstanceId), Is.False);
|
||||
Assert.That(spawnedRequests.Skip(firstSpawnIndex).Take(targetCount)
|
||||
.Select(request => request.PortalId).Distinct().Count(), Is.EqualTo(targetCount));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RewardQueueFailureStaysPendingUntilQueuedAndChoiceIsConfirmed()
|
||||
{
|
||||
openPortalIds.AddRange(Enumerable.Range(0, 3));
|
||||
Assert.That(missions.TryStart(1), Is.True);
|
||||
Defense25DPersonalMissionState mission = missions.States[0];
|
||||
allowRewardQueue = false;
|
||||
|
||||
foreach (int spawnId in mission.TargetSpawnIds)
|
||||
Assert.That(missions.ResolveTarget(7, spawnId, DefenseEnemyResolution.Death), Is.True);
|
||||
|
||||
Assert.That(mission.Status, Is.EqualTo(Defense25DPersonalMissionStatus.RewardPending));
|
||||
Assert.That(mission.RewardQueued, Is.False);
|
||||
Assert.That(queuedRewardTokens, Is.Empty);
|
||||
Assert.That(missions.TryCompleteReward(7, mission.MissionInstanceId), Is.False);
|
||||
|
||||
allowRewardQueue = true;
|
||||
Assert.That(missions.TryQueuePendingReward(7, mission.MissionInstanceId), Is.True);
|
||||
Assert.That(queuedRewardTokens, Has.Count.EqualTo(1));
|
||||
Assert.That(missions.TryQueuePendingReward(7, mission.MissionInstanceId), Is.False);
|
||||
Assert.That(mission.Status, Is.EqualTo(Defense25DPersonalMissionStatus.RewardPending));
|
||||
Assert.That(missions.TryStart(2), Is.False);
|
||||
Assert.That(missions.TryCompleteReward(7, mission.MissionInstanceId), Is.True);
|
||||
Assert.That(mission.Status, Is.EqualTo(Defense25DPersonalMissionStatus.Completed));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ArrivalFailsImmediatelyAndDoesNotResolveRemainingEnemiesOrReward()
|
||||
{
|
||||
openPortalIds.AddRange(Enumerable.Range(0, 3));
|
||||
Assert.That(missions.TryStart(1), Is.True);
|
||||
IReadOnlyList<int> targets = missions.States[0].TargetSpawnIds;
|
||||
|
||||
Assert.That(missions.ResolveTarget(7, targets[0], DefenseEnemyResolution.Arrival), Is.True);
|
||||
Assert.That(missions.States[0].Status, Is.EqualTo(Defense25DPersonalMissionStatus.Failed));
|
||||
Assert.That(missions.States[0].RemainingTargetCount, Is.EqualTo(2));
|
||||
Assert.That(missions.ResolveTarget(7, targets[1], DefenseEnemyResolution.Death), Is.True);
|
||||
Assert.That(missions.States[0].RemainingTargetCount, Is.EqualTo(1));
|
||||
Assert.That(queuedRewardTokens, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void UnownedGlobalEliteAndBossSummonIdsCannotCountAsMissionTargets()
|
||||
{
|
||||
openPortalIds.AddRange(Enumerable.Range(0, 3));
|
||||
Assert.That(missions.TryStart(1), Is.True);
|
||||
Defense25DPersonalMissionState mission = missions.States[0];
|
||||
int globalEliteId = mission.TargetSpawnIds[0];
|
||||
int bossSummonId = mission.TargetSpawnIds[1];
|
||||
|
||||
Assert.That(missions.ResolveTarget(7, globalEliteId, Defense25DSpawnSource.GlobalElite,
|
||||
1, mission.MissionInstanceId, DefenseEnemyResolution.Death), Is.False);
|
||||
Assert.That(missions.ResolveTarget(7, bossSummonId, Defense25DSpawnSource.BossSummon,
|
||||
1, mission.MissionInstanceId, DefenseEnemyResolution.Death), Is.False);
|
||||
Assert.That(missions.States[0].RemainingTargetCount, Is.EqualTo(3));
|
||||
Assert.That(queuedRewardTokens, Is.Empty);
|
||||
Assert.That(spawnedRequests.All(request => request.Source == Defense25DSpawnSource.PersonalMission), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ResetClearsMissionOwnershipAndRejectsStaleGenerationCallbacks()
|
||||
{
|
||||
openPortalIds.AddRange(Enumerable.Range(0, 3));
|
||||
Assert.That(missions.TryStart(1), Is.True);
|
||||
int staleSpawnId = missions.States[0].TargetSpawnIds[0];
|
||||
|
||||
missions.Reset(8, 54321);
|
||||
|
||||
Assert.That(missions.Generation, Is.EqualTo(8));
|
||||
Assert.That(missions.States.All(state => state.Status == Defense25DPersonalMissionStatus.Available), Is.True);
|
||||
Assert.That(missions.ResolveTarget(7, staleSpawnId, DefenseEnemyResolution.Death), Is.False);
|
||||
Assert.That(missions.TryCompleteReward(7, 1), Is.False);
|
||||
Assert.That(queuedRewardTokens, Is.Empty);
|
||||
|
||||
Assert.That(missions.TryStart(1), Is.True);
|
||||
Assert.That(missions.States[0].MissionInstanceId, Is.EqualTo(1));
|
||||
Assert.That(missions.States[0].TargetSpawnIds[0], Is.Not.EqualTo(staleSpawnId));
|
||||
}
|
||||
|
||||
private bool SpawnTargets(
|
||||
IReadOnlyList<Defense25DPersonalMissionSpawnRequest> requests,
|
||||
out IReadOnlyList<int> spawnIds)
|
||||
{
|
||||
var ids = new List<int>(requests.Count);
|
||||
foreach (Defense25DPersonalMissionSpawnRequest request in requests)
|
||||
{
|
||||
spawnedRequests.Add(request);
|
||||
ids.Add(nextSpawnId++);
|
||||
}
|
||||
|
||||
spawnIds = ids;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool QueueReward(string token)
|
||||
{
|
||||
if (!allowRewardQueue)
|
||||
return false;
|
||||
|
||||
queuedRewardTokens.Add(token);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f14c2a813b0e4d709c1f6a85e2b734d9
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Collections.Generic;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Defense;
|
||||
using BumpCombat.Defense.Comparison;
|
||||
using BumpCombat.Progression;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.PlayModeTests
|
||||
{
|
||||
public sealed class Defense25DProgressionPlayModeTests
|
||||
{
|
||||
[Test]
|
||||
public void KillExperienceUsesActualActorModifiersAndQueuesOneLevelChoice()
|
||||
{
|
||||
GameObject actorObject = new("Progression Actor");
|
||||
Defense25DActor actor = actorObject.AddComponent<Defense25DActor>();
|
||||
ModifierDefinition experienceModifier = Modifier(
|
||||
"experience-gain", CharacterStat.ExperienceGain, 0.5f, 1);
|
||||
ModifierDefinition rewardModifier = Modifier(
|
||||
"move-speed", CharacterStat.MoveSpeed, 0.2f, 2);
|
||||
Defense25DRewardConstants rewardConstants =
|
||||
ScriptableObject.CreateInstance<Defense25DRewardConstants>();
|
||||
Defense25DTowerModifiers towerModifiers = new(rewardConstants);
|
||||
int oldRequiredExperience = GameplayConstants.Current.LevelUp.BaseExperienceToNextLevel;
|
||||
try
|
||||
{
|
||||
GameplayConstants.Current.LevelUp.BaseExperienceToNextLevel = 5;
|
||||
Assert.That(experienceModifier.TryApplyToModel(actor, null), Is.True);
|
||||
|
||||
Defense25DProgression progression = new();
|
||||
List<string> requestedTokens = new();
|
||||
progression.ChoiceRequested += (_, token) => requestedTokens.Add(token);
|
||||
progression.Bind(
|
||||
actor,
|
||||
rewardConstants,
|
||||
new[] { rewardModifier },
|
||||
towerModifiers,
|
||||
_ => false);
|
||||
|
||||
progression.GrantKillExperience(3);
|
||||
Assert.That(progression.CurrentExperience, Is.EqualTo(4));
|
||||
Assert.That(progression.Level, Is.EqualTo(1));
|
||||
Assert.That(progression.HasPendingLevelChoice, Is.False);
|
||||
|
||||
progression.GrantKillExperience(1);
|
||||
Assert.That(progression.Level, Is.EqualTo(2));
|
||||
Assert.That(progression.CurrentExperience, Is.EqualTo(1));
|
||||
Assert.That(progression.HasPendingLevelChoice, Is.True);
|
||||
Assert.That(requestedTokens, Is.EqualTo(new[] { "run:1:level:2" }));
|
||||
Assert.That(progression.CurrentChoices, Has.Count.EqualTo(1));
|
||||
Assert.That(progression.CurrentChoices[0].TargetKind,
|
||||
Is.EqualTo(Defense25DRewardTargetKind.PlayerModifier));
|
||||
|
||||
Assert.That(progression.TryChooseModifier(0), Is.True);
|
||||
Assert.That(actor.GetStackCount(
|
||||
rewardModifier.ModifierId,
|
||||
CharacterStat.MoveSpeed), Is.EqualTo(1));
|
||||
Assert.That(progression.HasPendingLevelChoice, Is.False);
|
||||
|
||||
Defense25DTowerModifierDefinition fireAttack = System.Array.Find(
|
||||
rewardConstants.TowerModifierCatalog,
|
||||
definition => definition.DefinitionId == "fire.attack.common");
|
||||
Assert.That(fireAttack, Is.Not.Null);
|
||||
Assert.That(towerModifiers.Apply(
|
||||
Defense25DRewardChoice.CreateTowerModifier(fireAttack)), Is.True);
|
||||
Assert.That(towerModifiers.AppliedChoiceCount, Is.EqualTo(1));
|
||||
|
||||
progression.ResetProgression();
|
||||
Assert.That(progression.Level, Is.EqualTo(1));
|
||||
Assert.That(progression.CurrentExperience, Is.Zero);
|
||||
Assert.That(progression.RequiredExperience, Is.EqualTo(5));
|
||||
Assert.That(progression.CurrentChoices, Is.Empty);
|
||||
Assert.That(towerModifiers.AppliedChoiceCount, Is.Zero,
|
||||
"Restart clears the run's tower modifier stacks as well as XP and choices.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
GameplayConstants.Current.LevelUp.BaseExperienceToNextLevel = oldRequiredExperience;
|
||||
Object.DestroyImmediate(experienceModifier);
|
||||
Object.DestroyImmediate(rewardModifier);
|
||||
Object.DestroyImmediate(rewardConstants);
|
||||
Object.DestroyImmediate(actorObject);
|
||||
}
|
||||
}
|
||||
|
||||
private static ModifierDefinition Modifier(
|
||||
string id,
|
||||
CharacterStat stat,
|
||||
float value,
|
||||
int maximumStacks)
|
||||
{
|
||||
ModifierDefinition definition =
|
||||
ScriptableObject.CreateInstance<ModifierDefinition>();
|
||||
definition.Configure(
|
||||
$"progression-test.{id}",
|
||||
id,
|
||||
stat,
|
||||
ModifierOperation.Increased,
|
||||
value,
|
||||
maximumStacks);
|
||||
return definition;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 362e60601a304ba0a9b7dc96ce106a36
|
||||
@@ -0,0 +1,842 @@
|
||||
using System.Linq;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Defense;
|
||||
using BumpCombat.Defense.Comparison;
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Progression;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.PlayModeTests
|
||||
{
|
||||
public sealed class Defense25DRewardChoicePlayModeTests
|
||||
{
|
||||
[Test]
|
||||
public void LevelUp_OffersMixedDistinctChoicesAndIgnoresDuplicateToken()
|
||||
{
|
||||
GameObject playerObject = new("Reward Choice Player");
|
||||
ModifierDefinition playerModifier = CreatePlayerModifier("move-speed");
|
||||
Defense25DRewardConstants constants = CreateConstants(includeEpic: true);
|
||||
try
|
||||
{
|
||||
PlayerStats player = playerObject.AddComponent<PlayerStats>();
|
||||
Defense25DTowerModifiers towerModifiers =
|
||||
new(constants);
|
||||
Defense25DRewardChoices choices = new(
|
||||
constants,
|
||||
new[] { playerModifier },
|
||||
player,
|
||||
towerModifiers,
|
||||
_ => true);
|
||||
|
||||
Assert.That(choices.QueueReward(
|
||||
Defense25DRewardSource.LevelUp, "level:2"), Is.True);
|
||||
Assert.That(choices.QueueReward(
|
||||
Defense25DRewardSource.LevelUp, "level:2"), Is.False);
|
||||
Assert.That(choices.CurrentChoices, Has.Count.EqualTo(3));
|
||||
Assert.That(choices.CurrentChoices.Any(choice =>
|
||||
choice.TargetKind == Defense25DRewardTargetKind.PlayerModifier),
|
||||
Is.True);
|
||||
Assert.That(choices.CurrentChoices.Any(choice =>
|
||||
choice.TargetKind == Defense25DRewardTargetKind.TowerModifier),
|
||||
Is.True);
|
||||
Assert.That(choices.CurrentChoices.Select(choice =>
|
||||
(choice.TargetKind, choice.DefinitionId, choice.Rarity))
|
||||
.Distinct().ToList(),
|
||||
Has.Count.EqualTo(choices.CurrentChoices.Count));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(playerModifier);
|
||||
Object.DestroyImmediate(constants);
|
||||
Object.DestroyImmediate(playerObject);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PlayerChoice_ShowsKoreanEffectAndRareValueThatMatchesAppliedModifier()
|
||||
{
|
||||
GameObject playerObject = new("Reward Display Player");
|
||||
ModifierDefinition playerModifier = CreatePlayerModifier("move-speed");
|
||||
Defense25DRewardConstants constants = CreateConstants(includeEpic: true);
|
||||
constants.ChoiceCount = 1;
|
||||
constants.LevelUpRarityWeights = new Defense25DRewardRarityWeights
|
||||
{
|
||||
Common = 0,
|
||||
Rare = 100,
|
||||
Epic = 0,
|
||||
};
|
||||
constants.TowerModifierCatalog = System.Array.Empty<Defense25DTowerModifierDefinition>();
|
||||
try
|
||||
{
|
||||
PlayerStats player = playerObject.AddComponent<PlayerStats>();
|
||||
Defense25DRewardChoices choices = new(
|
||||
constants,
|
||||
new[] { playerModifier },
|
||||
player,
|
||||
new Defense25DTowerModifiers(constants),
|
||||
_ => false);
|
||||
|
||||
Assert.That(choices.QueueReward(
|
||||
Defense25DRewardSource.LevelUp, "level:display"), Is.True);
|
||||
Defense25DRewardChoice choice = choices.CurrentChoices.Single();
|
||||
|
||||
Assert.That(choice.Rarity, Is.EqualTo(Defense25DRewardRarity.Rare));
|
||||
Assert.That(choice.DisplayData.Title, Is.EqualTo("이동 속도 강화"));
|
||||
Assert.That(choice.DisplayData.Description,
|
||||
Is.EqualTo("이동 속도 +30%\n선택하면 즉시 적용됩니다."));
|
||||
Assert.That(choices.TryChooseReward(0, "level:display"), Is.True);
|
||||
Assert.That(player.Evaluate(CharacterStat.MoveSpeed, 100f),
|
||||
Is.EqualTo(130f).Within(0.001f));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(playerModifier);
|
||||
Object.DestroyImmediate(constants);
|
||||
Object.DestroyImmediate(playerObject);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RefreshChoices_PreservesSnapshotAndReplacesOnlyAnIneligibleEntry()
|
||||
{
|
||||
GameObject playerObject = new("Refresh Snapshot Player");
|
||||
ModifierDefinition[] definitions = Enumerable.Range(0, 4)
|
||||
.Select(index => CreatePlayerModifier($"snapshot-{index}"))
|
||||
.ToArray();
|
||||
Defense25DRewardConstants constants = CreateConstants(includeEpic: true);
|
||||
constants.TowerModifierCatalog = System.Array.Empty<Defense25DTowerModifierDefinition>();
|
||||
constants.LevelUpRarityWeights = new Defense25DRewardRarityWeights
|
||||
{
|
||||
Common = 100,
|
||||
Rare = 0,
|
||||
Epic = 0,
|
||||
};
|
||||
try
|
||||
{
|
||||
PlayerStats player = playerObject.AddComponent<PlayerStats>();
|
||||
Defense25DRewardChoices choices = new(
|
||||
constants,
|
||||
definitions,
|
||||
player,
|
||||
new Defense25DTowerModifiers(constants),
|
||||
_ => false);
|
||||
choices.QueueReward(Defense25DRewardSource.LevelUp, "level:snapshot");
|
||||
Defense25DRewardChoice[] snapshot = choices.CurrentChoices.ToArray();
|
||||
Assert.That(snapshot, Has.Length.EqualTo(3));
|
||||
|
||||
constants.LevelUpRarityWeights = new Defense25DRewardRarityWeights
|
||||
{
|
||||
Common = 0,
|
||||
Rare = 0,
|
||||
Epic = 100,
|
||||
};
|
||||
choices.RefreshChoices();
|
||||
for (int index = 0; index < snapshot.Length; index++)
|
||||
{
|
||||
Assert.That(choices.CurrentChoices[index], Is.SameAs(snapshot[index]),
|
||||
"A UI rebind retains each eligible token candidate and its order.");
|
||||
}
|
||||
|
||||
ModifierDefinition invalidated = definitions.Single(definition =>
|
||||
definition.ModifierId == snapshot[0].DefinitionId);
|
||||
Assert.That(invalidated.TryApplyToModel(player, null), Is.True);
|
||||
Assert.That(invalidated.TryApplyToModel(player, null), Is.True);
|
||||
choices.RefreshChoices();
|
||||
|
||||
Assert.That(choices.CurrentChoices, Has.Count.EqualTo(3));
|
||||
Assert.That(choices.CurrentChoices[0], Is.Not.SameAs(snapshot[0]));
|
||||
Assert.That(choices.CurrentChoices[0].DefinitionId,
|
||||
Is.Not.EqualTo(snapshot[0].DefinitionId));
|
||||
Assert.That(choices.CurrentChoices[1], Is.SameAs(snapshot[1]));
|
||||
Assert.That(choices.CurrentChoices[2], Is.SameAs(snapshot[2]));
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (ModifierDefinition definition in definitions)
|
||||
Object.DestroyImmediate(definition);
|
||||
Object.DestroyImmediate(constants);
|
||||
Object.DestroyImmediate(playerObject);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EmptyRarityFallbacksOnlyMoveDownward()
|
||||
{
|
||||
Defense25DRewardConstants constants = CreateConstants(includeEpic: true);
|
||||
constants.TowerModifierCatalog = new[]
|
||||
{
|
||||
TowerModifier("only-rare", DefenseTowerKind.Direct,
|
||||
Defense25DTowerModifierEffect.AttackDamageIncreased,
|
||||
Defense25DRewardRarity.Rare, 0.2f, 5),
|
||||
};
|
||||
try
|
||||
{
|
||||
constants.LevelUpRarityWeights = new Defense25DRewardRarityWeights
|
||||
{
|
||||
Common = 100,
|
||||
Rare = 0,
|
||||
Epic = 0,
|
||||
};
|
||||
Defense25DRewardChoices commonWithoutCommon = NewTowerOnlyChoices(constants);
|
||||
commonWithoutCommon.QueueReward(
|
||||
Defense25DRewardSource.LevelUp, "level:common-no-upgrade");
|
||||
Assert.That(commonWithoutCommon.CurrentChoices, Is.Empty,
|
||||
"A Common request does not upgrade to a populated Rare tier.");
|
||||
|
||||
constants.TowerModifierCatalog = new[]
|
||||
{
|
||||
TowerModifier("only-common", DefenseTowerKind.Direct,
|
||||
Defense25DTowerModifierEffect.AttackDamageIncreased,
|
||||
Defense25DRewardRarity.Common, 0.1f, 5),
|
||||
};
|
||||
constants.LevelUpRarityWeights = new Defense25DRewardRarityWeights
|
||||
{
|
||||
Common = 0,
|
||||
Rare = 100,
|
||||
Epic = 0,
|
||||
};
|
||||
Defense25DRewardChoices rareToCommon = NewTowerOnlyChoices(constants);
|
||||
rareToCommon.QueueReward(
|
||||
Defense25DRewardSource.LevelUp, "level:rare-to-common");
|
||||
Assert.That(rareToCommon.CurrentChoices.Single().Rarity,
|
||||
Is.EqualTo(Defense25DRewardRarity.Common));
|
||||
|
||||
constants.TowerModifierCatalog = new[]
|
||||
{
|
||||
TowerModifier("only-rare", DefenseTowerKind.Direct,
|
||||
Defense25DTowerModifierEffect.AttackDamageIncreased,
|
||||
Defense25DRewardRarity.Rare, 0.2f, 5),
|
||||
};
|
||||
constants.LevelUpRarityWeights = new Defense25DRewardRarityWeights
|
||||
{
|
||||
Common = 0,
|
||||
Rare = 0,
|
||||
Epic = 100,
|
||||
};
|
||||
Defense25DRewardChoices epicToRare = NewTowerOnlyChoices(constants);
|
||||
epicToRare.QueueReward(
|
||||
Defense25DRewardSource.LevelUp, "level:epic-to-rare");
|
||||
Assert.That(epicToRare.CurrentChoices.Single().Rarity,
|
||||
Is.EqualTo(Defense25DRewardRarity.Rare));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(constants);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PendingLevelUpPreemptsUnopenedRewardsButNotAnOpenedReward()
|
||||
{
|
||||
GameObject playerObject = new("Reward Priority Player");
|
||||
ModifierDefinition playerModifier = CreatePlayerModifier("priority");
|
||||
Defense25DRewardConstants constants = CreateConstants(includeEpic: true);
|
||||
constants.TowerModifierCatalog = System.Array.Empty<Defense25DTowerModifierDefinition>();
|
||||
constants.LevelUpRarityWeights = new Defense25DRewardRarityWeights
|
||||
{
|
||||
Common = 100,
|
||||
Rare = 0,
|
||||
Epic = 0,
|
||||
};
|
||||
constants.GlobalEliteRarityWeights = constants.LevelUpRarityWeights;
|
||||
try
|
||||
{
|
||||
PlayerStats player = playerObject.AddComponent<PlayerStats>();
|
||||
Defense25DRewardChoices choices = new(
|
||||
constants,
|
||||
new[] { playerModifier },
|
||||
player,
|
||||
new Defense25DTowerModifiers(constants),
|
||||
_ => false);
|
||||
|
||||
choices.QueueReward(Defense25DRewardSource.GlobalElite, "elite:pending");
|
||||
Defense25DRewardChoice unopenedEliteChoice = choices.CurrentChoices[0];
|
||||
choices.QueueReward(Defense25DRewardSource.LevelUp, "level:priority");
|
||||
Assert.That(choices.CurrentRewardToken, Is.EqualTo("level:priority"));
|
||||
Assert.That(choices.CurrentRewardIsOpened, Is.False);
|
||||
Assert.That(choices.GetPendingRewardCount(
|
||||
Defense25DRewardSource.GlobalElite), Is.EqualTo(1));
|
||||
|
||||
Assert.That(choices.TryChooseReward(0, "level:priority"), Is.True);
|
||||
Assert.That(choices.CurrentRewardToken, Is.EqualTo("elite:pending"));
|
||||
Assert.That(choices.CurrentChoices[0], Is.SameAs(unopenedEliteChoice),
|
||||
"The preempted unopened reward retains its token snapshot.");
|
||||
|
||||
choices.Reset();
|
||||
choices.QueueReward(Defense25DRewardSource.GlobalElite, "elite:opened");
|
||||
Assert.That(choices.MarkCurrentRewardOpened(), Is.True);
|
||||
choices.QueueReward(Defense25DRewardSource.LevelUp, "level:while-open");
|
||||
Assert.That(choices.CurrentRewardToken, Is.EqualTo("elite:opened"));
|
||||
Assert.That(choices.CurrentRewardIsOpened, Is.True);
|
||||
|
||||
Assert.That(choices.TryChooseReward(0, "elite:opened"), Is.True);
|
||||
Assert.That(choices.CurrentRewardToken, Is.EqualTo("level:while-open"));
|
||||
Assert.That(choices.CurrentRewardIsOpened, Is.False);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(playerModifier);
|
||||
Object.DestroyImmediate(constants);
|
||||
Object.DestroyImmediate(playerObject);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MixedTargetGuaranteeIsResolvedBeforeEachTargetsRarityRoll()
|
||||
{
|
||||
GameObject playerObject = new("Target First Player");
|
||||
ModifierDefinition playerModifier = CreatePlayerModifier("target-first");
|
||||
Defense25DRewardConstants constants = CreateConstants(includeEpic: true);
|
||||
constants.TowerModifierCatalog = new[]
|
||||
{
|
||||
TowerModifier("tower.epic.only", DefenseTowerKind.Direct,
|
||||
Defense25DTowerModifierEffect.AttackDamageIncreased,
|
||||
Defense25DRewardRarity.Epic, 0.35f, 1),
|
||||
};
|
||||
constants.LevelUpRarityWeights = new Defense25DRewardRarityWeights
|
||||
{
|
||||
Common = 80,
|
||||
Rare = 0,
|
||||
Epic = 20,
|
||||
};
|
||||
try
|
||||
{
|
||||
PlayerStats player = playerObject.AddComponent<PlayerStats>();
|
||||
Defense25DRewardChoices choices = new(
|
||||
constants,
|
||||
new[] { playerModifier },
|
||||
player,
|
||||
new Defense25DTowerModifiers(constants),
|
||||
_ => true);
|
||||
|
||||
for (int index = 0; index < 20; index++)
|
||||
{
|
||||
choices.Reset();
|
||||
choices.QueueReward(
|
||||
Defense25DRewardSource.LevelUp,
|
||||
$"level:target-first-{index}");
|
||||
Assert.That(choices.CurrentChoices.Select(choice => choice.TargetKind)
|
||||
.Distinct().ToList(), Is.EquivalentTo(new[]
|
||||
{
|
||||
Defense25DRewardTargetKind.PlayerModifier,
|
||||
Defense25DRewardTargetKind.TowerModifier,
|
||||
}), "Both eligible target groups are selected before rarity is rolled.");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(playerModifier);
|
||||
Object.DestroyImmediate(constants);
|
||||
Object.DestroyImmediate(playerObject);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MissionRelicRewardUsesTokenCandidatesAndCanResolveOnlyOnce()
|
||||
{
|
||||
Defense25DRewardConstants constants = CreateConstants(includeEpic: true);
|
||||
constants.TowerModifierCatalog = System.Array.Empty<Defense25DTowerModifierDefinition>();
|
||||
Defense25DRewardChoice[] candidates =
|
||||
{
|
||||
Defense25DRewardChoice.CreatePersonalMissionRelic(
|
||||
"relic.first", "Relic", new Defense25DRewardDisplayData("First", "First relic")),
|
||||
Defense25DRewardChoice.CreatePersonalMissionRelic(
|
||||
"relic.second", "Relic", new Defense25DRewardDisplayData("Second", "Second relic")),
|
||||
};
|
||||
int resolved = 0;
|
||||
try
|
||||
{
|
||||
Defense25DRewardChoices choices = new(
|
||||
constants,
|
||||
System.Array.Empty<ModifierDefinition>(),
|
||||
null,
|
||||
new Defense25DTowerModifiers(constants),
|
||||
_ => false,
|
||||
getMissionRelicCandidates: _ => candidates,
|
||||
chooseMissionRelic: choice =>
|
||||
{
|
||||
resolved++;
|
||||
return choice.DefinitionId == "relic.second";
|
||||
});
|
||||
|
||||
Assert.That(choices.QueueReward(
|
||||
Defense25DRewardSource.PersonalMissionRelic,
|
||||
"mission:15:relic"), Is.True);
|
||||
Assert.That(choices.QueueReward(
|
||||
Defense25DRewardSource.PersonalMissionRelic,
|
||||
"mission:15:relic"), Is.False);
|
||||
Assert.That(choices.CurrentChoices, Has.Count.EqualTo(2));
|
||||
Assert.That(choices.TryChooseReward(1, "mission:15:relic"), Is.True);
|
||||
Assert.That(resolved, Is.EqualTo(1));
|
||||
Assert.That(choices.TryChooseReward(1, "mission:15:relic"), Is.False);
|
||||
Assert.That(choices.QueueReward(
|
||||
Defense25DRewardSource.PersonalMissionRelic,
|
||||
"mission:15:relic"), Is.False);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(constants);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SameSeedAndTokenProduceSameCandidateSnapshot()
|
||||
{
|
||||
GameObject firstPlayerObject = new("Seeded Reward Player A");
|
||||
GameObject secondPlayerObject = new("Seeded Reward Player B");
|
||||
ModifierDefinition playerModifier = CreatePlayerModifier("seeded");
|
||||
Defense25DRewardConstants constants = CreateConstants(includeEpic: true);
|
||||
try
|
||||
{
|
||||
PlayerStats firstPlayer = firstPlayerObject.AddComponent<PlayerStats>();
|
||||
PlayerStats secondPlayer = secondPlayerObject.AddComponent<PlayerStats>();
|
||||
Defense25DRewardChoices first = new(
|
||||
constants,
|
||||
new[] { playerModifier },
|
||||
firstPlayer,
|
||||
new Defense25DTowerModifiers(constants),
|
||||
_ => true);
|
||||
Defense25DRewardChoices replay = new(
|
||||
constants,
|
||||
new[] { playerModifier },
|
||||
secondPlayer,
|
||||
new Defense25DTowerModifiers(constants),
|
||||
_ => true);
|
||||
|
||||
first.QueueReward(Defense25DRewardSource.LevelUp, "level:seeded-replay");
|
||||
replay.QueueReward(Defense25DRewardSource.LevelUp, "level:seeded-replay");
|
||||
Assert.That(first.CurrentChoices.Select(choice =>
|
||||
(choice.TargetKind, choice.DefinitionId, choice.Rarity)).ToArray(),
|
||||
Is.EqualTo(replay.CurrentChoices.Select(choice =>
|
||||
(choice.TargetKind, choice.DefinitionId, choice.Rarity)).ToArray()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(playerModifier);
|
||||
Object.DestroyImmediate(constants);
|
||||
Object.DestroyImmediate(firstPlayerObject);
|
||||
Object.DestroyImmediate(secondPlayerObject);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GlobalMidBoss_OffersEpicAndFallsBackToCurrencyWhenNoEpicIsEligible()
|
||||
{
|
||||
GameObject playerObject = new("MidBoss Reward Player");
|
||||
ModifierDefinition playerModifier = CreatePlayerModifier("collision-damage");
|
||||
Defense25DRewardConstants constants = CreateConstants(includeEpic: true);
|
||||
int grantedCurrency = 0;
|
||||
int grantedAmount = 0;
|
||||
try
|
||||
{
|
||||
PlayerStats player = playerObject.AddComponent<PlayerStats>();
|
||||
Defense25DRewardChoices choices = new(
|
||||
constants,
|
||||
new[] { playerModifier },
|
||||
player,
|
||||
new Defense25DTowerModifiers(constants),
|
||||
_ => true);
|
||||
|
||||
choices.QueueReward(
|
||||
Defense25DRewardSource.GlobalMidBoss,
|
||||
"midboss:240");
|
||||
Assert.That(choices.CurrentChoices.Any(choice =>
|
||||
choice.Rarity == Defense25DRewardRarity.Epic), Is.True);
|
||||
|
||||
constants.TowerModifierCatalog = constants.TowerModifierCatalog
|
||||
.Where(definition => definition.Rarity != Defense25DRewardRarity.Epic)
|
||||
.ToArray();
|
||||
player.AddModifier(new StatModifier(
|
||||
playerModifier.ModifierId,
|
||||
playerModifier.Stat,
|
||||
playerModifier.Operation,
|
||||
playerModifier.Value,
|
||||
playerModifier.MaximumStacks));
|
||||
player.AddModifier(new StatModifier(
|
||||
playerModifier.ModifierId,
|
||||
playerModifier.Stat,
|
||||
playerModifier.Operation,
|
||||
playerModifier.Value,
|
||||
playerModifier.MaximumStacks));
|
||||
Defense25DRewardChoices noEpicChoices = new(
|
||||
constants,
|
||||
new[] { playerModifier },
|
||||
player,
|
||||
new Defense25DTowerModifiers(constants),
|
||||
_ => true,
|
||||
grantCurrency: amount =>
|
||||
{
|
||||
grantedCurrency++;
|
||||
grantedAmount += amount;
|
||||
});
|
||||
noEpicChoices.QueueReward(
|
||||
Defense25DRewardSource.GlobalMidBoss,
|
||||
"midboss:no-epic");
|
||||
|
||||
Assert.That(noEpicChoices.CurrentChoices.Any(choice =>
|
||||
choice.TargetKind == Defense25DRewardTargetKind.Currency),
|
||||
Is.True);
|
||||
int currencyChoiceIndex = noEpicChoices.CurrentChoices
|
||||
.Select((choice, index) => (choice, index))
|
||||
.Single(pair => pair.choice.TargetKind
|
||||
== Defense25DRewardTargetKind.Currency).index;
|
||||
Assert.That(noEpicChoices.TryChooseReward(
|
||||
currencyChoiceIndex, "midboss:no-epic"), Is.True);
|
||||
Assert.That(grantedCurrency, Is.EqualTo(1));
|
||||
Assert.That(grantedAmount,
|
||||
Is.EqualTo(constants.EmptyEpicFallbackCurrency));
|
||||
Assert.That(noEpicChoices.TryChooseReward(
|
||||
currencyChoiceIndex, "midboss:no-epic"), Is.False);
|
||||
Assert.That(grantedCurrency, Is.EqualTo(1));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(playerModifier);
|
||||
Object.DestroyImmediate(constants);
|
||||
Object.DestroyImmediate(playerObject);
|
||||
}
|
||||
}
|
||||
|
||||
[TestCase(Defense25DRewardSource.LevelUp)]
|
||||
[TestCase(Defense25DRewardSource.GlobalElite)]
|
||||
public void EmptyModifierPoolOffersResolvableCurrencyFallback(
|
||||
Defense25DRewardSource source)
|
||||
{
|
||||
Defense25DRewardConstants constants = CreateConstants(includeEpic: true);
|
||||
constants.TowerModifierCatalog = System.Array.Empty<Defense25DTowerModifierDefinition>();
|
||||
int grantCount = 0;
|
||||
int grantedAmount = 0;
|
||||
try
|
||||
{
|
||||
Defense25DRewardChoices choices = new(
|
||||
constants,
|
||||
System.Array.Empty<ModifierDefinition>(),
|
||||
null,
|
||||
new Defense25DTowerModifiers(constants),
|
||||
_ => false,
|
||||
grantCurrency: amount =>
|
||||
{
|
||||
grantCount++;
|
||||
grantedAmount = amount;
|
||||
});
|
||||
|
||||
Assert.That(choices.QueueReward(source, "empty:" + source), Is.True);
|
||||
Assert.That(choices.CurrentChoices, Has.Count.EqualTo(1));
|
||||
Assert.That(choices.CurrentChoices[0].TargetKind,
|
||||
Is.EqualTo(Defense25DRewardTargetKind.Currency));
|
||||
Assert.That(choices.CurrentChoices[0].DisplayData.Title,
|
||||
Is.EqualTo("보상 대체"));
|
||||
Assert.That(choices.CurrentChoices[0].DisplayData.Description,
|
||||
Is.EqualTo("적격한 보상이 없어 재화 10개를 받습니다.\n선택하면 즉시 적용됩니다."));
|
||||
Assert.That(choices.TryChooseReward(0, "empty:" + source), Is.True);
|
||||
Assert.That(grantCount, Is.EqualTo(1));
|
||||
Assert.That(grantedAmount,
|
||||
Is.EqualTo(constants.EmptyEpicFallbackCurrency));
|
||||
Assert.That(choices.HasPendingReward, Is.False);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(constants);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EmptyRewardWithoutCurrencyCallbackAutomaticallyResolvesToken()
|
||||
{
|
||||
Defense25DRewardConstants constants = CreateConstants(includeEpic: true);
|
||||
constants.TowerModifierCatalog = System.Array.Empty<Defense25DTowerModifierDefinition>();
|
||||
try
|
||||
{
|
||||
Defense25DRewardChoices choices = new(
|
||||
constants,
|
||||
System.Array.Empty<ModifierDefinition>(),
|
||||
null,
|
||||
new Defense25DTowerModifiers(constants),
|
||||
_ => false);
|
||||
int autoResolvedCount = 0;
|
||||
string autoResolvedToken = null;
|
||||
choices.AutomaticallyResolved += (source, token) =>
|
||||
{
|
||||
Assert.That(source, Is.EqualTo(Defense25DRewardSource.LevelUp));
|
||||
autoResolvedCount++;
|
||||
autoResolvedToken = token;
|
||||
};
|
||||
|
||||
Assert.That(choices.QueueReward(
|
||||
Defense25DRewardSource.LevelUp, "empty:no-currency"), Is.True);
|
||||
Assert.That(choices.HasPendingReward, Is.False);
|
||||
Assert.That(choices.PendingRewardCount, Is.Zero);
|
||||
Assert.That(autoResolvedCount, Is.EqualTo(1));
|
||||
Assert.That(autoResolvedToken, Is.EqualTo("empty:no-currency"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(constants);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FullyInvalidatedOpenSnapshotFallsBackToCurrency()
|
||||
{
|
||||
GameObject playerObject = new("Invalidated Reward Player");
|
||||
ModifierDefinition playerModifier = CreatePlayerModifier("invalidated");
|
||||
Defense25DRewardConstants constants = CreateConstants(includeEpic: true);
|
||||
constants.TowerModifierCatalog = System.Array.Empty<Defense25DTowerModifierDefinition>();
|
||||
constants.LevelUpRarityWeights = new Defense25DRewardRarityWeights
|
||||
{
|
||||
Common = 100,
|
||||
Rare = 0,
|
||||
Epic = 0,
|
||||
};
|
||||
int grantedCurrency = 0;
|
||||
try
|
||||
{
|
||||
PlayerStats player = playerObject.AddComponent<PlayerStats>();
|
||||
Defense25DRewardChoices choices = new(
|
||||
constants,
|
||||
new[] { playerModifier },
|
||||
player,
|
||||
new Defense25DTowerModifiers(constants),
|
||||
_ => false,
|
||||
grantCurrency: amount => grantedCurrency += amount);
|
||||
|
||||
choices.QueueReward(Defense25DRewardSource.LevelUp, "stale:all-invalid");
|
||||
Assert.That(choices.CurrentChoices, Has.Count.EqualTo(1));
|
||||
Assert.That(choices.MarkCurrentRewardOpened(), Is.True);
|
||||
for (int index = 0; index < playerModifier.MaximumStacks; index++)
|
||||
{
|
||||
Assert.That(playerModifier.TryApplyToModel(player, null), Is.True);
|
||||
}
|
||||
|
||||
choices.RefreshChoices();
|
||||
|
||||
Assert.That(choices.CurrentRewardToken, Is.EqualTo("stale:all-invalid"));
|
||||
Assert.That(choices.CurrentRewardIsOpened, Is.True);
|
||||
Assert.That(choices.CurrentChoices, Has.Count.EqualTo(1));
|
||||
Assert.That(choices.CurrentChoices[0].TargetKind,
|
||||
Is.EqualTo(Defense25DRewardTargetKind.Currency));
|
||||
Assert.That(choices.TryChooseReward(0, "stale:all-invalid"), Is.True);
|
||||
Assert.That(grantedCurrency,
|
||||
Is.EqualTo(constants.EmptyEpicFallbackCurrency));
|
||||
Assert.That(choices.HasPendingReward, Is.False);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(playerModifier);
|
||||
Object.DestroyImmediate(constants);
|
||||
Object.DestroyImmediate(playerObject);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ChoosingPlayerModifier_ChangesOnlyPlayerModelAndConsumesRewardOnce()
|
||||
{
|
||||
GameObject playerObject = new("Apply Reward Player");
|
||||
ModifierDefinition playerModifier = CreatePlayerModifier("collision-damage");
|
||||
Defense25DRewardConstants constants = CreateConstants(includeEpic: true);
|
||||
try
|
||||
{
|
||||
PlayerStats player = playerObject.AddComponent<PlayerStats>();
|
||||
Defense25DTowerModifiers towerModifiers = new(constants);
|
||||
Defense25DRewardChoices choices = new(
|
||||
constants,
|
||||
new[] { playerModifier },
|
||||
player,
|
||||
towerModifiers,
|
||||
_ => true);
|
||||
choices.QueueReward(
|
||||
Defense25DRewardSource.LevelUp,
|
||||
"level:apply-once");
|
||||
int playerChoiceIndex = choices.CurrentChoices.ToList()
|
||||
.FindIndex(choice => choice.TargetKind
|
||||
== Defense25DRewardTargetKind.PlayerModifier);
|
||||
Assert.That(playerChoiceIndex, Is.GreaterThanOrEqualTo(0));
|
||||
|
||||
Assert.That(choices.TryChooseReward(playerChoiceIndex), Is.True);
|
||||
Assert.That(player.Evaluate(CharacterStat.CollisionDamage, 10f),
|
||||
Is.GreaterThan(10f));
|
||||
Assert.That(towerModifiers.AppliedChoiceCount, Is.Zero);
|
||||
Assert.That(choices.HasPendingReward, Is.False);
|
||||
Assert.That(choices.TryChooseReward(playerChoiceIndex), Is.False);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(playerModifier);
|
||||
Object.DestroyImmediate(constants);
|
||||
Object.DestroyImmediate(playerObject);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PlayerRarityVariantsShareStacksAndHealOnlyAfterSuccessfulApply()
|
||||
{
|
||||
GameObject playerObject = new("Rarity Variant Player");
|
||||
ModifierDefinition playerModifier = CreatePlayerModifier("move-speed");
|
||||
playerModifier.Configure(
|
||||
"test.move-speed", "Move speed", CharacterStat.MoveSpeed,
|
||||
ModifierOperation.Increased, 0.2f, 2, 6f);
|
||||
Defense25DRewardConstants constants = CreateConstants(includeEpic: true);
|
||||
int healCalls = 0;
|
||||
float totalHeal = 0f;
|
||||
try
|
||||
{
|
||||
constants.LevelUpRarityWeights = new Defense25DRewardRarityWeights
|
||||
{
|
||||
Common = 0,
|
||||
Rare = 100,
|
||||
Epic = 0,
|
||||
};
|
||||
PlayerStats player = playerObject.AddComponent<PlayerStats>();
|
||||
Defense25DRewardChoices choices = new(
|
||||
constants,
|
||||
new[] { playerModifier },
|
||||
player,
|
||||
new Defense25DTowerModifiers(constants),
|
||||
_ => false,
|
||||
amount =>
|
||||
{
|
||||
healCalls++;
|
||||
totalHeal += amount;
|
||||
});
|
||||
|
||||
Assert.That(choices.QueueReward(
|
||||
Defense25DRewardSource.LevelUp, "level:rare"), Is.True);
|
||||
Assert.That(choices.CurrentChoices, Has.Count.EqualTo(1));
|
||||
Assert.That(choices.CurrentChoices[0].Rarity,
|
||||
Is.EqualTo(Defense25DRewardRarity.Rare));
|
||||
Assert.That(choices.TryChooseReward(0), Is.True);
|
||||
Assert.That(player.Evaluate(CharacterStat.MoveSpeed, 10f),
|
||||
Is.EqualTo(13f).Within(0.001f));
|
||||
Assert.That(healCalls, Is.EqualTo(1));
|
||||
Assert.That(totalHeal, Is.EqualTo(9f).Within(0.001f));
|
||||
|
||||
constants.LevelUpRarityWeights = new Defense25DRewardRarityWeights
|
||||
{
|
||||
Common = 0,
|
||||
Rare = 0,
|
||||
Epic = 100,
|
||||
};
|
||||
Assert.That(playerModifier.CanApplyToModel(player), Is.True,
|
||||
"A rare stack leaves one shared stack available to an Epic variant.");
|
||||
Assert.That(choices.QueueReward(
|
||||
Defense25DRewardSource.LevelUp, "level:epic"), Is.True);
|
||||
Assert.That(choices.CurrentChoices[0].Rarity,
|
||||
Is.EqualTo(Defense25DRewardRarity.Epic));
|
||||
Assert.That(choices.TryChooseReward(0), Is.True);
|
||||
Assert.That(player.Evaluate(CharacterStat.MoveSpeed, 10f),
|
||||
Is.EqualTo(17f).Within(0.001f));
|
||||
Assert.That(healCalls, Is.EqualTo(2));
|
||||
Assert.That(totalHeal, Is.EqualTo(21f).Within(0.001f));
|
||||
|
||||
Assert.That(playerModifier.TryApplyToModel(
|
||||
player,
|
||||
amount =>
|
||||
{
|
||||
healCalls++;
|
||||
totalHeal += amount;
|
||||
},
|
||||
1.5f), Is.False,
|
||||
"The shared cap rejects a later rarity variant.");
|
||||
Assert.That(healCalls, Is.EqualTo(2));
|
||||
Assert.That(totalHeal, Is.EqualTo(21f).Within(0.001f));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(playerModifier);
|
||||
Object.DestroyImmediate(constants);
|
||||
Object.DestroyImmediate(playerObject);
|
||||
}
|
||||
}
|
||||
|
||||
private static ModifierDefinition CreatePlayerModifier(string id)
|
||||
{
|
||||
ModifierDefinition definition =
|
||||
ScriptableObject.CreateInstance<ModifierDefinition>();
|
||||
definition.Configure(
|
||||
$"test.{id}",
|
||||
id,
|
||||
id == "move-speed"
|
||||
? CharacterStat.MoveSpeed
|
||||
: CharacterStat.CollisionDamage,
|
||||
ModifierOperation.Increased,
|
||||
0.2f,
|
||||
2);
|
||||
return definition;
|
||||
}
|
||||
|
||||
private static Defense25DRewardChoices NewTowerOnlyChoices(
|
||||
Defense25DRewardConstants constants)
|
||||
{
|
||||
return new Defense25DRewardChoices(
|
||||
constants,
|
||||
System.Array.Empty<ModifierDefinition>(),
|
||||
null,
|
||||
new Defense25DTowerModifiers(constants),
|
||||
_ => true);
|
||||
}
|
||||
|
||||
private static Defense25DRewardConstants CreateConstants(bool includeEpic)
|
||||
{
|
||||
Defense25DRewardConstants constants =
|
||||
ScriptableObject.CreateInstance<Defense25DRewardConstants>();
|
||||
constants.RunSeed = 137;
|
||||
constants.ChoiceCount = 3;
|
||||
constants.LevelUpRarityWeights = new Defense25DRewardRarityWeights
|
||||
{
|
||||
Common = 75,
|
||||
Rare = 20,
|
||||
Epic = 5,
|
||||
};
|
||||
constants.GlobalEliteRarityWeights = new Defense25DRewardRarityWeights
|
||||
{
|
||||
Common = 60,
|
||||
Rare = 30,
|
||||
Epic = 10,
|
||||
};
|
||||
constants.EmptyEpicFallbackCurrency = 10;
|
||||
constants.TowerModifierCatalog = new[]
|
||||
{
|
||||
TowerModifier("fire.attack.common", DefenseTowerKind.Direct,
|
||||
Defense25DTowerModifierEffect.AttackDamageIncreased,
|
||||
Defense25DRewardRarity.Common, 0.1f, 5),
|
||||
TowerModifier("fire.attack.rare", DefenseTowerKind.Direct,
|
||||
Defense25DTowerModifierEffect.AttackDamageIncreased,
|
||||
Defense25DRewardRarity.Rare, 0.2f, 5),
|
||||
TowerModifier("fire.attack.epic", DefenseTowerKind.Direct,
|
||||
Defense25DTowerModifierEffect.AttackDamageIncreased,
|
||||
Defense25DRewardRarity.Epic, 0.35f, 5),
|
||||
TowerModifier("lightning.chain.rare", DefenseTowerKind.Lightning,
|
||||
Defense25DTowerModifierEffect.ChainCount,
|
||||
Defense25DRewardRarity.Rare, 1f, 3),
|
||||
TowerModifier("cold.projectile.epic", DefenseTowerKind.Cold,
|
||||
Defense25DTowerModifierEffect.AdditionalProjectile,
|
||||
Defense25DRewardRarity.Epic, 1f, 2),
|
||||
}.Where(definition => includeEpic
|
||||
|| definition.Rarity != Defense25DRewardRarity.Epic).ToArray();
|
||||
return constants;
|
||||
}
|
||||
|
||||
private static Defense25DTowerModifierDefinition TowerModifier(
|
||||
string id,
|
||||
DefenseTowerKind family,
|
||||
Defense25DTowerModifierEffect effect,
|
||||
Defense25DRewardRarity rarity,
|
||||
float value,
|
||||
int maximumStacks)
|
||||
{
|
||||
return new Defense25DTowerModifierDefinition
|
||||
{
|
||||
DefinitionId = id,
|
||||
DisplayName = id,
|
||||
Description = id,
|
||||
Family = family,
|
||||
Effect = effect,
|
||||
Rarity = rarity,
|
||||
Value = value,
|
||||
MaximumStacks = maximumStacks,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4680907c74e6440d80712dec83dad63c
|
||||
@@ -0,0 +1,997 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Defense;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Defense.Comparison;
|
||||
using BumpCombat.Enemies;
|
||||
using BumpCombat.Progression;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.InputSystem.LowLevel;
|
||||
using UnityEngine.TestTools;
|
||||
using UnityEngine.UI;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace BumpCombat.Tests
|
||||
{
|
||||
public sealed class Defense25DRunHudPlayModeTests
|
||||
{
|
||||
private const string ConstantsPath =
|
||||
"Assets/_Project/Constants/Defense25DComparison/RunHudConstants.asset";
|
||||
private GameObject hudTestWorldObject;
|
||||
private GameObject hudTestObject;
|
||||
private Defense25DComparisonSettings hudTestSettings;
|
||||
private Defense25DRunDefinition hudTestRunDefinition;
|
||||
private Defense25DWaveDirectorConstants hudTestWaveConstants;
|
||||
private Defense25DProgression hudTestProgression;
|
||||
private Defense25DRewardConstants hudTestRewardConstants;
|
||||
private ModifierDefinition hudTestModifier;
|
||||
private float previousTimeScale;
|
||||
private InputSettings.BackgroundBehavior previousBackgroundBehavior;
|
||||
private InputSettings.EditorInputBehaviorInPlayMode previousEditorInputBehavior;
|
||||
private bool previousRunInBackground;
|
||||
private bool hudInputSettingsCaptured;
|
||||
|
||||
[SetUp]
|
||||
public void CaptureTimeScale()
|
||||
{
|
||||
previousTimeScale = Time.timeScale;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RunHudConstantsAssetMatchesTheFhdLayoutContract()
|
||||
{
|
||||
Defense25DRunHudConstants constants =
|
||||
AssetDatabase.LoadAssetAtPath<Defense25DRunHudConstants>(ConstantsPath);
|
||||
|
||||
Assert.That(constants, Is.Not.Null);
|
||||
Assert.That(constants.CombatHudBottomLeft, Is.EqualTo(new Vector2(24f, 24f)));
|
||||
Assert.That(constants.CombatHudSize, Is.EqualTo(new Vector2(392f, 136f)));
|
||||
Assert.That(constants.HealthTrackSize, Is.EqualTo(new Vector2(352f, 12f)));
|
||||
Assert.That(constants.ArtifactGaugeSize, Is.EqualTo(new Vector2(352f, 18f)));
|
||||
Assert.That(constants.ExperienceTrackSize, Is.EqualTo(new Vector2(352f, 8f)));
|
||||
Assert.That(constants.GuardTrackSize, Is.EqualTo(new Vector2(18f, 94f)));
|
||||
Assert.That(constants.TimerSize, Is.EqualTo(new Vector2(220f, 66f)));
|
||||
Assert.That(constants.WaveSummarySize, Is.EqualTo(new Vector2(700f, 116f)));
|
||||
Assert.That(constants.MinimapSize, Is.EqualTo(new Vector2(300f, 162f)));
|
||||
Assert.That(constants.PortalListSize, Is.EqualTo(new Vector2(320f, 300f)));
|
||||
Assert.That(constants.PortalCount, Is.EqualTo(12));
|
||||
Assert.That(constants.MissionCount, Is.EqualTo(3));
|
||||
Assert.That(constants.ModalMaximumSize, Is.EqualTo(new Vector2(1040f, 420f)));
|
||||
}
|
||||
|
||||
[TestCase(1280f, 720f, 2f / 3f)]
|
||||
[TestCase(1920f, 1080f, 1f)]
|
||||
[TestCase(2560f, 1440f, 4f / 3f)]
|
||||
[TestCase(1399f, 799f, 1399f / 1920f)]
|
||||
public void ViewportScaleUsesTheSmallerReferenceAxis(
|
||||
float width, float height, float expectedScale)
|
||||
{
|
||||
float scale = Defense25DRunHudConstants.ScaleForViewport(
|
||||
new Rect(0f, 0f, width, height));
|
||||
|
||||
Assert.That(scale, Is.EqualTo(expectedScale).Within(.0001f));
|
||||
}
|
||||
|
||||
[TestCase(0f, 1080f)]
|
||||
[TestCase(1920f, 0f)]
|
||||
[TestCase(-1f, 720f)]
|
||||
public void InvalidViewportHasNoLayoutScale(float width, float height)
|
||||
{
|
||||
Assert.That(Defense25DRunHudConstants.IsValidViewport(
|
||||
new Rect(0f, 0f, width, height)), Is.False);
|
||||
Assert.That(Defense25DRunHudConstants.ScaleForViewport(
|
||||
new Rect(0f, 0f, width, height)), Is.Zero);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator BindCreatesRealButtonHitRectsAndUnbindClearsThem()
|
||||
{
|
||||
GameObject host = new("Run HUD PlayMode Test");
|
||||
Defense25DRunHud hud = host.AddComponent<Defense25DRunHud>();
|
||||
hud.Bind(null, null, null);
|
||||
yield return null;
|
||||
|
||||
Assert.That(hud.IsLayoutValid, Is.True);
|
||||
Rect[] firstRects = hud.GetPointerBlockRects();
|
||||
Assert.That(firstRects, Is.Not.Empty);
|
||||
foreach (Rect hitRect in firstRects)
|
||||
Assert.That(hitRect.width * hitRect.height, Is.GreaterThan(0f));
|
||||
|
||||
Graphic[] graphics = host.GetComponentsInChildren<Graphic>(true);
|
||||
foreach (Graphic graphic in graphics)
|
||||
if (graphic.raycastTarget)
|
||||
Assert.That(graphic.GetComponent<Button>(), Is.Not.Null,
|
||||
$"Decorative graphic '{graphic.name}' must not intercept UI raycasts.");
|
||||
|
||||
hud.Bind(null, null, null);
|
||||
yield return null;
|
||||
Assert.That(hud.GetPointerBlockRects(), Has.Length.EqualTo(firstRects.Length));
|
||||
|
||||
hud.Unbind();
|
||||
Assert.That(hud.IsLayoutValid, Is.False);
|
||||
Assert.That(hud.GetPointerBlockRects(), Is.Empty);
|
||||
Object.Destroy(host);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator InvalidViewportHidesHudAndValidViewportRestoresIt()
|
||||
{
|
||||
GameObject host = new("Run HUD Viewport Test");
|
||||
Defense25DRunHud hud = host.AddComponent<Defense25DRunHud>();
|
||||
hud.Bind(null, null, null);
|
||||
|
||||
hud.RefreshLayout(new Rect(0f, 0f, 0f, 1080f));
|
||||
Assert.That(hud.IsLayoutValid, Is.False);
|
||||
Assert.That(hud.GetPointerBlockRects(), Is.Empty);
|
||||
|
||||
hud.RefreshLayout(new Rect(0f, 0f, 1920f, 1080f));
|
||||
yield return null;
|
||||
Assert.That(hud.IsLayoutValid, Is.True);
|
||||
Assert.That(hud.GetPointerBlockRects(), Is.Not.Empty);
|
||||
|
||||
hud.Unbind();
|
||||
Object.Destroy(host);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[TestCase(1280, 720, 0f, 0f, 1280f, 720f)]
|
||||
[TestCase(1920, 1080, 0f, 0f, 1920f, 1080f)]
|
||||
[TestCase(2560, 1440, 0f, 0f, 2560f, 1440f)]
|
||||
[TestCase(1399, 799, 59.5f, 39.5f, 1280f, 720f)]
|
||||
public void ViewportKeepsWorldRenderScaleAndLetterboxBounds(
|
||||
int screenWidth, int screenHeight,
|
||||
float x, float y, float width, float height)
|
||||
{
|
||||
Rect viewport = Defense25DView.GetViewport(screenWidth, screenHeight);
|
||||
|
||||
Assert.That(viewport.x, Is.EqualTo(x).Within(.001f));
|
||||
Assert.That(viewport.y, Is.EqualTo(y).Within(.001f));
|
||||
Assert.That(viewport.width, Is.EqualTo(width).Within(.001f));
|
||||
Assert.That(viewport.height, Is.EqualTo(height).Within(.001f));
|
||||
}
|
||||
|
||||
[TestCase(1280, 720, 2f / 3f)]
|
||||
[TestCase(1920, 1080, 1f)]
|
||||
[TestCase(2560, 1440, 4f / 3f)]
|
||||
[TestCase(1399, 799, 2f / 3f)]
|
||||
public void HudLayoutUsesViewportScaleAtFhdQhdAndLetterboxedSizes(
|
||||
int screenWidth, int screenHeight, float expectedScale)
|
||||
{
|
||||
GameObject host = new("Run HUD viewport layout test");
|
||||
try
|
||||
{
|
||||
Defense25DRunHud hud = host.AddComponent<Defense25DRunHud>();
|
||||
hud.Bind(null, null, null);
|
||||
Rect viewport = Defense25DView.GetViewport(screenWidth, screenHeight);
|
||||
hud.RefreshLayout(viewport);
|
||||
|
||||
CanvasScaler scaler = hud.HudCanvas.GetComponent<CanvasScaler>();
|
||||
Assert.That(scaler.scaleFactor, Is.EqualTo(expectedScale).Within(.001f));
|
||||
Vector2 expectedSize = viewport.size / expectedScale;
|
||||
Assert.That(hud.ViewportRoot.sizeDelta.x, Is.EqualTo(expectedSize.x).Within(.001f));
|
||||
Assert.That(hud.ViewportRoot.sizeDelta.y, Is.EqualTo(expectedSize.y).Within(.001f));
|
||||
Assert.That(hud.GetPointerBlockRects(), Is.Not.Empty);
|
||||
hud.Unbind();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(host);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ViewportGroundFootprintUsesCameraRaysWithoutRenderTexture()
|
||||
{
|
||||
GameObject cameraObject = new("Viewport Footprint Math Camera");
|
||||
try
|
||||
{
|
||||
Camera camera = cameraObject.AddComponent<Camera>();
|
||||
camera.orthographic = true;
|
||||
camera.orthographicSize = 6f;
|
||||
camera.transform.SetPositionAndRotation(new Vector3(0f, 30f, -20f),
|
||||
Quaternion.LookRotation(new Vector3(0f, -.8f, .6f), Vector3.up));
|
||||
Assert.That(camera.targetTexture, Is.Null);
|
||||
|
||||
Assert.That(Defense25DView.TryGetViewportGroundFootprint(camera, 0f,
|
||||
Vector2.zero, out Vector2 minimum, out Vector2 maximum), Is.True);
|
||||
Assert.That(minimum.x, Is.LessThan(maximum.x));
|
||||
Assert.That(minimum.y, Is.LessThan(maximum.y));
|
||||
Assert.That(float.IsInfinity(minimum.x) || float.IsNaN(minimum.x), Is.False);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(cameraObject);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FollowCenterClampsTheTargetToTheGroundViewportFootprint()
|
||||
{
|
||||
Vector2 center = Defense25DView.ClampViewportCenter(
|
||||
new Vector2(100f, -100f), Vector2.zero, new Vector2(20f, 20f),
|
||||
new Vector2(-5f, -8f), new Vector2(7f, 10f));
|
||||
|
||||
Assert.That(center, Is.EqualTo(new Vector2(13f, -12f)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SmallArenaCentersViewportWhenFootprintIsLargerThanBounds()
|
||||
{
|
||||
Vector2 center = Defense25DView.ClampViewportCenter(
|
||||
new Vector2(80f, -60f), new Vector2(3f, -4f), new Vector2(2f, 1f),
|
||||
new Vector2(-6f, -5f), new Vector2(8f, 7f));
|
||||
|
||||
Assert.That(center, Is.EqualTo(new Vector2(2f, -5f)));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator TierThreeUpgradeRequiresAnExplicitBranchButtonChoice()
|
||||
{
|
||||
yield return InitializeHudTestWorld(longRun: true);
|
||||
Defense25DActor player = hudTestWorldObject.GetComponent<Defense25DComparison>().Player;
|
||||
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
|
||||
Vector2 firstPad = world.TowerManager.GetPadPosition(0);
|
||||
Vector2 secondPad = world.TowerManager.GetPadPosition(1);
|
||||
player.ResetAt(firstPad);
|
||||
world.TowerManager.AddCurrency(5000);
|
||||
Assert.That(world.TowerManager.TryBuildTowerAtPad(0, DefenseTowerKind.Direct), Is.True);
|
||||
Assert.That(world.SelectBuildPad(0), Is.True);
|
||||
Assert.That(world.UpgradeSelectedTower(DefenseTowerBranch.None), Is.True);
|
||||
Assert.That(world.UpgradeSelectedTower(DefenseTowerBranch.None), Is.True);
|
||||
hudTestHud.RefreshState();
|
||||
|
||||
Defense25DTower firstTower = world.TowerManager.GetTowerAtPad(0);
|
||||
Assert.That(firstTower.Tier, Is.EqualTo(3));
|
||||
FindButton("Upgrade Selected Tower").onClick.Invoke();
|
||||
Assert.That(firstTower.Tier, Is.EqualTo(3),
|
||||
"An unselected T3 branch must not silently upgrade into First.");
|
||||
|
||||
Button first = FindButton("Upgrade Branch First");
|
||||
Button second = FindButton("Upgrade Branch Second");
|
||||
Assert.That(first, Is.Not.Null);
|
||||
Assert.That(second, Is.Not.Null);
|
||||
Assert.That(GetButtonLabel(first), Does.Contain(
|
||||
DefenseTower.GetBranchName(firstTower.Kind, DefenseTowerBranch.First)));
|
||||
Assert.That(GetButtonLabel(first), Does.Not.Contain("First"));
|
||||
Assert.That(GetButtonLabel(second), Does.Contain(
|
||||
DefenseTower.GetBranchName(firstTower.Kind, DefenseTowerBranch.Second)));
|
||||
Assert.That(GetButtonLabel(second), Does.Not.Contain("Second"));
|
||||
first.onClick.Invoke();
|
||||
Assert.That(hudTestHud.SelectedUpgradeBranch, Is.EqualTo(DefenseTowerBranch.First));
|
||||
|
||||
player.ResetAt(secondPad);
|
||||
Assert.That(world.SelectBuildPad(1), Is.True);
|
||||
Assert.That(world.TowerManager.TryBuildTowerAtPad(1, DefenseTowerKind.Direct), Is.True);
|
||||
Assert.That(world.UpgradeSelectedTower(DefenseTowerBranch.None), Is.True);
|
||||
Assert.That(world.UpgradeSelectedTower(DefenseTowerBranch.None), Is.True);
|
||||
hudTestHud.RefreshState();
|
||||
Defense25DTower secondTower = world.TowerManager.GetTowerAtPad(1);
|
||||
Assert.That(hudTestHud.SelectedUpgradeBranch, Is.EqualTo(DefenseTowerBranch.None),
|
||||
"A branch chosen for another pad must be discarded when selection changes.");
|
||||
FindButton("Upgrade Selected Tower").onClick.Invoke();
|
||||
Assert.That(secondTower.Tier, Is.EqualTo(3),
|
||||
"The newly selected T3 tower must wait for its own branch choice.");
|
||||
FindButton("Upgrade Branch Second").onClick.Invoke();
|
||||
FindButton("Upgrade Selected Tower").onClick.Invoke();
|
||||
Assert.That(secondTower.Tier, Is.EqualTo(4));
|
||||
Assert.That(secondTower.Branch, Is.EqualTo(DefenseTowerBranch.Second));
|
||||
|
||||
player.ResetAt(firstPad);
|
||||
Assert.That(world.SelectBuildPad(0), Is.True);
|
||||
hudTestHud.RefreshState();
|
||||
FindButton("Upgrade Branch First").onClick.Invoke();
|
||||
FindButton("Upgrade Selected Tower").onClick.Invoke();
|
||||
Assert.That(firstTower.Tier, Is.EqualTo(4));
|
||||
Assert.That(firstTower.Branch, Is.EqualTo(DefenseTowerBranch.First));
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator TierThreeBranchKeysUseTowerNamesWithoutUpgradingImmediately()
|
||||
{
|
||||
yield return InitializeHudTestWorld(longRun: true);
|
||||
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
|
||||
world.AdvanceRunClock(world.PreparationRemaining + .1f);
|
||||
Defense25DActor player = world.Player;
|
||||
player.ResetAt(world.TowerManager.GetPadPosition(0));
|
||||
world.TowerManager.AddCurrency(5000);
|
||||
Assert.That(world.TowerManager.TryBuildTowerAtPad(0, DefenseTowerKind.Direct), Is.True);
|
||||
Assert.That(world.SelectBuildPad(0), Is.True);
|
||||
Assert.That(world.UpgradeSelectedTower(DefenseTowerBranch.None), Is.True);
|
||||
Assert.That(world.UpgradeSelectedTower(DefenseTowerBranch.None), Is.True);
|
||||
hudTestHud.RefreshState();
|
||||
Defense25DTower tower = world.TowerManager.GetTowerAtPad(0);
|
||||
Button first = FindButton("Upgrade Branch First");
|
||||
Button second = FindButton("Upgrade Branch Second");
|
||||
Assert.That(GetButtonLabel(first), Does.Contain(
|
||||
DefenseTower.GetBranchName(tower.Kind, DefenseTowerBranch.First)));
|
||||
Assert.That(GetButtonLabel(second), Does.Contain(
|
||||
DefenseTower.GetBranchName(tower.Kind, DefenseTowerBranch.Second)));
|
||||
Assert.That(GetButtonLabel(first), Does.Not.Contain("First"));
|
||||
Assert.That(GetButtonLabel(second), Does.Not.Contain("Second"));
|
||||
|
||||
BeginHudKeyboardInput();
|
||||
yield return PressHudKey(Key.J);
|
||||
Assert.That(hudTestHud.SelectedUpgradeBranch, Is.EqualTo(DefenseTowerBranch.First));
|
||||
Assert.That(tower.Tier, Is.EqualTo(3), "J selects a branch; it does not upgrade.");
|
||||
yield return ReleaseHudKeys();
|
||||
yield return PressHudKey(Key.K);
|
||||
Assert.That(hudTestHud.SelectedUpgradeBranch, Is.EqualTo(DefenseTowerBranch.Second));
|
||||
Assert.That(tower.Tier, Is.EqualTo(3), "K selects a branch; it does not upgrade.");
|
||||
FindButton("Upgrade Selected Tower").onClick.Invoke();
|
||||
Assert.That(tower.Tier, Is.EqualTo(4));
|
||||
Assert.That(tower.Branch, Is.EqualTo(DefenseTowerBranch.Second));
|
||||
yield return ReleaseHudKeys();
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator FieldRelicModalUsesTheExistingReadableRelicLabel()
|
||||
{
|
||||
yield return InitializeHudTestWorld(longRun: true);
|
||||
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
|
||||
FieldInfo choicesField = typeof(Defense25DComparison).GetField(
|
||||
"earnedRelicChoices", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.That(choicesField, Is.Not.Null);
|
||||
var choices = choicesField.GetValue(world) as Queue<PassiveRelicId[]>;
|
||||
Assert.That(choices, Is.Not.Null);
|
||||
choices.Enqueue(new[] { PassiveRelicId.TwinHornTalisman });
|
||||
hudTestHud.RefreshState();
|
||||
|
||||
string label = GetButtonLabel(FindButton("Choice 1"));
|
||||
Assert.That(label, Does.Contain("TWIN HORN TALISMAN"));
|
||||
Assert.That(label, Does.Contain("ordinary bump deals 2x damage"));
|
||||
Assert.That(label, Does.Not.Contain(nameof(PassiveRelicId.TwinHornTalisman)));
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator HudPauseKeysRespectPendingChoiceAndRNeverRestartsFromHud()
|
||||
{
|
||||
yield return InitializeHudTestWorld(longRun: true);
|
||||
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
|
||||
world.AdvanceRunClock(world.PreparationRemaining + .1f);
|
||||
world.ReadInput = true;
|
||||
BeginHudKeyboardInput();
|
||||
int generation = world.RunGeneration;
|
||||
|
||||
yield return PressHudKey(Key.R);
|
||||
Assert.That(world.RunGeneration, Is.EqualTo(generation),
|
||||
"The HUD must not bind R to an immediate run reset.");
|
||||
yield return ReleaseHudKeys();
|
||||
|
||||
yield return PressHudKey(Key.P);
|
||||
Assert.That(world.Paused, Is.True);
|
||||
yield return ReleaseHudKeys();
|
||||
yield return PressHudKey(Key.Escape);
|
||||
Assert.That(world.Paused, Is.False);
|
||||
yield return ReleaseHudKeys();
|
||||
|
||||
hudTestRewardConstants = Object.Instantiate(
|
||||
AssetDatabase.LoadAssetAtPath<Defense25DRewardConstants>(
|
||||
"Assets/_Project/Constants/Defense25DComparison/RewardConstants.asset"));
|
||||
hudTestModifier = ScriptableObject.CreateInstance<ModifierDefinition>();
|
||||
hudTestModifier.Configure("hud.modal-priority", "Modal Priority",
|
||||
CharacterStat.MoveSpeed, ModifierOperation.Increased, .1f, 1);
|
||||
hudTestProgression = new Defense25DProgression();
|
||||
hudTestProgression.Bind(world.Player, hudTestRewardConstants, new[] { hudTestModifier },
|
||||
new Defense25DTowerModifiers(hudTestRewardConstants), _ => false);
|
||||
Assert.That(hudTestProgression.QueueReward(Defense25DRewardSource.LevelUp,
|
||||
"hud-modal-priority"), Is.True);
|
||||
hudTestHud.Bind(world, hudTestProgression, null);
|
||||
Assert.That(FindButton("Choice 1").gameObject.activeInHierarchy, Is.True);
|
||||
|
||||
yield return PressHudKey(Key.P);
|
||||
Assert.That(world.Paused, Is.False, "A pending choice takes priority over pause input.");
|
||||
Assert.That(FindButton("Choice 1").gameObject.activeInHierarchy, Is.True);
|
||||
yield return ReleaseHudKeys();
|
||||
yield return PressHudKey(Key.Escape);
|
||||
Assert.That(world.Paused, Is.False, "Escape must not steal input from a choice modal.");
|
||||
Assert.That(FindButton("Choice 1").gameObject.activeInHierarchy, Is.True);
|
||||
yield return ReleaseHudKeys();
|
||||
|
||||
yield return PressHudKey(Key.A);
|
||||
Assert.That(FindButton("Choice 1").gameObject.activeInHierarchy, Is.True,
|
||||
"Holding A must not resolve or leak through the open choice modal.");
|
||||
Assert.That(hudTestProgression.CurrentChoices, Is.Not.Empty);
|
||||
Assert.That(GetButtonLabel(FindButton("Choice 1")), Does.Contain("선택하면 즉시 적용"));
|
||||
Assert.That(GetButtonLabel(FindButton("Choice 1")), Does.Contain(
|
||||
hudTestProgression.CurrentChoices[0].DisplayData.Title));
|
||||
Assert.That(GetButtonLabel(FindButton("Choice 1")), Does.Contain(
|
||||
hudTestProgression.CurrentChoices[0].DisplayData.Description));
|
||||
Assert.That(GetButtonLabel(FindButton("Choice 1")), Does.Contain("등급"));
|
||||
string rarityLabel = hudTestProgression.CurrentChoices[0].Rarity switch
|
||||
{
|
||||
Defense25DRewardRarity.Common => "일반",
|
||||
Defense25DRewardRarity.Rare => "희귀",
|
||||
Defense25DRewardRarity.Epic => "에픽",
|
||||
_ => "등급 없음",
|
||||
};
|
||||
Assert.That(GetButtonLabel(FindButton("Choice 1")),
|
||||
Does.Contain($"등급 · {rarityLabel}"));
|
||||
Assert.That(CountOccurrences(GetButtonLabel(FindButton("Choice 1")),
|
||||
"선택하면 즉시 적용됩니다"), Is.EqualTo(1),
|
||||
"A reward description and the HUD must not duplicate the confirmation copy.");
|
||||
yield return ReleaseHudKeys();
|
||||
Assert.That(FindButton("Choice 1").gameObject.activeInHierarchy, Is.True,
|
||||
"Releasing A after a modal is open must not activate a choice.");
|
||||
Assert.That(hudTestHud.IsGameplayInputBlocked, Is.True,
|
||||
"The pending choice continues to own gameplay input after A is released.");
|
||||
FindButton("Choice 1").onClick.Invoke();
|
||||
Assert.That(hudTestProgression.HasPendingLevelChoice, Is.False,
|
||||
"Choosing the reward must synchronously resolve its pending level choice.");
|
||||
Assert.That(FindButton("Choice 1").gameObject.activeInHierarchy, Is.False,
|
||||
"The HUD choice modal must refresh when Progression raises Changed.");
|
||||
Assert.That(hudTestHud.IsGameplayInputBlocked, Is.False,
|
||||
"Gameplay input is released after the pending choice resolves.");
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ResultPanelShowsLedgerProgressionAndMainRunSeedAndEndReason()
|
||||
{
|
||||
yield return InitializeHudTestWorld(longRun: true);
|
||||
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
|
||||
world.AdvanceRunClock(world.PreparationRemaining + 0.1f);
|
||||
|
||||
hudTestRewardConstants = Object.Instantiate(
|
||||
AssetDatabase.LoadAssetAtPath<Defense25DRewardConstants>(
|
||||
"Assets/_Project/Constants/Defense25DComparison/RewardConstants.asset"));
|
||||
ModifierDefinition catalogModifier = FindFirstLevelUpModifier();
|
||||
bool hasCatalogModifier = catalogModifier != null;
|
||||
if (hasCatalogModifier)
|
||||
hudTestModifier = Object.Instantiate(catalogModifier);
|
||||
else
|
||||
{
|
||||
hudTestModifier = ScriptableObject.CreateInstance<ModifierDefinition>();
|
||||
hudTestModifier.Configure("hud.result", "Result modifier",
|
||||
CharacterStat.MoveSpeed, ModifierOperation.Increased, .1f, 1);
|
||||
}
|
||||
hudTestProgression = new Defense25DProgression();
|
||||
hudTestProgression.Bind(world.Player, hudTestRewardConstants, new[] { hudTestModifier },
|
||||
new Defense25DTowerModifiers(hudTestRewardConstants), _ => false);
|
||||
hudTestHud.Bind(world, hudTestProgression, null);
|
||||
Assert.That(hudTestModifier.TryApplyToModel(world.Player, null), Is.True);
|
||||
SetPrivateResult(world, "RunFailed", true);
|
||||
typeof(Defense25DComparison).GetField("runEndReason",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic).SetValue(world,
|
||||
Defense25DRunEndReason.FinalBossArrived);
|
||||
hudTestHud.RefreshState();
|
||||
|
||||
string details = FindText("Run Result Details").text;
|
||||
Assert.That(FindButton("Restart Run").gameObject.activeInHierarchy, Is.True);
|
||||
Assert.That(details, Does.Contain("최종 보스·종료 사유: 최종 보스 도착"));
|
||||
Assert.That(details, Does.Contain($"공급 {world.Ledger.SuppliedCount}"));
|
||||
Assert.That(details, Does.Contain($"처치 {world.Ledger.KillEventCount}"));
|
||||
Assert.That(details, Does.Contain($"시드 {world.CurrentRunSeed}"));
|
||||
Assert.That(details, Does.Contain("미션 성공 0 · 실패 0"));
|
||||
Assert.That(details, Does.Contain("최종 레벨 1"));
|
||||
Assert.That(details, Does.Contain("현재 플레이어 효과:"));
|
||||
if (hasCatalogModifier)
|
||||
Assert.That(details, Does.Contain(hudTestModifier.GetKoreanDisplayName()));
|
||||
else
|
||||
Assert.That(details, Does.Contain("기타 활성 효과 1종"));
|
||||
Assert.That(details, Does.Not.Contain(hudTestModifier.ModifierId));
|
||||
Assert.That(details, Does.Not.Contain(nameof(Defense25DSpawnSource.FinalBoss)));
|
||||
|
||||
typeof(Defense25DComparison).GetField("runEndReason",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic).SetValue(world,
|
||||
Defense25DRunEndReason.None);
|
||||
SetPrivateResult(world, "RunCleared", true);
|
||||
hudTestHud.RefreshState();
|
||||
Assert.That(FindText("Run Result Details").text,
|
||||
Does.Contain("최종 보스·종료 사유: 최종 보스 처치"));
|
||||
|
||||
SetPrivateResult(world, "RunFailed", true);
|
||||
typeof(Defense25DComparison).GetField("runEndReason",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic).SetValue(world,
|
||||
Defense25DRunEndReason.BaseDestroyed);
|
||||
hudTestHud.RefreshState();
|
||||
Assert.That(FindText("Run Result Details").text,
|
||||
Does.Contain("최종 보스·종료 사유: 거점 파괴"));
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ActivePausedRestartCanCancelAndConfirmOnlyOnce()
|
||||
{
|
||||
yield return InitializeHudTestWorld(longRun: false);
|
||||
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
|
||||
world.SetPaused(true);
|
||||
hudTestHud.RefreshState();
|
||||
int generation = world.RunGeneration;
|
||||
Button restart = FindButton("Restart Run");
|
||||
|
||||
restart.onClick.Invoke();
|
||||
restart.onClick.Invoke();
|
||||
Assert.That(IsRestartConfirmationVisible(), Is.True);
|
||||
Assert.That(world.RunGeneration, Is.EqualTo(generation));
|
||||
FindButton("Cancel Restart").onClick.Invoke();
|
||||
Assert.That(IsRestartConfirmationVisible(), Is.False);
|
||||
Assert.That(world.RunGeneration, Is.EqualTo(generation));
|
||||
|
||||
restart.onClick.Invoke();
|
||||
Button confirm = FindButton("Confirm Restart");
|
||||
confirm.onClick.Invoke();
|
||||
confirm.onClick.Invoke();
|
||||
Assert.That(world.RunGeneration, Is.EqualTo(generation + 1));
|
||||
Assert.That(IsRestartConfirmationVisible(), Is.False);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator PendingRewardBlocksRestartUntilChoiceIsResolved()
|
||||
{
|
||||
yield return InitializeHudTestWorld(longRun: false);
|
||||
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
|
||||
hudTestRewardConstants = ScriptableObject.CreateInstance<Defense25DRewardConstants>();
|
||||
hudTestRewardConstants.TowerModifierCatalog = Array.Empty<Defense25DTowerModifierDefinition>();
|
||||
hudTestModifier = ScriptableObject.CreateInstance<ModifierDefinition>();
|
||||
hudTestModifier.Configure("hud.pending", "hud.pending", CharacterStat.MoveSpeed,
|
||||
ModifierOperation.Increased, .1f, 1);
|
||||
hudTestProgression = new Defense25DProgression();
|
||||
hudTestProgression.Bind(world.Player, hudTestRewardConstants, new[] { hudTestModifier },
|
||||
new Defense25DTowerModifiers(hudTestRewardConstants), _ => false);
|
||||
Assert.That(hudTestProgression.QueueReward(Defense25DRewardSource.LevelUp,
|
||||
"hud-pending-restart"), Is.True);
|
||||
hudTestHud.Bind(world, hudTestProgression, null);
|
||||
world.SetPaused(true);
|
||||
int generation = world.RunGeneration;
|
||||
|
||||
FindButton("Restart Run").onClick.Invoke();
|
||||
Assert.That(IsRestartConfirmationVisible(), Is.False);
|
||||
Assert.That(world.RunGeneration, Is.EqualTo(generation));
|
||||
|
||||
Assert.That(hudTestProgression.TryChooseModifier(0), Is.True);
|
||||
hudTestHud.RefreshState();
|
||||
FindButton("Restart Run").onClick.Invoke();
|
||||
Assert.That(IsRestartConfirmationVisible(), Is.True);
|
||||
Assert.That(world.RunGeneration, Is.EqualTo(generation));
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ConfirmedLongRunRestartRebindsMinimapToTheNewLayout()
|
||||
{
|
||||
yield return InitializeHudTestWorld(longRun: true);
|
||||
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
|
||||
world.AdvanceRunClock(world.PreparationRemaining + 0.1f);
|
||||
world.SetPaused(true);
|
||||
Defense25DRunLayout previousLayout = world.RunLayout;
|
||||
int previousGeneration = world.RunGeneration;
|
||||
|
||||
FindButton("Restart Run").onClick.Invoke();
|
||||
Assert.That(IsRestartConfirmationVisible(), Is.True);
|
||||
FindButton("Confirm Restart").onClick.Invoke();
|
||||
|
||||
Assert.That(world.RunGeneration, Is.EqualTo(previousGeneration + 1));
|
||||
Assert.That(world.RunLayout, Is.Not.SameAs(previousLayout));
|
||||
hudTestHud.RefreshState();
|
||||
FieldInfo cachedLayoutSeed = typeof(Defense25DRunHud).GetField("layoutSeed",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.That(cachedLayoutSeed.GetValue(hudTestHud),
|
||||
Is.EqualTo(world.RunLayout.LayoutSeed),
|
||||
"Restart must rebuild the HUD minimap from the newly generated layout.");
|
||||
Assert.That(hudTestHud.IsRestartConfirmationPending, Is.False);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ArtifactDropCandidateIsShownAndChosenThroughHudModal()
|
||||
{
|
||||
yield return InitializeHudTestWorld(longRun: true);
|
||||
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
|
||||
world.AdvanceRunClock(20f);
|
||||
Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Running));
|
||||
world.AdvanceRunClock(60f);
|
||||
Defense25DArtifactDropState drop = world.ArtifactDropStates[0];
|
||||
world.Player.ResetAt(drop.Position);
|
||||
Assert.That(world.TryInteractWithArtifactDrop(drop.DropId), Is.True);
|
||||
Assert.That(world.CurrentArtifactDropCandidates, Has.Count.EqualTo(2));
|
||||
|
||||
hudTestHud.RefreshState();
|
||||
Assert.That(FindButton("Choice 1").gameObject.activeInHierarchy, Is.True);
|
||||
Assert.That(FindButton("Choice 2").gameObject.activeInHierarchy, Is.True);
|
||||
FindButton("Choice 1").onClick.Invoke();
|
||||
|
||||
Assert.That(world.HasPendingArtifactDropChoice, Is.False);
|
||||
Assert.That(world.Loadout.OwnedArtifactCount, Is.EqualTo(1));
|
||||
Assert.That(FindButton("Choice 1").gameObject.activeInHierarchy, Is.False);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator PortalRowsSeparateTelegraphOpenAndStoppedUnopenedStates()
|
||||
{
|
||||
yield return InitializeHudTestWorld(longRun: true);
|
||||
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
|
||||
world.AdvanceRunClock(130f);
|
||||
hudTestHud.RefreshState();
|
||||
|
||||
Text thirdPortal = FindText("Portal 3");
|
||||
Assert.That(thirdPortal.text, Does.Contain("예고 00:10"));
|
||||
Assert.That(thirdPortal.text, Does.Contain("위협"));
|
||||
|
||||
world.AdvanceRunClock(10f);
|
||||
hudTestHud.RefreshState();
|
||||
Assert.That(world.WaveDirector.IsPortalOpen(2), Is.True);
|
||||
Assert.That(thirdPortal.text, Does.Not.Contain("미개방"));
|
||||
|
||||
world.WaveDirector.StopAllSupply();
|
||||
hudTestHud.RefreshState();
|
||||
Text twelfthPortal = FindText("Portal 12");
|
||||
Assert.That(FindText("Summary").text, Does.Contain("다음 강적 공급 중단"));
|
||||
Assert.That(thirdPortal.text, Does.Contain("공급 중단"));
|
||||
Assert.That(thirdPortal.text, Does.Not.Contain("미개방"));
|
||||
Assert.That(twelfthPortal.text, Does.Contain("미개방"));
|
||||
Assert.That(twelfthPortal.text, Does.Contain("공급 중단"));
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator EarlyCallButtonUsesWorldCommandAndCurrencyShowsSpendableAndKillTotalsSeparately()
|
||||
{
|
||||
yield return InitializeHudTestWorld(longRun: true);
|
||||
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
|
||||
|
||||
world.AdvanceRunClock(37f);
|
||||
hudTestHud.RefreshState();
|
||||
Button earlyCall = FindButton("Early Call Next Wave");
|
||||
Defense25DWavePlan currentPlan = world.WaveDirector.GetCurrentPlan();
|
||||
Assert.That(currentPlan, Is.Not.Null);
|
||||
Assert.That(world.CombatElapsed,
|
||||
Is.LessThan(currentPlan.ActualStart + world.RunDefinition.WaveDirectorConstants.SpawnWindowSeconds));
|
||||
Assert.That(earlyCall.interactable, Is.False,
|
||||
"Early call must stay disabled during the current wave spawn window.");
|
||||
Assert.That(FindText("Summary").text, Does.Contain("스폰 창"));
|
||||
|
||||
world.AdvanceRunClock(1f);
|
||||
Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Running));
|
||||
Assert.That(world.NotifyLongRunBossActivated(), Is.True);
|
||||
|
||||
world.TowerManager.AddCurrency(63);
|
||||
hudTestHud.RefreshState();
|
||||
Text summary = FindText("Summary");
|
||||
Assert.That(summary.text, Does.Contain($"건설 재화 {world.TowerManager.ConstructionCurrency}"));
|
||||
Assert.That(summary.text, Does.Contain($"처치 재화 {world.KillCurrency}"));
|
||||
|
||||
int currencyBeforeCall = world.TowerManager.ConstructionCurrency;
|
||||
Assert.That(earlyCall.gameObject.activeInHierarchy, Is.True);
|
||||
Assert.That(earlyCall.interactable, Is.True);
|
||||
Assert.That(FindText("Summary").text, Does.Contain("Micro Break"));
|
||||
earlyCall.onClick.Invoke();
|
||||
Assert.That(world.TowerManager.ConstructionCurrency, Is.GreaterThan(currencyBeforeCall));
|
||||
Assert.That(FindText("HUD Action Message").text, Does.Contain("조기 호출"));
|
||||
Assert.That(FindText("Summary").text, Does.Contain("Micro Break 00:00"));
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator RunSummaryShowsNextWaveThreatTelegraphAndBreakState()
|
||||
{
|
||||
yield return InitializeHudTestWorld(longRun: true);
|
||||
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
|
||||
world.AdvanceRunClock(37f);
|
||||
hudTestHud.RefreshState();
|
||||
Assert.That(FindText("Summary").text, Does.Contain("스폰 창"));
|
||||
|
||||
world.AdvanceRunClock(1f);
|
||||
hudTestHud.RefreshState();
|
||||
|
||||
Defense25DWavePlan current = world.WaveDirector.GetCurrentPlan();
|
||||
Defense25DWavePlan next = world.WaveDirector.GetNextPlanPreview();
|
||||
Assert.That(current, Is.Not.Null);
|
||||
Assert.That(next, Is.Not.Null);
|
||||
|
||||
string summary = FindText("Summary").text;
|
||||
Assert.That(summary, Does.Contain($"다음 Wave {next.WaveId}"));
|
||||
Assert.That(summary, Does.Contain($"다음 위협 {next.ThreatBudget:0.#}"));
|
||||
Assert.That(summary, Does.Contain("예고"));
|
||||
Assert.That(summary, Does.Contain("Micro Break"));
|
||||
|
||||
Assert.That(world.WaveDirector.TryGetNextStrongEventPreview(
|
||||
out Defense25DSpawnSource strongSource, out float nominalStrongStart), Is.True);
|
||||
Assert.That(strongSource, Is.EqualTo(Defense25DSpawnSource.GlobalElite));
|
||||
world.WaveDirector.RegisterReliefWindow("hud-next-strong-relief", nominalStrongStart, 12f);
|
||||
Assert.That(world.WaveDirector.TryGetNextStrongEventPreview(out strongSource,
|
||||
out float actualStrongStart), Is.True);
|
||||
Assert.That(actualStrongStart, Is.GreaterThan(nominalStrongStart),
|
||||
"The HUD preview must use the Director's relief-adjusted actual time.");
|
||||
hudTestHud.RefreshState();
|
||||
int strongCountdownSeconds = Mathf.Max(0,
|
||||
Mathf.CeilToInt(actualStrongStart - world.CombatElapsed));
|
||||
string expectedStrongCountdown =
|
||||
$"{strongCountdownSeconds / 60:00}:{strongCountdownSeconds % 60:00}";
|
||||
Assert.That(FindText("Summary").text,
|
||||
Does.Contain($"다음 강적 엘리트 {expectedStrongCountdown}"));
|
||||
|
||||
world.WaveDirector.RegisterReliefWindow("hud-summary-relief",
|
||||
world.CombatElapsed, 5f);
|
||||
hudTestHud.RefreshState();
|
||||
Assert.That(FindText("Summary").text, Does.Contain("구호 시간"));
|
||||
Assert.That(FindButton("Early Call Next Wave").interactable, Is.False,
|
||||
"Early call must be disabled while the Director is in a relief window.");
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator RunSummaryUsesStrategyBreakScheduleWhenConfigured()
|
||||
{
|
||||
yield return InitializeHudTestWorld(longRun: true, strategyBreakEvery: 1);
|
||||
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
|
||||
world.AdvanceRunClock(38f);
|
||||
hudTestHud.RefreshState();
|
||||
|
||||
Assert.That(world.WaveDirector.GetCurrentPlan().WaveId, Is.EqualTo(1));
|
||||
Assert.That(FindText("Summary").text, Does.Contain("Strategy Break 00:15"));
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator MissionRowsShowRuntimeStateAndStartThroughWorldCommand()
|
||||
{
|
||||
yield return InitializeHudTestWorld(longRun: true, allPortalsOpen: true);
|
||||
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
|
||||
Assert.That(world.PersonalMissionStates, Has.Count.EqualTo(3));
|
||||
world.AdvanceRunClock(38f);
|
||||
hudTestHud.RefreshState();
|
||||
|
||||
Button start = FindButton("Start Mission 1");
|
||||
Assert.That(start.gameObject.activeInHierarchy, Is.True);
|
||||
Assert.That(start.interactable, Is.True);
|
||||
start.onClick.Invoke();
|
||||
|
||||
Assert.That(world.PersonalMissionStates[0].Status,
|
||||
Is.EqualTo(Defense25DPersonalMissionStatus.Active));
|
||||
Assert.That(FindText("Mission 1").text, Does.Contain("진행"));
|
||||
Assert.That(start.gameObject.activeInHierarchy, Is.False);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator VictoryAndDefeatRestartImmediately()
|
||||
{
|
||||
yield return InitializeHudTestWorld(longRun: false);
|
||||
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
|
||||
int generation = world.RunGeneration;
|
||||
SetPrivateResult(world, "RunCleared", true);
|
||||
hudTestHud.RefreshState();
|
||||
FindButton("Restart Run").onClick.Invoke();
|
||||
Assert.That(world.RunGeneration, Is.EqualTo(generation + 1));
|
||||
Assert.That(IsRestartConfirmationVisible(), Is.False);
|
||||
|
||||
generation = world.RunGeneration;
|
||||
SetPrivateResult(world, "RunFailed", true);
|
||||
hudTestHud.RefreshState();
|
||||
FindButton("Restart Run").onClick.Invoke();
|
||||
Assert.That(world.RunGeneration, Is.EqualTo(generation + 1));
|
||||
Assert.That(IsRestartConfirmationVisible(), Is.False);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTearDown]
|
||||
public IEnumerator DestroyViewTestObjects()
|
||||
{
|
||||
if (hudTestKeyboard != null && hudTestKeyboard.added)
|
||||
{
|
||||
InputSystem.QueueStateEvent(hudTestKeyboard, new KeyboardState());
|
||||
InputSystem.RemoveDevice(hudTestKeyboard);
|
||||
}
|
||||
hudTestKeyboard = null;
|
||||
if (hudTestObject != null)
|
||||
Object.Destroy(hudTestObject);
|
||||
if (hudTestWorldObject != null)
|
||||
Object.Destroy(hudTestWorldObject);
|
||||
if (hudTestSettings != null)
|
||||
Object.DestroyImmediate(hudTestSettings);
|
||||
if (hudTestRunDefinition != null)
|
||||
Object.DestroyImmediate(hudTestRunDefinition);
|
||||
if (hudTestWaveConstants != null)
|
||||
Object.DestroyImmediate(hudTestWaveConstants);
|
||||
if (hudTestRewardConstants != null)
|
||||
Object.DestroyImmediate(hudTestRewardConstants);
|
||||
if (hudTestModifier != null)
|
||||
Object.DestroyImmediate(hudTestModifier);
|
||||
hudTestHud = null;
|
||||
hudTestObject = null;
|
||||
hudTestWorldObject = null;
|
||||
hudTestSettings = null;
|
||||
hudTestRunDefinition = null;
|
||||
hudTestWaveConstants = null;
|
||||
hudTestProgression = null;
|
||||
hudTestRewardConstants = null;
|
||||
hudTestModifier = null;
|
||||
if (hudInputSettingsCaptured)
|
||||
{
|
||||
InputSystem.settings.backgroundBehavior = previousBackgroundBehavior;
|
||||
InputSystem.settings.editorInputBehaviorInPlayMode =
|
||||
previousEditorInputBehavior;
|
||||
Application.runInBackground = previousRunInBackground;
|
||||
hudInputSettingsCaptured = false;
|
||||
}
|
||||
Time.timeScale = previousTimeScale;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
private Defense25DRunHud hudTestHud;
|
||||
private Keyboard hudTestKeyboard;
|
||||
|
||||
private IEnumerator InitializeHudTestWorld(bool longRun, bool allPortalsOpen = false,
|
||||
int strategyBreakEvery = 0)
|
||||
{
|
||||
Time.timeScale = 0f;
|
||||
hudTestSettings = Object.Instantiate(AssetDatabase.LoadAssetAtPath<Defense25DComparisonSettings>(
|
||||
"Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset"));
|
||||
hudTestWorldObject = new GameObject("Run HUD interaction world");
|
||||
Defense25DComparison world = hudTestWorldObject.AddComponent<Defense25DComparison>();
|
||||
yield return null;
|
||||
if (longRun)
|
||||
{
|
||||
hudTestRunDefinition = Object.Instantiate(
|
||||
AssetDatabase.LoadAssetAtPath<Defense25DRunDefinition>(
|
||||
"Assets/_Project/Constants/Defense25DComparison/LongDefenseRun.asset"));
|
||||
if (strategyBreakEvery > 0)
|
||||
{
|
||||
hudTestWaveConstants = Object.Instantiate(
|
||||
hudTestRunDefinition.WaveDirectorConstants);
|
||||
hudTestWaveConstants.StrategyBreakEvery = strategyBreakEvery;
|
||||
hudTestRunDefinition.WaveDirectorConstants = hudTestWaveConstants;
|
||||
}
|
||||
hudTestRunDefinition.RewardConstants = AssetDatabase.LoadAssetAtPath<Defense25DRewardConstants>(
|
||||
"Assets/_Project/Constants/Defense25DComparison/RewardConstants.asset");
|
||||
EnemyDefinition testSpecialEnemy = AssetDatabase.LoadAssetAtPath<EnemyDefinition>(
|
||||
"Assets/_Project/Constants/Enemies/Skeleton.asset");
|
||||
Assert.That(testSpecialEnemy, Is.Not.Null,
|
||||
"The long-run HUD fixture requires a valid source enemy definition.");
|
||||
hudTestRunDefinition.GlobalEliteDefinition = testSpecialEnemy;
|
||||
hudTestRunDefinition.GlobalMidBossDefinition = testSpecialEnemy;
|
||||
hudTestRunDefinition.FinalBossDefinition = testSpecialEnemy;
|
||||
hudTestRunDefinition.MonsterSourceMotionCatalog =
|
||||
AssetDatabase.LoadAssetAtPath<Defense25DMonsterSourceMotionCatalog>(
|
||||
"Assets/_Project/Constants/Defense25DComparison/MonsterSourceMotions.asset");
|
||||
if (allPortalsOpen)
|
||||
{
|
||||
Defense25DPortalDefinition[] portals =
|
||||
(Defense25DPortalDefinition[])hudTestRunDefinition.PortalDefinitions.Clone();
|
||||
for (int i = 0; i < portals.Length; i++)
|
||||
portals[i].OpenAt = 0f;
|
||||
hudTestRunDefinition.PortalDefinitions = portals;
|
||||
}
|
||||
Assert.That(hudTestRunDefinition.MonsterSourceMotionCatalog, Is.Not.Null);
|
||||
Assert.That(hudTestRunDefinition.Validate(out string validationError), Is.True,
|
||||
validationError);
|
||||
world.InitializeLongRun(hudTestSettings, hudTestRunDefinition);
|
||||
}
|
||||
else
|
||||
world.Initialize(hudTestSettings);
|
||||
world.AutoSpawn = false;
|
||||
world.ReadInput = false;
|
||||
hudTestObject = new GameObject("Run HUD interaction under test");
|
||||
hudTestHud = hudTestObject.AddComponent<Defense25DRunHud>();
|
||||
hudTestHud.Bind(world, null, null);
|
||||
FieldInfo runHudField = typeof(Defense25DComparison).GetField("runHud",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.That(runHudField, Is.Not.Null,
|
||||
"The HUD input fixture must use the world's actual presentation binding.");
|
||||
runHudField.SetValue(world, hudTestHud);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
private Button FindButton(string name)
|
||||
{
|
||||
foreach (Button button in hudTestObject.GetComponentsInChildren<Button>(true))
|
||||
if (button.name == name)
|
||||
return button;
|
||||
Assert.Fail($"HUD button '{name}' was not created.");
|
||||
return null;
|
||||
}
|
||||
|
||||
private Text FindText(string name)
|
||||
{
|
||||
foreach (Text text in hudTestObject.GetComponentsInChildren<Text>(true))
|
||||
if (text.name == name)
|
||||
return text;
|
||||
Assert.Fail($"HUD text '{name}' was not created.");
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string GetButtonLabel(Button button)
|
||||
{
|
||||
Text label = button != null ? button.GetComponentInChildren<Text>(true) : null;
|
||||
return label != null ? label.text : string.Empty;
|
||||
}
|
||||
|
||||
private static int CountOccurrences(string source, string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(source) || string.IsNullOrEmpty(value))
|
||||
return 0;
|
||||
return (source.Length - source.Replace(value, string.Empty).Length) / value.Length;
|
||||
}
|
||||
|
||||
private void BeginHudKeyboardInput()
|
||||
{
|
||||
if (hudTestKeyboard == null || !hudTestKeyboard.added)
|
||||
{
|
||||
hudInputSettingsCaptured = true;
|
||||
previousBackgroundBehavior = InputSystem.settings.backgroundBehavior;
|
||||
previousEditorInputBehavior =
|
||||
InputSystem.settings.editorInputBehaviorInPlayMode;
|
||||
previousRunInBackground = Application.runInBackground;
|
||||
InputSystem.settings.backgroundBehavior =
|
||||
InputSettings.BackgroundBehavior.IgnoreFocus;
|
||||
InputSystem.settings.editorInputBehaviorInPlayMode =
|
||||
InputSettings.EditorInputBehaviorInPlayMode.AllDeviceInputAlwaysGoesToGameView;
|
||||
Application.runInBackground = true;
|
||||
hudTestKeyboard = InputSystem.AddDevice<Keyboard>();
|
||||
hudTestKeyboard.MakeCurrent();
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator PressHudKey(Key key)
|
||||
{
|
||||
InputSystem.QueueStateEvent(hudTestKeyboard, new KeyboardState(key));
|
||||
yield return null;
|
||||
Assert.That(hudTestKeyboard[key].isPressed, Is.True,
|
||||
$"Queued {key} input must reach the synthetic keyboard on frame {Time.frameCount}.");
|
||||
Assert.That(Keyboard.current, Is.SameAs(hudTestKeyboard),
|
||||
$"HUD input must read the same synthetic keyboard that received {key}.");
|
||||
Assert.That(hudTestKeyboard[key].wasPressedThisFrame, Is.True,
|
||||
$"Queued {key} press edge must be visible to HUD LateUpdate on frame {Time.frameCount}.");
|
||||
|
||||
// HUD key handling is in LateUpdate. A UnityTest resumed from `yield return null`
|
||||
// can assert during Update before that LateUpdate has consumed the press edge.
|
||||
// Resume one frame later so caller assertions test the resulting HUD state.
|
||||
yield return null;
|
||||
}
|
||||
|
||||
private IEnumerator ReleaseHudKeys()
|
||||
{
|
||||
InputSystem.QueueStateEvent(hudTestKeyboard, new KeyboardState());
|
||||
yield return null;
|
||||
Assert.That(hudTestKeyboard.anyKey.isPressed, Is.False);
|
||||
Assert.That(Keyboard.current, Is.SameAs(hudTestKeyboard),
|
||||
"The synthetic keyboard that received key releases must remain current.");
|
||||
|
||||
// The HUD clears its held-A safety latch in LateUpdate after observing release.
|
||||
// Let that frame finish before the caller checks the post-modal input state.
|
||||
yield return null;
|
||||
}
|
||||
|
||||
private bool IsRestartConfirmationVisible()
|
||||
{
|
||||
foreach (Button button in hudTestObject.GetComponentsInChildren<Button>(true))
|
||||
if (button.name == "Confirm Restart")
|
||||
return button.gameObject.activeInHierarchy;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static ModifierDefinition FindFirstLevelUpModifier()
|
||||
{
|
||||
ModifierDefinition[] definitions = GameplayConstants.Current.LevelUp.ModifierDefinitions;
|
||||
if (definitions == null)
|
||||
return null;
|
||||
for (int i = 0; i < definitions.Length; i++)
|
||||
if (definitions[i] != null)
|
||||
return definitions[i];
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void SetPrivateResult(Defense25DComparison world, string property, bool value)
|
||||
{
|
||||
typeof(Defense25DComparison).GetProperty(property)
|
||||
.GetSetMethod(true).Invoke(world, new object[] { value });
|
||||
if (property == "RunCleared")
|
||||
typeof(Defense25DComparison).GetProperty("RunFailed")
|
||||
.GetSetMethod(true).Invoke(world, new object[] { false });
|
||||
else
|
||||
typeof(Defense25DComparison).GetProperty("RunCleared")
|
||||
.GetSetMethod(true).Invoke(world, new object[] { false });
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e583705e2dfd4717bf37b3d21a0276e4
|
||||
@@ -0,0 +1,624 @@
|
||||
using System.Collections;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Defense;
|
||||
using BumpCombat.Defense.Comparison;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.TestTools;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace BumpCombat.Tests
|
||||
{
|
||||
public sealed class Defense25DRunScenePlayModeTests
|
||||
{
|
||||
private const string ScenePath = "Assets/_Project/Scenes/Defense25DRun.unity";
|
||||
|
||||
private Scene loadedScene;
|
||||
private Scene previousActiveScene;
|
||||
private float previousTimeScale;
|
||||
private int previousScreenWidth;
|
||||
private int previousScreenHeight;
|
||||
private bool restoreScreenResolution;
|
||||
private float previousFixedDeltaTime;
|
||||
private bool restoreFixedDeltaTime;
|
||||
|
||||
[UnityTearDown]
|
||||
public IEnumerator TearDown()
|
||||
{
|
||||
if (previousActiveScene.IsValid() && previousActiveScene.isLoaded)
|
||||
SceneManager.SetActiveScene(previousActiveScene);
|
||||
|
||||
if (loadedScene.IsValid() && loadedScene.isLoaded)
|
||||
{
|
||||
AsyncOperation unload = SceneManager.UnloadSceneAsync(loadedScene);
|
||||
if (unload != null)
|
||||
yield return unload;
|
||||
}
|
||||
|
||||
loadedScene = default;
|
||||
previousActiveScene = default;
|
||||
Time.timeScale = previousTimeScale;
|
||||
if (restoreFixedDeltaTime)
|
||||
{
|
||||
Time.fixedDeltaTime = previousFixedDeltaTime;
|
||||
restoreFixedDeltaTime = false;
|
||||
}
|
||||
|
||||
if (restoreScreenResolution && previousScreenWidth > 0 && previousScreenHeight > 0)
|
||||
{
|
||||
Screen.SetResolution(previousScreenWidth, previousScreenHeight, FullScreenMode.Windowed);
|
||||
restoreScreenResolution = false;
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator SavedRunSceneLoadsLongRunHudAndConfirmedRestart()
|
||||
{
|
||||
previousTimeScale = Time.timeScale;
|
||||
previousActiveScene = SceneManager.GetActiveScene();
|
||||
AsyncOperation load = EditorSceneManager.LoadSceneAsyncInPlayMode(ScenePath,
|
||||
new LoadSceneParameters(LoadSceneMode.Additive));
|
||||
Assert.That(load, Is.Not.Null, $"Could not load saved scene {ScenePath}.");
|
||||
yield return load;
|
||||
|
||||
loadedScene = SceneManager.GetSceneByPath(ScenePath);
|
||||
Assert.That(loadedScene.IsValid() && loadedScene.isLoaded, Is.True,
|
||||
$"Scene did not reload from {ScenePath}.");
|
||||
SceneManager.SetActiveScene(loadedScene);
|
||||
yield return null;
|
||||
yield return null;
|
||||
|
||||
Defense25DRunSceneProfile profile = FindInScene<Defense25DRunSceneProfile>();
|
||||
Defense25DComparison world = FindInScene<Defense25DComparison>();
|
||||
Defense25DRunHud hud = FindInScene<Defense25DRunHud>();
|
||||
Assert.That(profile, Is.Not.Null);
|
||||
Assert.That(profile.RunDefinition, Is.Not.Null);
|
||||
Assert.That(profile.RunDefinition.PreparationSeconds, Is.EqualTo(20f).Within(.001f));
|
||||
Assert.That(world, Is.Not.Null);
|
||||
Assert.That(world.IsLongRun, Is.True, "The saved scene must enter through InitializeLongRun.");
|
||||
Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Preparing));
|
||||
Assert.That(world.PreparationRemaining, Is.GreaterThan(0f).And.LessThanOrEqualTo(20f));
|
||||
Assert.That(world.PortalCount, Is.EqualTo(12));
|
||||
Assert.That(world.RunLayout, Is.Not.Null);
|
||||
Assert.That(world.RunLayout.PadCount, Is.EqualTo(18));
|
||||
Assert.That(world.GetComponentsInChildren<Transform>(true)
|
||||
.Count(transform => transform.name.StartsWith("Tower build pad ")), Is.EqualTo(18));
|
||||
Assert.That(world.View, Is.Not.Null);
|
||||
Assert.That(hud, Is.Not.Null);
|
||||
SerializedObject serializedWorld = new(world);
|
||||
Assert.That(serializedWorld.FindProperty("runHud").objectReferenceValue, Is.SameAs(hud));
|
||||
Assert.That(hud.IsLayoutValid, Is.True);
|
||||
|
||||
world.AutoSpawn = false;
|
||||
world.ReadInput = false;
|
||||
int generationBeforeRestart = world.RunGeneration;
|
||||
|
||||
Button pause = FindButton(hud, "Pause Toggle");
|
||||
Button restart = FindButton(hud, "Restart Run");
|
||||
Assert.That(pause, Is.Not.Null);
|
||||
Assert.That(restart, Is.Not.Null);
|
||||
pause.onClick.Invoke();
|
||||
Assert.That(world.Paused, Is.True);
|
||||
restart.onClick.Invoke();
|
||||
Assert.That(hud.IsRestartConfirmationPending, Is.True,
|
||||
"An active run restart must wait for explicit confirmation.");
|
||||
Assert.That(world.RunGeneration, Is.EqualTo(generationBeforeRestart));
|
||||
|
||||
FindButton(hud, "Cancel Restart").onClick.Invoke();
|
||||
Assert.That(hud.IsRestartConfirmationPending, Is.False);
|
||||
Assert.That(world.RunGeneration, Is.EqualTo(generationBeforeRestart));
|
||||
|
||||
restart.onClick.Invoke();
|
||||
Assert.That(hud.IsRestartConfirmationPending, Is.True);
|
||||
FindButton(hud, "Confirm Restart").onClick.Invoke();
|
||||
Assert.That(hud.IsRestartConfirmationPending, Is.False);
|
||||
Assert.That(world.RunGeneration, Is.EqualTo(generationBeforeRestart + 1));
|
||||
Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Preparing));
|
||||
Assert.That(world.PreparationRemaining, Is.EqualTo(20f).Within(.001f));
|
||||
|
||||
world.AdvanceRunClock(20f);
|
||||
Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Running));
|
||||
Assert.That(world.PreparationRemaining, Is.EqualTo(0f).Within(.001f));
|
||||
Assert.That(world.CurrentGlobalWaveId, Is.GreaterThan(0));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator CapturesSavedRunHudAtFhdAndQhdScreenResolutions()
|
||||
{
|
||||
previousTimeScale = Time.timeScale;
|
||||
previousScreenWidth = Screen.width;
|
||||
previousScreenHeight = Screen.height;
|
||||
restoreScreenResolution = previousScreenWidth > 0 && previousScreenHeight > 0;
|
||||
previousActiveScene = SceneManager.GetActiveScene();
|
||||
|
||||
AsyncOperation load = EditorSceneManager.LoadSceneAsyncInPlayMode(ScenePath,
|
||||
new LoadSceneParameters(LoadSceneMode.Additive));
|
||||
Assert.That(load, Is.Not.Null, $"Could not load saved scene {ScenePath}.");
|
||||
yield return load;
|
||||
|
||||
loadedScene = SceneManager.GetSceneByPath(ScenePath);
|
||||
Assert.That(loadedScene.IsValid() && loadedScene.isLoaded, Is.True,
|
||||
$"Scene did not reload from {ScenePath}.");
|
||||
SceneManager.SetActiveScene(loadedScene);
|
||||
yield return null;
|
||||
yield return null;
|
||||
|
||||
Defense25DComparison world = FindInScene<Defense25DComparison>();
|
||||
Defense25DRunHud hud = FindInScene<Defense25DRunHud>();
|
||||
Assert.That(world, Is.Not.Null);
|
||||
Assert.That(hud, Is.Not.Null);
|
||||
Assert.That(hud.IsLayoutValid, Is.True);
|
||||
world.AutoSpawn = false;
|
||||
world.ReadInput = false;
|
||||
|
||||
string outputDirectory = System.Environment.GetEnvironmentVariable("BUMPCOMBAT_TASK14_CAPTURE_DIR");
|
||||
if (string.IsNullOrWhiteSpace(outputDirectory))
|
||||
outputDirectory = Path.Combine(Path.GetTempPath(), "BumpCombatTask14Captures");
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
|
||||
yield return CaptureAtResolution(outputDirectory, 1920, 1080);
|
||||
yield return CaptureAtResolution(outputDirectory, 2560, 1440);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator SavedSceneFastForwardsClockThroughWaveLedgerBossClearAndRestart()
|
||||
{
|
||||
yield return LoadSavedRunSceneForTransitionTest();
|
||||
Defense25DComparison world = FindInScene<Defense25DComparison>();
|
||||
Defense25DRunHud hud = FindInScene<Defense25DRunHud>();
|
||||
world.AutoSpawn = false;
|
||||
world.ReadInput = false;
|
||||
int generation = world.RunGeneration;
|
||||
|
||||
Assert.That(world.PortalCount, Is.EqualTo(12));
|
||||
Assert.That(world.CombatElapsed, Is.EqualTo(0f).Within(.001f));
|
||||
world.AdvanceRunClock(world.PreparationRemaining);
|
||||
Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Running));
|
||||
Assert.That(world.CombatElapsed, Is.EqualTo(0f).Within(.001f));
|
||||
world.AdvanceRunClock(720f);
|
||||
Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.BossActive),
|
||||
"The saved scene reaches the configured 720-second final boss on its own run timeline.");
|
||||
Assert.That(world.CombatElapsed, Is.EqualTo(720f).Within(.01f));
|
||||
Assert.That(world.CurrentGlobalWaveId, Is.EqualTo(27),
|
||||
"The 720-second saved-scene timeline should have supplied wave 27.");
|
||||
Assert.That(world.ArtifactDropStates.Count, Is.EqualTo(3),
|
||||
"The long-run profile owns three scheduled artifact chests.");
|
||||
Assert.That(world.ArtifactDropStates.All(drop =>
|
||||
drop.Status == Defense25DArtifactDropStatus.Available), Is.True,
|
||||
"All three chest ledger entries should be available by the 720-second boss event.");
|
||||
Defense25DEnemy finalBoss = world.Enemies.FirstOrDefault(enemy =>
|
||||
enemy.SpawnContext.Source == Defense25DSpawnSource.FinalBoss && enemy.IsAvailable);
|
||||
Assert.That(finalBoss, Is.Not.Null);
|
||||
Defense25DEnemy[] residualEnemies = world.Enemies
|
||||
.Where(enemy => enemy != finalBoss && enemy.IsAvailable).ToArray();
|
||||
Assert.That(residualEnemies, Is.Not.Empty,
|
||||
"At least one pre-boss enemy remains so the clearing phase is exercised separately.");
|
||||
|
||||
world.ResolveEnemy(finalBoss, DefenseEnemyResolution.Death);
|
||||
Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Clearing));
|
||||
Assert.That(residualEnemies.Any(enemy => enemy.IsAvailable), Is.True,
|
||||
"Final-boss death starts clearing while residual enemies remain.");
|
||||
|
||||
foreach (Defense25DEnemy residual in residualEnemies)
|
||||
if (residual != null && residual.IsAvailable)
|
||||
world.ResolveEnemy(residual, DefenseEnemyResolution.Death);
|
||||
|
||||
Defense25DProgression progression = world.Progression;
|
||||
hud.RefreshState();
|
||||
bool hudChoiceModalVisible = hud.GetComponentsInChildren<Transform>(true)
|
||||
.Any(transform => transform.name == "Run Choice Modal" && transform.gameObject.activeSelf);
|
||||
string missionStates = string.Join(";", world.PersonalMissionStates.Select(state =>
|
||||
$"{state.MissionId}:{state.Status}:{state.MissionInstanceId}:{state.RewardToken}"));
|
||||
TestContext.WriteLine(
|
||||
$"Clear gates after boss/residual commands: RemainingEnemies={world.RemainingEnemies}; " +
|
||||
$"ActiveLedgerEnemies={world.Enemies.Count(enemy => enemy.IsAvailable)}; " +
|
||||
$"HasPendingFieldRelicReward={world.HasPendingFieldRelicReward}; " +
|
||||
$"FieldRelicChoices={string.Join(",", world.PendingFieldRelicChoices)}; " +
|
||||
$"HasPendingArtifactDropChoice={world.HasPendingArtifactDropChoice}; " +
|
||||
$"CanFinishRunClearPresentation={world.CanFinishRunClearPresentation}; " +
|
||||
$"RewardQueuePending={progression?.RewardChoices?.PendingRewardCount ?? 0}; " +
|
||||
$"ProgressionLevelChoice={progression?.HasPendingLevelChoice ?? false}; " +
|
||||
$"CurrentChoiceCount={progression?.CurrentChoices.Count ?? 0}; HudChoiceModalVisible={hudChoiceModalVisible}; " +
|
||||
$"CurrentReward={progression?.CurrentRewardSource}:{progression?.CurrentRewardToken}; " +
|
||||
$"Missions={missionStates}; BossDefeated={!finalBoss.IsAvailable}; Phase={world.RunPhase}");
|
||||
Assert.That(world.RemainingEnemies, Is.Zero);
|
||||
|
||||
int resolvedFieldRelics = 0;
|
||||
while (world.HasPendingFieldRelicReward)
|
||||
{
|
||||
Assert.That(world.PendingFieldRelicChoices, Is.Not.Empty,
|
||||
"A pending field-relic gate must expose a real selectable choice.");
|
||||
Assert.That(world.ResolveFieldRelicChoice(world.PendingFieldRelicChoices[0]), Is.True,
|
||||
"Resolve the field-relic gate through its public choice command.");
|
||||
resolvedFieldRelics++;
|
||||
}
|
||||
|
||||
int resolvedProgressionChoices = 0;
|
||||
while (progression.RewardChoices.PendingRewardCount > 0)
|
||||
{
|
||||
int pendingBefore = progression.RewardChoices.PendingRewardCount;
|
||||
string rewardToken = progression.CurrentRewardToken;
|
||||
Assert.That(progression.TryChooseModifier(0, rewardToken), Is.True,
|
||||
$"Resolve the current {progression.CurrentRewardSource} reward through its public choice command.");
|
||||
Assert.That(progression.RewardChoices.PendingRewardCount, Is.LessThan(pendingBefore),
|
||||
"Each selected reward must leave the actual pending queue.");
|
||||
resolvedProgressionChoices++;
|
||||
}
|
||||
TestContext.WriteLine(
|
||||
$"Resolved live clear gates through public UI commands: progression={resolvedProgressionChoices}; " +
|
||||
$"fieldRelics={resolvedFieldRelics}; RemainingEnemies={world.RemainingEnemies}; " +
|
||||
$"missionRewardPending={world.PersonalMissionStates.Any(state => state.Status == Defense25DPersonalMissionStatus.RewardPending)}");
|
||||
|
||||
Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Victory));
|
||||
Assert.That(world.RunCleared, Is.True);
|
||||
Assert.That(world.IsRunning, Is.False);
|
||||
hud.RefreshState();
|
||||
|
||||
Button restart = FindButton(hud, "Restart Run");
|
||||
Assert.That(restart, Is.Not.Null);
|
||||
Assert.That(restart.interactable, Is.True,
|
||||
"The result-screen restart must be immediately available after Victory.");
|
||||
restart.onClick.Invoke();
|
||||
Assert.That(hud.IsRestartConfirmationPending, Is.False,
|
||||
"A result-screen restart does not open the in-progress confirmation dialog.");
|
||||
Assert.That(world.RunGeneration, Is.EqualTo(generation + 1));
|
||||
Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Preparing));
|
||||
Assert.That(world.PreparationRemaining, Is.EqualTo(20f).Within(.001f));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator SavedSceneFinalBossArrivalProducesDefeatAndImmediateRestart()
|
||||
{
|
||||
yield return LoadSavedRunSceneForTransitionTest();
|
||||
Defense25DComparison world = FindInScene<Defense25DComparison>();
|
||||
Defense25DRunHud hud = FindInScene<Defense25DRunHud>();
|
||||
world.AutoSpawn = false;
|
||||
world.ReadInput = false;
|
||||
int generation = world.RunGeneration;
|
||||
|
||||
world.AdvanceRunClock(world.RunDefinition.PreparationSeconds + 720f);
|
||||
Defense25DEnemy finalBoss = world.Enemies.FirstOrDefault(enemy =>
|
||||
enemy.SpawnContext.Source == Defense25DSpawnSource.FinalBoss && enemy.IsAvailable);
|
||||
Assert.That(finalBoss, Is.Not.Null);
|
||||
|
||||
world.ResolveEnemy(finalBoss, DefenseEnemyResolution.Arrival);
|
||||
Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Defeat));
|
||||
Assert.That(world.RunFailed, Is.True);
|
||||
Assert.That(world.IsRunning, Is.False);
|
||||
hud.RefreshState();
|
||||
|
||||
Button restart = FindButton(hud, "Restart Run");
|
||||
Assert.That(restart, Is.Not.Null);
|
||||
Assert.That(restart.interactable, Is.True,
|
||||
"The result-screen restart must be immediately available after Defeat.");
|
||||
restart.onClick.Invoke();
|
||||
Assert.That(hud.IsRestartConfirmationPending, Is.False);
|
||||
Assert.That(world.RunGeneration, Is.EqualTo(generation + 1));
|
||||
Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Preparing));
|
||||
Assert.That(world.PreparationRemaining, Is.EqualTo(20f).Within(.001f));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator SavedRunGroundPlaneDoesNotBlockMovementAtCapturedSeed()
|
||||
{
|
||||
const int seed = 1655779489;
|
||||
yield return LoadSavedRunSceneForTransitionTest(seed);
|
||||
Defense25DComparison world = FindInScene<Defense25DComparison>();
|
||||
world.AutoSpawn = false;
|
||||
world.ReadInput = false;
|
||||
world.AdvanceRunClock(world.RunDefinition.PreparationSeconds);
|
||||
|
||||
GameObject ground = loadedScene.GetRootGameObjects()
|
||||
.SelectMany(root => root.GetComponentsInChildren<Transform>(true))
|
||||
.Select(item => item.gameObject)
|
||||
.FirstOrDefault(item => item.name == "Defense25DRun Ground");
|
||||
Assert.That(ground, Is.Not.Null);
|
||||
Assert.That(ground.layer, Is.EqualTo(LayerMask.NameToLayer("Default")),
|
||||
"The walkable floor collider must stay in the scene but outside the movement obstacle mask.");
|
||||
|
||||
Defense25DEnemy enemy = world.Enemies.FirstOrDefault(candidate =>
|
||||
candidate != null && candidate.IsAvailable);
|
||||
Assert.That(enemy, Is.Not.Null, $"Seed {seed} should open the first portal and spawn a real enemy.");
|
||||
Physics.SyncTransforms();
|
||||
|
||||
Vector2 displacement = Vector2.right * .1f;
|
||||
string cast = DescribeMovementCast(enemy.Actor, world.Settings, displacement);
|
||||
Vector2 moved = enemy.Actor.Move(displacement);
|
||||
TestContext.Progress.WriteLine(
|
||||
$"RUNSCENE_GROUND_MOVE_PROBE seed={world.CurrentRunSeed} portal={enemy.SpawnContext.PortalId} " +
|
||||
$"origin={enemy.Actor.Position} moved={moved} cast={cast}");
|
||||
Assert.That(moved.x, Is.GreaterThan(.09f),
|
||||
$"A real long-run actor should move horizontally across the walkable ground. " +
|
||||
$"seed={world.CurrentRunSeed}, portal={enemy.SpawnContext.PortalId}, cast={cast}");
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator SavedScenePlayerDeathKeepsLongRunEnemyMovingOnFixedFrames()
|
||||
{
|
||||
const int seed = 1655779489;
|
||||
yield return LoadSavedRunSceneForTransitionTest(seed);
|
||||
Time.timeScale = 1f;
|
||||
|
||||
Defense25DComparison world = FindInScene<Defense25DComparison>();
|
||||
world.AutoSpawn = false;
|
||||
world.ReadInput = false;
|
||||
world.AdvanceRunClock(world.RunDefinition.PreparationSeconds);
|
||||
Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Running));
|
||||
|
||||
var trackedPositions = new System.Collections.Generic.Dictionary<Defense25DEnemy, Vector2>();
|
||||
foreach (Defense25DEnemy enemy in world.Enemies)
|
||||
if (enemy != null && enemy.IsAvailable)
|
||||
trackedPositions[enemy] = enemy.Actor.Position;
|
||||
Assert.That(trackedPositions.Count, Is.GreaterThan(0),
|
||||
"The saved scene should have active long-run enemies after preparation.");
|
||||
float gameTimeBeforeDeath = world.GameTime;
|
||||
float reviveDelay = GameplayConstants.Current.Defense.PlayerReviveDelaySeconds;
|
||||
Assert.That(world.Player.TakeDamage(world.Player.MaxHealth + 1000f, Vector2.right), Is.True);
|
||||
Assert.That(world.Player.IsAlive, Is.False);
|
||||
|
||||
int ticks = 0;
|
||||
bool enemyMoved = false;
|
||||
string movementDiagnostics = string.Empty;
|
||||
float moveObservationEnd = gameTimeBeforeDeath + Mathf.Min(.75f, reviveDelay * .5f);
|
||||
while (world.GameTime < moveObservationEnd && ticks++ < 240)
|
||||
{
|
||||
yield return new WaitForFixedUpdate();
|
||||
movementDiagnostics = string.Join(" | ", world.Enemies
|
||||
.Where(enemy => enemy != null && enemy.IsAvailable)
|
||||
.Select(enemy => $"{enemy.SpawnContext.Source}/portal{enemy.SpawnContext.PortalId}/" +
|
||||
$"wave{enemy.SpawnContext.GlobalWaveId}/spawn{enemy.SpawnContext.SpawnId}:" +
|
||||
$"pos={enemy.Actor.Position},lastMove={enemy.Actor.LastMove},blocked={enemy.Actor.MovementBlocked}," +
|
||||
$"blocker={enemy.Actor.LastMovementBlocker?.name ?? "none"},waypoint={enemy.WaypointIndex}," +
|
||||
$"speed={enemy.Actor.MoveSpeed:F2},stunned={enemy.IsStunned},attacking={enemy.IsAttacking}," +
|
||||
$"speciesAttack={enemy.MonsterCombat?.IsAttackInProgress}," +
|
||||
$"bodyCollider={enemy.Actor.BodyCollider.enabled}"));
|
||||
foreach (Defense25DEnemy enemy in world.Enemies)
|
||||
{
|
||||
if (enemy == null || !enemy.IsAvailable) continue;
|
||||
if (!trackedPositions.TryGetValue(enemy, out Vector2 startPosition))
|
||||
{
|
||||
trackedPositions[enemy] = enemy.Actor.Position;
|
||||
continue;
|
||||
}
|
||||
if (Vector2.Distance(startPosition, enemy.Actor.Position) > .01f)
|
||||
{
|
||||
enemyMoved = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (enemyMoved) break;
|
||||
}
|
||||
|
||||
if (enemyMoved)
|
||||
Debug.Log($"RUNSCENE_PLAYER_DEATH_MOVEMENT seed={world.CurrentRunSeed}, " +
|
||||
$"portals={string.Join(",", world.RunLayout.PortalPositions)}, " +
|
||||
$"activePortals={string.Join(",", world.ActiveWavePortalIds)}; {movementDiagnostics}");
|
||||
|
||||
Assert.That(world.Player.IsAlive, Is.False,
|
||||
"The player should remain dead during the route-movement observation window.");
|
||||
Assert.That(enemyMoved, Is.True,
|
||||
$"At least one active long-run enemy must move on actual fixed frames while the player is dead; " +
|
||||
$"seed={world.CurrentRunSeed}, layoutBounds={world.RunLayout.Bounds}, " +
|
||||
$"portals={string.Join(",", world.RunLayout.PortalPositions)}, " +
|
||||
$"activePortals={string.Join(",", world.ActiveWavePortalIds)}, " +
|
||||
$"paused={world.Paused}, running={world.IsRunning}, timescale={Time.timeScale}, " +
|
||||
$"gameTimeBefore={gameTimeBeforeDeath:F3}, gameTimeAfter={world.GameTime:F3}, " +
|
||||
$"fixedDelta={Time.fixedDeltaTime:F3}, trackedEnemies={trackedPositions.Count}; {movementDiagnostics}.");
|
||||
Assert.That(ticks, Is.LessThan(240), "The Unity fixed-frame observation should reach its game-time target.");
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator SavedScenePlayerDeathRevivesAndDefeatRejectsDelayedCallbacks()
|
||||
{
|
||||
yield return LoadSavedRunSceneForTransitionTest();
|
||||
Time.timeScale = 0f;
|
||||
previousFixedDeltaTime = Time.fixedDeltaTime;
|
||||
restoreFixedDeltaTime = true;
|
||||
Time.fixedDeltaTime = .25f;
|
||||
|
||||
Defense25DComparison world = FindInScene<Defense25DComparison>();
|
||||
Defense25DRunHud hud = FindInScene<Defense25DRunHud>();
|
||||
world.AutoSpawn = false;
|
||||
world.ReadInput = false;
|
||||
world.AdvanceRunClock(world.RunDefinition.PreparationSeconds);
|
||||
Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Running));
|
||||
|
||||
Defense25DEnemy movingEnemy = world.Enemies.FirstOrDefault(enemy => enemy.IsAvailable);
|
||||
Assert.That(movingEnemy, Is.Not.Null,
|
||||
"The saved scene should have active long-run enemies after preparation.");
|
||||
float baseHealthBeforeArrival = world.BaseHealth;
|
||||
float reviveDelay = GameplayConstants.Current.Defense.PlayerReviveDelaySeconds;
|
||||
Assert.That(world.Player.TakeDamage(world.Player.MaxHealth + 1000f, Vector2.right), Is.True);
|
||||
Assert.That(world.Player.IsAlive, Is.False);
|
||||
|
||||
float reviveAt = world.GameTime + reviveDelay;
|
||||
int ticks = 0;
|
||||
while (world.GameTime + Time.fixedDeltaTime < reviveAt - .001f && ticks++ < 200)
|
||||
world.SendMessage("FixedUpdate", SendMessageOptions.RequireReceiver);
|
||||
Assert.That(world.Player.IsAlive, Is.False,
|
||||
"The player should remain dead before the configured revive delay.");
|
||||
world.ResolveEnemy(movingEnemy, DefenseEnemyResolution.Arrival);
|
||||
Assert.That(world.BaseHealth, Is.LessThan(baseHealthBeforeArrival),
|
||||
"An enemy arrival must damage the base while the player is dead.");
|
||||
while (!world.Player.IsAlive && ticks++ < 220)
|
||||
world.SendMessage("FixedUpdate", SendMessageOptions.RequireReceiver);
|
||||
Assert.That(world.Player.IsAlive, Is.True,
|
||||
"The saved scene should revive the player after the configured delay.");
|
||||
Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Running));
|
||||
|
||||
float invulnerabilityEnd = world.GameTime
|
||||
+ GameplayConstants.Current.Defense.PlayerReviveInvulnerabilitySeconds + .25f;
|
||||
while (world.GameTime < invulnerabilityEnd && ticks++ < 240)
|
||||
world.SendMessage("FixedUpdate", SendMessageOptions.RequireReceiver);
|
||||
Assert.That(world.Player.TakeDamage(world.Player.MaxHealth + 1000f, Vector2.left), Is.True);
|
||||
Assert.That(world.Player.IsAlive, Is.False);
|
||||
|
||||
float untilBoss = world.RunDefinition.FinalBossAtSeconds + 1f - world.CombatElapsed;
|
||||
if (untilBoss > 0f)
|
||||
world.AdvanceRunClock(untilBoss);
|
||||
Defense25DEnemy finalBoss = world.Enemies.FirstOrDefault(enemy =>
|
||||
enemy.SpawnContext.Source == Defense25DSpawnSource.FinalBoss && enemy.IsAvailable);
|
||||
Defense25DEnemy delayedRewardEnemy = world.Enemies.FirstOrDefault(enemy =>
|
||||
enemy.IsAvailable && enemy.SpawnContext.Source is
|
||||
Defense25DSpawnSource.GlobalElite or Defense25DSpawnSource.GlobalMidBoss);
|
||||
Assert.That(finalBoss, Is.Not.Null);
|
||||
Assert.That(delayedRewardEnemy, Is.Not.Null,
|
||||
"The delayed callback regression requires an unresolved elite or mid-boss reward source.");
|
||||
|
||||
foreach (Defense25DEnemy enemy in world.Enemies
|
||||
.Where(enemy => enemy.IsAvailable && enemy != finalBoss && enemy != delayedRewardEnemy)
|
||||
.ToArray())
|
||||
{
|
||||
if (world.BaseHealth <= 0f)
|
||||
break;
|
||||
world.ResolveEnemy(enemy, DefenseEnemyResolution.Arrival);
|
||||
}
|
||||
|
||||
Assert.That(world.BaseHealth, Is.Zero,
|
||||
"Unresolved enemy arrivals should reduce the saved scene base to zero.");
|
||||
Assert.That(world.RunFailed, Is.True);
|
||||
Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Defeat));
|
||||
Assert.That(world.IsRunning, Is.False);
|
||||
Assert.That(world.Player.IsAlive, Is.False);
|
||||
|
||||
int generationAtDefeat = world.RunGeneration;
|
||||
int killCurrencyAtDefeat = world.KillCurrency;
|
||||
int constructionCurrencyAtDefeat = world.TowerManager.ConstructionCurrency;
|
||||
bool pendingRewardAtDefeat = world.Progression != null && world.Progression.HasPendingReward;
|
||||
world.AdvanceRunClock(reviveDelay + 1f);
|
||||
world.SendMessage("FixedUpdate", SendMessageOptions.RequireReceiver);
|
||||
Assert.That(world.Player.IsAlive, Is.False,
|
||||
"A delayed revive callback must not revive the player after Defeat.");
|
||||
|
||||
world.ResolveEnemy(delayedRewardEnemy, DefenseEnemyResolution.Death);
|
||||
Assert.That(world.KillCurrency, Is.EqualTo(killCurrencyAtDefeat));
|
||||
Assert.That(world.TowerManager.ConstructionCurrency, Is.EqualTo(constructionCurrencyAtDefeat));
|
||||
Assert.That(world.Progression == null || world.Progression.HasPendingReward == pendingRewardAtDefeat,
|
||||
Is.True, "A delayed boss-death callback must not enqueue a reward after Defeat.");
|
||||
Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Defeat));
|
||||
Assert.That(world.RunGeneration, Is.EqualTo(generationAtDefeat));
|
||||
|
||||
hud.RefreshState();
|
||||
Button restart = FindButton(hud, "Restart Run");
|
||||
Assert.That(restart, Is.Not.Null);
|
||||
restart.onClick.Invoke();
|
||||
Assert.That(hud.IsRestartConfirmationPending, Is.False);
|
||||
Assert.That(world.RunGeneration, Is.EqualTo(generationAtDefeat + 1));
|
||||
Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Preparing));
|
||||
Assert.That(world.Player.IsAlive, Is.True);
|
||||
|
||||
int postRestartKillCurrency = world.KillCurrency;
|
||||
int postRestartConstructionCurrency = world.TowerManager.ConstructionCurrency;
|
||||
world.ResolveEnemy(delayedRewardEnemy, DefenseEnemyResolution.Death);
|
||||
Assert.That(world.KillCurrency, Is.EqualTo(postRestartKillCurrency),
|
||||
"A callback from the previous generation must not change the restarted run.");
|
||||
Assert.That(world.TowerManager.ConstructionCurrency, Is.EqualTo(postRestartConstructionCurrency));
|
||||
}
|
||||
|
||||
private IEnumerator LoadSavedRunSceneForTransitionTest(int? fixedSeed = null)
|
||||
{
|
||||
previousTimeScale = Time.timeScale;
|
||||
Time.timeScale = 0f;
|
||||
previousActiveScene = SceneManager.GetActiveScene();
|
||||
UnityEngine.Events.UnityAction<Scene, LoadSceneMode> beforeStart = null;
|
||||
if (fixedSeed.HasValue)
|
||||
{
|
||||
beforeStart = (scene, _) =>
|
||||
{
|
||||
if (scene.path != ScenePath) return;
|
||||
Defense25DComparison comparison = scene.GetRootGameObjects()
|
||||
.SelectMany(root => root.GetComponentsInChildren<Defense25DComparison>(true))
|
||||
.FirstOrDefault();
|
||||
Assert.That(comparison, Is.Not.Null, "The saved run scene must contain its Comparison bootstrap.");
|
||||
var flags = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic;
|
||||
typeof(Defense25DComparison).GetField("useFixedLongRunSeed", flags)
|
||||
.SetValue(comparison, true);
|
||||
typeof(Defense25DComparison).GetField("longRunLayoutSeed", flags)
|
||||
.SetValue(comparison, fixedSeed.Value);
|
||||
};
|
||||
SceneManager.sceneLoaded += beforeStart;
|
||||
}
|
||||
AsyncOperation load = EditorSceneManager.LoadSceneAsyncInPlayMode(ScenePath,
|
||||
new LoadSceneParameters(LoadSceneMode.Additive));
|
||||
Assert.That(load, Is.Not.Null, $"Could not load saved scene {ScenePath}.");
|
||||
yield return load;
|
||||
if (beforeStart != null) SceneManager.sceneLoaded -= beforeStart;
|
||||
loadedScene = SceneManager.GetSceneByPath(ScenePath);
|
||||
Assert.That(loadedScene.IsValid() && loadedScene.isLoaded, Is.True,
|
||||
$"Scene did not reload from {ScenePath}.");
|
||||
SceneManager.SetActiveScene(loadedScene);
|
||||
yield return null;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
private static string DescribeMovementCast(Defense25DActor actor,
|
||||
Defense25DComparisonSettings settings, Vector2 displacement)
|
||||
{
|
||||
CapsuleCollider capsule = actor.BodyCollider;
|
||||
float cylinder = Mathf.Max(0f, capsule.height * .5f - capsule.radius);
|
||||
Vector3 center = Defense25DActor.OnGround(actor.Center,
|
||||
settings.GroundY + capsule.center.y);
|
||||
RaycastHit[] hits = Physics.CapsuleCastAll(center + Vector3.up * cylinder,
|
||||
center - Vector3.up * cylinder, capsule.radius,
|
||||
Defense25DActor.OnGround(displacement.normalized),
|
||||
displacement.magnitude + settings.MovementSkin,
|
||||
Defense25DComparisonSettings.TerrainMask | Defense25DComparisonSettings.ActorMask,
|
||||
QueryTriggerInteraction.Ignore);
|
||||
return string.Join("; ", hits.Select(hit =>
|
||||
$"{hit.collider.name}:distance={hit.distance:F6},normal={hit.normal},bounds={hit.collider.bounds}"));
|
||||
}
|
||||
|
||||
private static IEnumerator CaptureAtResolution(string outputDirectory, int width, int height)
|
||||
{
|
||||
Screen.SetResolution(width, height, FullScreenMode.Windowed);
|
||||
for (int frame = 0; frame < 60 && (Screen.width != width || Screen.height != height); frame++)
|
||||
yield return null;
|
||||
|
||||
string filePath = Path.Combine(outputDirectory, $"defense25d-run-hud-{width}x{height}.png");
|
||||
if (File.Exists(filePath))
|
||||
File.Delete(filePath);
|
||||
|
||||
ScreenCapture.CaptureScreenshot(filePath);
|
||||
for (int frame = 0; frame < 120 && (!File.Exists(filePath) || new FileInfo(filePath).Length == 0); frame++)
|
||||
yield return null;
|
||||
|
||||
Assert.That(File.Exists(filePath), Is.True, $"ScreenCapture did not create {filePath}.");
|
||||
Texture2D capture = new(2, 2, TextureFormat.RGBA32, false);
|
||||
try
|
||||
{
|
||||
Assert.That(capture.LoadImage(File.ReadAllBytes(filePath)), Is.True,
|
||||
$"ScreenCapture output is not a readable PNG: {filePath}.");
|
||||
TestContext.Progress.WriteLine(
|
||||
$"DEFENSE25D_HUD_CAPTURE requested={width}x{height} screen={Screen.width}x{Screen.height} " +
|
||||
$"pixels={capture.width}x{capture.height} path={filePath}");
|
||||
Assert.That(Screen.width, Is.EqualTo(width), "Screen.SetResolution did not reach the requested width.");
|
||||
Assert.That(Screen.height, Is.EqualTo(height), "Screen.SetResolution did not reach the requested height.");
|
||||
Assert.That(capture.width, Is.EqualTo(width), "Captured frame width does not match the requested resolution.");
|
||||
Assert.That(capture.height, Is.EqualTo(height), "Captured frame height does not match the requested resolution.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.Destroy(capture);
|
||||
}
|
||||
}
|
||||
|
||||
private T FindInScene<T>() where T : Component
|
||||
{
|
||||
return loadedScene.GetRootGameObjects()
|
||||
.SelectMany(root => root.GetComponentsInChildren<T>(true))
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static Button FindButton(Defense25DRunHud hud, string objectName)
|
||||
{
|
||||
return hud.GetComponentsInChildren<Button>(true)
|
||||
.FirstOrDefault(button => button.gameObject.name == objectName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0296e49a5f4141d8870c129931eecac7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,508 @@
|
||||
using System.Collections;
|
||||
using System.Linq;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Defense;
|
||||
using BumpCombat.Defense.Comparison;
|
||||
using BumpCombat.Enemies;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace BumpCombat.PlayModeTests
|
||||
{
|
||||
public sealed class Defense25DTowerConsumerPlayModeTests
|
||||
{
|
||||
private const string SettingsPath =
|
||||
"Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset";
|
||||
|
||||
private Defense25DComparisonSettings settings;
|
||||
private Defense25DComparison world;
|
||||
private Defense25DRewardConstants rewardConstants;
|
||||
private Defense25DTowerModifiers modifiers;
|
||||
private DefenseTowerKind[] previousPortalImmunities;
|
||||
private EnemyDefinition sourceEnemy;
|
||||
private float previousTimeScale;
|
||||
|
||||
[UnitySetUp]
|
||||
public IEnumerator SetUp()
|
||||
{
|
||||
previousTimeScale = Time.timeScale;
|
||||
Time.timeScale = 1f;
|
||||
DefenseConstants tuning = GameplayConstants.Current.Defense;
|
||||
previousPortalImmunities = tuning.PortalImmuneTowerKinds;
|
||||
tuning.PortalImmuneTowerKinds = Enumerable.Repeat(
|
||||
DefenseTowerKind.Empowering,
|
||||
previousPortalImmunities != null && previousPortalImmunities.Length > 0
|
||||
? previousPortalImmunities.Length
|
||||
: 6).ToArray();
|
||||
|
||||
settings = Object.Instantiate(
|
||||
AssetDatabase.LoadAssetAtPath<Defense25DComparisonSettings>(SettingsPath));
|
||||
Assert.That(settings, Is.Not.Null);
|
||||
settings.Enemy = AssetDatabase.LoadAssetAtPath<EnemyDefinition>(
|
||||
"Assets/_Project/Constants/Enemies/Slime.asset");
|
||||
settings.EnemyArt = ReadCharacter("Slime", "Slime", "Attack");
|
||||
settings.EnemyRadius = .24f;
|
||||
settings.EnemyColliderOffset = new Vector2(0f, -.28f);
|
||||
settings.EnemyVisualScale = 1f;
|
||||
settings.EnemyFootOffset = -.4f;
|
||||
settings.EnemyBodyCenterCorrectionPixels = 0f;
|
||||
world = new GameObject("Tower consumer regression world")
|
||||
.AddComponent<Defense25DComparison>();
|
||||
world.Initialize(settings);
|
||||
world.AutoSpawn = false;
|
||||
world.ReadInput = false;
|
||||
world.Player.ResetAt(Vector2.zero);
|
||||
|
||||
sourceEnemy = settings.Enemy;
|
||||
settings.Enemy = Object.Instantiate(sourceEnemy);
|
||||
SerializedObject serializedEnemy = new(settings.Enemy);
|
||||
serializedEnemy.FindProperty("maxHealth").floatValue = 1000f;
|
||||
serializedEnemy.ApplyModifiedPropertiesWithoutUndo();
|
||||
|
||||
rewardConstants = ScriptableObject.CreateInstance<Defense25DRewardConstants>();
|
||||
modifiers = new Defense25DTowerModifiers(rewardConstants);
|
||||
world.TowerManager.SetRunModifiers(modifiers);
|
||||
yield return new WaitForFixedUpdate();
|
||||
}
|
||||
|
||||
[UnityTearDown]
|
||||
public IEnumerator TearDown()
|
||||
{
|
||||
if (world != null)
|
||||
Object.Destroy(world.gameObject);
|
||||
if (settings != null && settings.Enemy != sourceEnemy)
|
||||
Object.Destroy(settings.Enemy);
|
||||
if (settings != null)
|
||||
Object.Destroy(settings);
|
||||
if (rewardConstants != null)
|
||||
Object.Destroy(rewardConstants);
|
||||
if (previousPortalImmunities != null)
|
||||
GameplayConstants.Current.Defense.PortalImmuneTowerKinds = previousPortalImmunities;
|
||||
Time.timeScale = previousTimeScale;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator LegacyComparisonKeepsSixConfiguredPads()
|
||||
{
|
||||
Assert.That(world.TowerManager.PadCount, Is.EqualTo(6));
|
||||
Assert.That(world.TowerManager.GetPadPosition(5),
|
||||
Is.EqualTo(GameplayConstants.Current.Defense.GetBuildPadPosition(5)));
|
||||
Assert.Throws<System.ArgumentOutOfRangeException>(
|
||||
() => world.TowerManager.GetPadPosition(6));
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator DirectModifiersChangeRealDamageTargetCountAndProjectileVisuals()
|
||||
{
|
||||
AssertApply("fire.attack.common");
|
||||
AssertApply("fire.targets.epic");
|
||||
Defense25DTower tower = BuildTower(DefenseTowerKind.Direct);
|
||||
Defense25DEnemy first = SpawnTarget(tower.Position + Vector2.right * .8f);
|
||||
Defense25DEnemy second = SpawnTarget(tower.Position + Vector2.right * 1.6f);
|
||||
Physics.SyncTransforms();
|
||||
|
||||
tower.StepParityAttacks(.01f);
|
||||
|
||||
Assert.That(CountProjectileRoots(), Is.EqualTo(2));
|
||||
Assert.That(HasVisibleProjectile(), Is.True);
|
||||
tower.StepParityAttacks(.2f);
|
||||
|
||||
Assert.That(first.Actor.DamageEvents, Is.EqualTo(1));
|
||||
Assert.That(second.Actor.DamageEvents, Is.EqualTo(1));
|
||||
Assert.That(1000f - first.Actor.CurrentHealth,
|
||||
Is.EqualTo(GameplayConstants.Current.Defense.DirectTowerDamage
|
||||
* 1.1f * tower.SightEfficiency).Within(.01f));
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator NewlyBuiltUpgradedTowerAppliesRunDamageBeforeOneOutsideSightCorrection()
|
||||
{
|
||||
AssertApply("fire.attack.common");
|
||||
Defense25DTower tower = BuildTower(DefenseTowerKind.Direct);
|
||||
Assert.That(tower.TryUpgrade(DefenseTowerBranch.None), Is.True);
|
||||
world.Player.ResetAt(tower.Position + Vector2.right
|
||||
* (GameplayConstants.Current.Defense.PlayerSightRadius + 2f));
|
||||
Assert.That(tower.SightEfficiency,
|
||||
Is.EqualTo(GameplayConstants.Current.Defense.OutsideSightDamageEfficiency)
|
||||
.Within(.001f));
|
||||
Defense25DEnemy target = SpawnTarget(tower.Position + Vector2.right * .8f);
|
||||
Physics.SyncTransforms();
|
||||
|
||||
tower.StepParityAttacks(.01f);
|
||||
tower.StepParityAttacks(.2f);
|
||||
|
||||
float tierScale = 1f + GameplayConstants.Current.Defense.TowerDamageBonusPerTier;
|
||||
float expectedDamage = GameplayConstants.Current.Defense.DirectTowerDamage
|
||||
* tierScale * 1.1f
|
||||
* GameplayConstants.Current.Defense.OutsideSightDamageEfficiency;
|
||||
Assert.That(target.Actor.DamageEvents, Is.EqualTo(1));
|
||||
Assert.That(1000f - target.Actor.CurrentHealth,
|
||||
Is.EqualTo(BumpCombat.Combat.DamageCalculator.FinalizeDamage(expectedDamage)),
|
||||
"A newly built, upgraded tower applies run damage and the 70% sight rule once each.");
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator PlayerDeathPausesAnInFlightTowerAttackUntilRevival()
|
||||
{
|
||||
Defense25DTower tower = BuildTower(DefenseTowerKind.Direct);
|
||||
Defense25DEnemy target = SpawnTarget(tower.Position + Vector2.right * .8f);
|
||||
Physics.SyncTransforms();
|
||||
|
||||
tower.StepParityAttacks(.01f);
|
||||
Assert.That(CountProjectileRoots(), Is.EqualTo(1));
|
||||
Assert.That(world.Player.TakeDamageWithResult(
|
||||
100000f,
|
||||
Vector2.left,
|
||||
0f,
|
||||
BumpCombat.Enemies.EnemyDamageSource.None,
|
||||
ordinaryBump: false,
|
||||
out _), Is.True);
|
||||
Assert.That(world.TowersOperational, Is.False);
|
||||
|
||||
tower.StepParityAttacks(1f);
|
||||
Assert.That(target.Actor.DamageEvents, Is.Zero,
|
||||
"A tower flight must not deal damage while the player is dead.");
|
||||
|
||||
world.Player.ResetAt(tower.Position);
|
||||
tower.StepParityAttacks(.2f);
|
||||
|
||||
Assert.That(target.Actor.DamageEvents, Is.EqualTo(1),
|
||||
"The paused flight resumes once after the player is revived.");
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator DirectTierFourMeteorUsesRunDamageModifierOnce()
|
||||
{
|
||||
AssertApply("fire.attack.common");
|
||||
Defense25DTower tower = BuildTower(DefenseTowerKind.Direct);
|
||||
Assert.That(tower.TryUpgrade(DefenseTowerBranch.None), Is.True);
|
||||
Assert.That(tower.TryUpgrade(DefenseTowerBranch.None), Is.True);
|
||||
Assert.That(tower.TryUpgrade(DefenseTowerBranch.First), Is.True);
|
||||
Defense25DEnemy target = SpawnTarget(tower.Position + Vector2.right * .8f);
|
||||
Physics.SyncTransforms();
|
||||
|
||||
tower.StepParityAttacks(.01f);
|
||||
tower.StepParityAttacks(.32f);
|
||||
|
||||
float tierScale = 1f + GameplayConstants.Current.Defense.TowerDamageBonusPerTier * 3f;
|
||||
float expectedDamage = GameplayConstants.Current.Defense.DirectTowerDamage
|
||||
* tierScale * 1.6f * 1.1f * tower.SightEfficiency;
|
||||
Assert.That(target.Actor.DamageEvents, Is.EqualTo(1));
|
||||
Assert.That(1000f - target.Actor.CurrentHealth,
|
||||
Is.GreaterThan(expectedDamage - 2f),
|
||||
"The run modifier applies after tier/meteor scaling without being multiplied twice.");
|
||||
Assert.That(1000f - target.Actor.CurrentHealth,
|
||||
Is.LessThan(expectedDamage + 1f));
|
||||
Assert.That(HasVisibleTowerVisual(), Is.True);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator PlayerDeathStopsAStartedSummonAttack()
|
||||
{
|
||||
Defense25DTower tower = BuildTower(DefenseTowerKind.Summon);
|
||||
Defense25DEnemy target = SpawnTarget(tower.Position + Vector2.right * .2f);
|
||||
tower.StepSummonPack(.01f);
|
||||
Defense25DSummon[] pack = world.Summons
|
||||
.Where(candidate => candidate.Owner == tower)
|
||||
.OrderBy(candidate => candidate.SlotIndex)
|
||||
.ToArray();
|
||||
Assert.That(pack, Has.Length.EqualTo(GameplayConstants.Current.Defense.SummonUnitCount));
|
||||
Defense25DSummon summon = pack[0];
|
||||
summon.Step(.01f);
|
||||
Assert.That(summon.IsAttacking, Is.True);
|
||||
|
||||
Assert.That(world.Player.TakeDamageWithResult(
|
||||
100000f,
|
||||
Vector2.left,
|
||||
0f,
|
||||
BumpCombat.Enemies.EnemyDamageSource.None,
|
||||
ordinaryBump: false,
|
||||
out _), Is.True);
|
||||
summon.Step(.1f);
|
||||
|
||||
Assert.That(summon.IsAttacking, Is.False);
|
||||
Assert.That(target.Actor.DamageEvents, Is.Zero,
|
||||
"A summon cannot continue its attack after player death.");
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ColdAdditionalProjectileHitsDistinctTargetAndRendersBoth()
|
||||
{
|
||||
AssertApply("cold.projectile.epic");
|
||||
Defense25DTower tower = BuildTower(DefenseTowerKind.Cold);
|
||||
Defense25DEnemy first = SpawnTarget(tower.Position + Vector2.right * .8f);
|
||||
Defense25DEnemy second = SpawnTarget(tower.Position + Vector2.right * 1.6f);
|
||||
Physics.SyncTransforms();
|
||||
|
||||
tower.StepParityAttacks(.01f);
|
||||
|
||||
Assert.That(CountProjectileRoots(), Is.EqualTo(2));
|
||||
Assert.That(HasVisibleProjectile(), Is.True);
|
||||
tower.StepParityAttacks(.2f);
|
||||
tower.StepParityAttacks(.2f);
|
||||
|
||||
Assert.That(first.Actor.DamageEvents, Is.EqualTo(1));
|
||||
Assert.That(second.Actor.DamageEvents, Is.EqualTo(1));
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator LightningChainModifierHitsAndRendersAnExtraTarget()
|
||||
{
|
||||
AssertApply("lightning.chain.rare");
|
||||
Assert.That(modifiers.GetEffectiveStats(new Defense25DTowerStatInput(
|
||||
DefenseTowerKind.Lightning,
|
||||
0f,
|
||||
0f,
|
||||
supportsChainTargets: true)).ChainCount, Is.EqualTo(1));
|
||||
Defense25DTower tower = BuildTower(DefenseTowerKind.Lightning);
|
||||
Defense25DEnemy first = SpawnTarget(tower.Position + Vector2.right * .35f);
|
||||
Defense25DEnemy alreadyInArea = SpawnTarget(tower.Position + Vector2.right * .65f);
|
||||
float outsideBaseRadius = GameplayConstants.Current.Defense.LightningRadius + .35f;
|
||||
Defense25DEnemy chained = SpawnTarget(
|
||||
first.Actor.Position + Vector2.right * outsideBaseRadius);
|
||||
Assert.That(world.IsTowerImmuneToEnemy(DefenseTowerKind.Lightning, chained), Is.False);
|
||||
Physics.SyncTransforms();
|
||||
|
||||
tower.StepParityAttacks(.01f);
|
||||
|
||||
Assert.That(first.Actor.DamageEvents, Is.EqualTo(1));
|
||||
Assert.That(alreadyInArea.Actor.DamageEvents, Is.EqualTo(1),
|
||||
"The chain must skip targets already hit by the initial area query.");
|
||||
Assert.That(chained.Actor.DamageEvents, Is.EqualTo(1));
|
||||
Assert.That(CountTowerVisualRoots(), Is.GreaterThanOrEqualTo(2));
|
||||
Assert.That(HasVisibleTowerVisual(), Is.True);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator SeismicAreaModifierExpandsActualHitAndVisibleMarker()
|
||||
{
|
||||
AssertApply("seismic.area.common");
|
||||
Defense25DTower tower = BuildTower(DefenseTowerKind.Seismic);
|
||||
float baseRadius = GameplayConstants.Current.Defense.SeismicRadius;
|
||||
Defense25DEnemy first = SpawnTarget(tower.Position + Vector2.right * .2f);
|
||||
Defense25DEnemy edge = SpawnTarget(tower.Position
|
||||
+ Vector2.right * (baseRadius * 1.05f));
|
||||
Physics.SyncTransforms();
|
||||
|
||||
tower.StepParityAttacks(.01f);
|
||||
|
||||
Assert.That(first.Actor.DamageEvents, Is.EqualTo(1));
|
||||
Assert.That(edge.Actor.DamageEvents, Is.EqualTo(1));
|
||||
Assert.That(HasVisibleTowerVisual(), Is.True);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ColdSplitHitsDistinctTargetsWithoutRecursiveSplitting()
|
||||
{
|
||||
AssertApply("cold.split.epic");
|
||||
AssertApply("cold.return.epic");
|
||||
Defense25DTower tower = BuildTower(DefenseTowerKind.Cold);
|
||||
Defense25DEnemy originalTarget = SpawnTarget(
|
||||
tower.Position + Vector2.right * .8f);
|
||||
tower.StepParityAttacks(.01f);
|
||||
Vector2 projectileStart = Defense25DActor.Ground(
|
||||
FindProjectileRoot().position);
|
||||
Vector2 incomingDirection = (
|
||||
originalTarget.Actor.Position - projectileStart).normalized;
|
||||
Defense25DEnemy offAngleTarget = SpawnTarget(
|
||||
originalTarget.Actor.Position + incomingDirection * .7f);
|
||||
Defense25DEnemy[] splitTargets =
|
||||
{
|
||||
SpawnTarget(originalTarget.Actor.Position
|
||||
+ Rotate(incomingDirection, 25f)),
|
||||
SpawnTarget(originalTarget.Actor.Position
|
||||
+ Rotate(incomingDirection, -25f)),
|
||||
};
|
||||
Physics.SyncTransforms();
|
||||
|
||||
tower.StepParityAttacks(.2f);
|
||||
Assert.That(CountProjectileRoots(), Is.EqualTo(3),
|
||||
"The original flight and its two fixed-angle child flights should exist.");
|
||||
Assert.That(HasVisibleProjectile(), Is.True);
|
||||
|
||||
tower.StepParityAttacks(.02f);
|
||||
float[] splitAngles = world.GetComponentsInChildren<Transform>(true)
|
||||
.Where(transform => transform.name == "Tower Projectile")
|
||||
.Select(transform => Defense25DActor.Ground(transform.position)
|
||||
- originalTarget.Actor.Position)
|
||||
.Where(offset => offset.sqrMagnitude > .01f)
|
||||
.Select(offset => Vector2.SignedAngle(incomingDirection, offset.normalized))
|
||||
.Where(angle => Mathf.Abs(angle) < 90f)
|
||||
.OrderBy(angle => angle)
|
||||
.ToArray();
|
||||
Assert.That(splitAngles, Has.Length.EqualTo(2));
|
||||
Assert.That(splitAngles[0], Is.EqualTo(-25f).Within(.5f));
|
||||
Assert.That(splitAngles[1], Is.EqualTo(25f).Within(.5f));
|
||||
|
||||
tower.StepParityAttacks(.2f);
|
||||
|
||||
float originalDamage = GameplayConstants.Current.Defense.ColdDamage
|
||||
* tower.SightEfficiency;
|
||||
float expectedFullDamage = BumpCombat.Combat.DamageCalculator
|
||||
.FinalizeDamage(originalDamage);
|
||||
float expectedHalfDamage = BumpCombat.Combat.DamageCalculator
|
||||
.FinalizeDamage(originalDamage * .5f);
|
||||
Assert.That(originalTarget.Actor.DamageEvents, Is.EqualTo(2),
|
||||
"The split source hits the original target outbound and once on its return.");
|
||||
Assert.That(1000f - originalTarget.Actor.CurrentHealth,
|
||||
Is.EqualTo(expectedFullDamage + expectedHalfDamage).Within(.001f));
|
||||
Assert.That(offAngleTarget.Actor.DamageEvents, Is.Zero,
|
||||
"Split children travel along the specified ±25° lanes, not toward the nearest off-angle target.");
|
||||
foreach (Defense25DEnemy splitTarget in splitTargets)
|
||||
{
|
||||
Assert.That(splitTarget.Actor.DamageEvents, Is.EqualTo(1));
|
||||
Assert.That(1000f - splitTarget.Actor.CurrentHealth,
|
||||
Is.EqualTo(expectedHalfDamage).Within(.001f));
|
||||
}
|
||||
yield return null;
|
||||
Assert.That(CountProjectileRoots(), Is.Zero,
|
||||
"Split children neither split again nor inherit the original flight's return effect.");
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ColdReturnDamagesAnInterveningTargetAndMovesVisualBackToOrigin()
|
||||
{
|
||||
AssertApply("cold.return.epic");
|
||||
Defense25DTower tower = BuildTower(DefenseTowerKind.Cold);
|
||||
Defense25DEnemy outboundTarget = SpawnTarget(
|
||||
tower.Position + Vector2.right * 1.8f);
|
||||
Physics.SyncTransforms();
|
||||
|
||||
tower.StepParityAttacks(.01f);
|
||||
Defense25DEnemy returnTarget = SpawnTarget(
|
||||
tower.Position + Vector2.right * 1.25f);
|
||||
Physics.SyncTransforms();
|
||||
tower.StepParityAttacks(.2f);
|
||||
Assert.That(outboundTarget.Actor.DamageEvents, Is.EqualTo(1));
|
||||
Transform projectile = FindProjectileRoot();
|
||||
Assert.That(projectile, Is.Not.Null);
|
||||
float beforeReturnStep = Vector2.Distance(
|
||||
Defense25DActor.Ground(projectile.position), tower.Position);
|
||||
|
||||
float expectedFullDamage = BumpCombat.Combat.DamageCalculator
|
||||
.FinalizeDamage(GameplayConstants.Current.Defense.ColdDamage
|
||||
* tower.SightEfficiency);
|
||||
float expectedReturnDamage = BumpCombat.Combat.DamageCalculator
|
||||
.FinalizeDamage(GameplayConstants.Current.Defense.ColdDamage
|
||||
* tower.SightEfficiency * .5f);
|
||||
|
||||
for (int i = 0; i < 4; i++)
|
||||
tower.StepParityAttacks(.03f);
|
||||
|
||||
Assert.That(Vector2.Distance(Defense25DActor.Ground(projectile.position), tower.Position),
|
||||
Is.LessThan(beforeReturnStep));
|
||||
Assert.That(outboundTarget.Actor.DamageEvents, Is.EqualTo(2),
|
||||
"Each target may be hit once outbound and once during the single return leg.");
|
||||
Assert.That(1000f - outboundTarget.Actor.CurrentHealth,
|
||||
Is.EqualTo(expectedFullDamage + expectedReturnDamage).Within(.001f));
|
||||
Assert.That(returnTarget.Actor.DamageEvents, Is.EqualTo(1));
|
||||
Assert.That(1000f - returnTarget.Actor.CurrentHealth,
|
||||
Is.EqualTo(expectedReturnDamage).Within(.001f),
|
||||
"The return path deals half damage and does not repeatedly hit a target across segments.");
|
||||
Assert.That(HasVisibleProjectile(), Is.True);
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
tower.StepParityAttacks(.02f);
|
||||
Assert.That(outboundTarget.Actor.DamageEvents, Is.EqualTo(2));
|
||||
Assert.That(returnTarget.Actor.DamageEvents, Is.EqualTo(1));
|
||||
yield return null;
|
||||
}
|
||||
|
||||
private static Vector2 Rotate(Vector2 direction, float angleDegrees)
|
||||
{
|
||||
float angle = angleDegrees * Mathf.Deg2Rad;
|
||||
float cosine = Mathf.Cos(angle);
|
||||
float sine = Mathf.Sin(angle);
|
||||
return new Vector2(
|
||||
direction.x * cosine - direction.y * sine,
|
||||
direction.x * sine + direction.y * cosine).normalized;
|
||||
}
|
||||
|
||||
private Defense25DTower BuildTower(DefenseTowerKind kind)
|
||||
{
|
||||
Vector2 pad = GameplayConstants.Current.Defense.GetBuildPadPosition(0);
|
||||
world.Player.ResetAt(pad);
|
||||
Assert.That(world.TowerManager.TryBuildTowerAtPad(0, kind), Is.True);
|
||||
return world.TowerManager.GetTowerAtPad(0);
|
||||
}
|
||||
|
||||
private Defense25DEnemy SpawnTarget(Vector2 position)
|
||||
{
|
||||
Defense25DEnemy enemy = world.SpawnNextEnemy();
|
||||
enemy.Actor.ResetAt(position);
|
||||
return enemy;
|
||||
}
|
||||
|
||||
private void AssertApply(string definitionId)
|
||||
{
|
||||
Defense25DTowerModifierDefinition definition =
|
||||
rewardConstants.TowerModifierCatalog.Single(candidate =>
|
||||
candidate.DefinitionId == definitionId);
|
||||
Assert.That(modifiers.Apply(
|
||||
Defense25DRewardChoice.CreateTowerModifier(definition)), Is.True);
|
||||
}
|
||||
|
||||
private int CountProjectileRoots() => world.GetComponentsInChildren<Transform>(true)
|
||||
.Count(transform => transform.name == "Tower Projectile");
|
||||
|
||||
private int CountTowerVisualRoots() => world.GetComponentsInChildren<Transform>(true)
|
||||
.Count(transform => transform.name is "Tower Attack Effect" or "Lightning Attack Effect");
|
||||
|
||||
private Transform FindProjectileRoot() => world.GetComponentsInChildren<Transform>(true)
|
||||
.FirstOrDefault(transform => transform.name == "Tower Projectile");
|
||||
|
||||
private bool HasVisibleProjectile()
|
||||
{
|
||||
foreach (Transform projectile in world.GetComponentsInChildren<Transform>(true)
|
||||
.Where(transform => transform.name == "Tower Projectile"))
|
||||
{
|
||||
SpriteRenderer renderer = projectile.GetComponentInChildren<SpriteRenderer>(true);
|
||||
if (renderer != null && renderer.enabled && renderer.sprite != null)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool HasVisibleTowerVisual() => world.GetComponentsInChildren<SpriteRenderer>(true)
|
||||
.Any(renderer => renderer.enabled && renderer.sprite != null
|
||||
&& renderer.transform.parent != null
|
||||
&& renderer.transform.parent.name == "Tower Attack Effect");
|
||||
|
||||
private static ComparisonSpriteFrames ReadCharacter(
|
||||
string folder,
|
||||
string prefix,
|
||||
string attack)
|
||||
{
|
||||
Sprite[] Read(string suffix) => AssetDatabase.LoadAllAssetsAtPath(
|
||||
$"Assets/_Project/Art/Characters/{folder}/{prefix}_{suffix}.png")
|
||||
.OfType<Sprite>()
|
||||
.OrderBy(sprite => int.TryParse(
|
||||
sprite.name[(sprite.name.LastIndexOf('_') + 1)..], out int frame)
|
||||
? frame
|
||||
: 0)
|
||||
.ToArray();
|
||||
return new ComparisonSpriteFrames
|
||||
{
|
||||
Idle = Read("Idle"),
|
||||
Walk = Read("Walk"),
|
||||
Attack = Read(attack),
|
||||
Hurt = Read("Hurt"),
|
||||
Death = Read("Death"),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d333d6c5f0ca44058a6508def5ac0bc4
|
||||
@@ -0,0 +1,293 @@
|
||||
using System.Collections;
|
||||
using System.Linq;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Defense;
|
||||
using BumpCombat.Defense.Comparison;
|
||||
using BumpCombat.Enemies;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace BumpCombat.PlayModeTests
|
||||
{
|
||||
public sealed class Defense25DTowerManagerPlayModeTests
|
||||
{
|
||||
private const string SettingsPath =
|
||||
"Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset";
|
||||
private const string RunDefinitionPath =
|
||||
"Assets/_Project/Constants/Defense25DComparison/LongDefenseRun.asset";
|
||||
|
||||
private Defense25DComparisonSettings settings;
|
||||
private Defense25DComparison world;
|
||||
private Defense25DFacilityVisuals facilityVisuals;
|
||||
private EnemyDefinition sourceEnemy;
|
||||
private float previousTimeScale;
|
||||
|
||||
[UnitySetUp]
|
||||
public IEnumerator SetUp()
|
||||
{
|
||||
previousTimeScale = Time.timeScale;
|
||||
Time.timeScale = 1f;
|
||||
settings = Object.Instantiate(
|
||||
AssetDatabase.LoadAssetAtPath<Defense25DComparisonSettings>(SettingsPath));
|
||||
Assert.That(settings, Is.Not.Null);
|
||||
sourceEnemy = AssetDatabase.LoadAssetAtPath<EnemyDefinition>(
|
||||
"Assets/_Project/Constants/Enemies/Slime.asset");
|
||||
settings.Enemy = Object.Instantiate(sourceEnemy);
|
||||
settings.EnemyArt = ReadCharacter("Slime", "Slime", "Attack");
|
||||
settings.EnemyRadius = .24f;
|
||||
settings.EnemyColliderOffset = new Vector2(0f, -.28f);
|
||||
settings.EnemyVisualScale = 1f;
|
||||
settings.EnemyFootOffset = -.4f;
|
||||
settings.EnemyBodyCenterCorrectionPixels = 0f;
|
||||
|
||||
Defense25DRunDefinition definition =
|
||||
AssetDatabase.LoadAssetAtPath<Defense25DRunDefinition>(RunDefinitionPath);
|
||||
Assert.That(definition, Is.Not.Null);
|
||||
world = new GameObject("Long-run tower manager regression")
|
||||
.AddComponent<Defense25DComparison>();
|
||||
world.InitializeLongRun(settings, definition, 14);
|
||||
world.AutoSpawn = false;
|
||||
world.ReadInput = false;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTearDown]
|
||||
public IEnumerator TearDown()
|
||||
{
|
||||
if (facilityVisuals != null)
|
||||
{
|
||||
facilityVisuals.Clear();
|
||||
Object.Destroy(facilityVisuals.gameObject);
|
||||
}
|
||||
if (world != null)
|
||||
Object.Destroy(world.gameObject);
|
||||
if (settings != null)
|
||||
{
|
||||
if (settings.Enemy != null && settings.Enemy != sourceEnemy)
|
||||
Object.Destroy(settings.Enemy);
|
||||
Object.Destroy(settings);
|
||||
}
|
||||
Time.timeScale = previousTimeScale;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator LongRunManagerBuildsAtTheEighteenthProfilePad()
|
||||
{
|
||||
Assert.That(world.RunLayout.PadCount, Is.EqualTo(18));
|
||||
Assert.That(world.TowerManager.PadCount, Is.EqualTo(18));
|
||||
Assert.That(world.TowerManager.GetPadPosition(17),
|
||||
Is.EqualTo(world.RunLayout.PadPositions[17]));
|
||||
|
||||
Vector2 pad = world.TowerManager.GetPadPosition(17);
|
||||
world.Player.ResetAt(pad);
|
||||
Assert.That(world.TowerManager.TryBuildTowerAtPad(17, DefenseTowerKind.Direct),
|
||||
Is.True);
|
||||
Defense25DTower tower = world.TowerManager.GetTowerAtPad(17);
|
||||
Assert.That(tower, Is.Not.Null);
|
||||
Assert.That(tower.PadIndex, Is.EqualTo(17));
|
||||
Assert.That(tower.Position, Is.EqualTo(pad));
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator SummonAtEighteenthPadReturnsTowardItsAnchorWithoutAView()
|
||||
{
|
||||
Defense25DTowerManager manager = world.TowerManager;
|
||||
Assert.That(world.View, Is.Null,
|
||||
"This test keeps presentation absent so summon movement cannot depend on camera visibility.");
|
||||
Vector2 pad = manager.GetPadPosition(17);
|
||||
world.Player.ResetAt(pad);
|
||||
Assert.That(manager.TryBuildTowerAtPad(17, DefenseTowerKind.Summon), Is.True);
|
||||
|
||||
Defense25DTower tower = manager.GetTowerAtPad(17);
|
||||
Assert.That(world.TowersOperational, Is.True);
|
||||
Assert.That(tower.CommandTarget, Is.Null);
|
||||
Defense25DSummon[] pack = world.Summons
|
||||
.Where(candidate => candidate.Owner == tower)
|
||||
.OrderBy(candidate => candidate.SlotIndex)
|
||||
.ToArray();
|
||||
Assert.That(pack, Has.Length.EqualTo(GameplayConstants.Current.Defense.SummonUnitCount));
|
||||
Defense25DSummon summon = pack[0];
|
||||
Vector2 anchor = summon.Actor.Position;
|
||||
Vector2 packCenter = pack
|
||||
.Select(candidate => candidate.Actor.Position)
|
||||
.Aggregate(Vector2.zero, (sum, position) => sum + position) / pack.Length;
|
||||
Vector2 awayFromPack = (anchor - packCenter).normalized;
|
||||
Assert.That(awayFromPack.sqrMagnitude, Is.GreaterThan(.5f));
|
||||
summon.Actor.ResetAt(anchor + awayFromPack * .6f);
|
||||
Physics.SyncTransforms();
|
||||
Vector2 displacedPosition = summon.Actor.Position;
|
||||
float initialDistance = Vector2.Distance(displacedPosition, anchor);
|
||||
|
||||
summon.Step(.1f);
|
||||
Assert.That(Vector2.Dot(summon.Actor.LastMove, anchor - displacedPosition),
|
||||
Is.GreaterThan(0f),
|
||||
$"The summon should move toward its anchor without intersecting its pack; "
|
||||
+ $"blocked by {summon.Actor.LastMovementBlocker?.name ?? "none"}.");
|
||||
yield return new WaitForFixedUpdate();
|
||||
|
||||
Assert.That(Vector2.Distance(summon.Actor.Position, anchor),
|
||||
Is.LessThan(initialDistance),
|
||||
"A summon on the outermost long-run pad must continue moving back to its anchor without a view.");
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator EveryLongRunPadCanBeSelectedBuiltUpgradedAndResetWithDefaultSummonPack()
|
||||
{
|
||||
Defense25DTowerManager manager = world.TowerManager;
|
||||
DefenseConstants tuning = GameplayConstants.Current.Defense;
|
||||
Assert.That(world.RunLayout.PadCount, Is.EqualTo(18));
|
||||
Assert.That(manager.PadCount, Is.EqualTo(18));
|
||||
Assert.That(manager.Towers.Count, Is.EqualTo(18));
|
||||
Assert.That(world.SelectBuildKind(DefenseTowerKind.Summon), Is.True);
|
||||
manager.AddCurrency(10_000);
|
||||
|
||||
for (int padId = 0; padId < world.RunLayout.PadCount; padId++)
|
||||
{
|
||||
Vector2 pad = world.RunLayout.PadPositions[padId];
|
||||
Assert.That(manager.GetPadPosition(padId), Is.EqualTo(pad),
|
||||
$"Pad {padId} must use this run's layout position.");
|
||||
Assert.That(world.SelectBuildPad(padId), Is.True,
|
||||
$"Pad {padId} must be selectable.");
|
||||
Assert.That(world.SelectedBuildPad, Is.EqualTo(padId));
|
||||
Assert.That(manager.GetTowerAtPad(padId), Is.Null);
|
||||
|
||||
world.Player.ResetAt(pad);
|
||||
int beforeBuild = manager.ConstructionCurrency;
|
||||
Assert.That(world.BuildSelectedTower(), Is.True,
|
||||
$"Pad {padId} must be reachable and buildable.");
|
||||
Assert.That(manager.ConstructionCurrency,
|
||||
Is.EqualTo(beforeBuild - tuning.SummonTowerCost));
|
||||
|
||||
Defense25DTower tower = manager.GetTowerAtPad(padId);
|
||||
Assert.That(manager.Towers[padId], Is.SameAs(tower));
|
||||
Assert.That(manager.TowersInConstructionOrder[padId], Is.SameAs(tower));
|
||||
Assert.That(tower.PadIndex, Is.EqualTo(padId));
|
||||
Assert.That(tower.Position, Is.EqualTo(pad));
|
||||
Assert.That(tower.Tier, Is.EqualTo(1));
|
||||
Assert.That(tower.TargetSummonCount, Is.EqualTo(tuning.SummonUnitCount));
|
||||
Assert.That(tower.SummonCount, Is.EqualTo(tuning.SummonUnitCount));
|
||||
AssertDefaultSummonPack(tower, tuning.SummonUnitCount);
|
||||
|
||||
int beforeRejectedUpgrade = manager.ConstructionCurrency;
|
||||
world.Player.ResetAt(pad + Vector2.right * (tuning.BuildInteractionRadius + .25f));
|
||||
Assert.That(world.UpgradeSelectedTower(DefenseTowerBranch.None), Is.False,
|
||||
$"Pad {padId} must reject upgrade interaction from outside its access radius.");
|
||||
Assert.That(tower.Tier, Is.EqualTo(1));
|
||||
Assert.That(manager.ConstructionCurrency, Is.EqualTo(beforeRejectedUpgrade));
|
||||
|
||||
world.Player.ResetAt(pad);
|
||||
Assert.That(world.UpgradeSelectedTower(DefenseTowerBranch.None), Is.True,
|
||||
$"Pad {padId} must allow a nearby upgrade through its selected-pad route.");
|
||||
Assert.That(tower.Tier, Is.EqualTo(2));
|
||||
Assert.That(tower.TargetSummonCount, Is.EqualTo(tuning.SummonUnitCount + 1));
|
||||
Assert.That(tower.SummonCount, Is.EqualTo(tuning.SummonUnitCount + 1));
|
||||
AssertDefaultSummonPack(tower, tuning.SummonUnitCount + 1);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
Assert.That(world.SelectBuildPad(18), Is.False,
|
||||
"A profile with 18 pads must reject the first out-of-range index.");
|
||||
Assert.That(world.SelectedBuildPad, Is.EqualTo(17),
|
||||
"A rejected index must preserve the last valid selection.");
|
||||
|
||||
int nextGeneration = world.RunGeneration + 1;
|
||||
world.ResetRun();
|
||||
yield return null;
|
||||
|
||||
Assert.That(world.RunGeneration, Is.EqualTo(nextGeneration));
|
||||
Assert.That(manager.PadCount, Is.EqualTo(18));
|
||||
Assert.That(manager.Towers.Count, Is.EqualTo(18));
|
||||
Assert.That(manager.TowersInConstructionOrder, Is.Empty);
|
||||
Assert.That(manager.ConstructionCurrency, Is.EqualTo(tuning.InitialConstructionCurrency));
|
||||
Assert.That(world.Summons, Is.Empty,
|
||||
"Restart must unregister every summon from every pad's pack.");
|
||||
for (int padId = 0; padId < 18; padId++)
|
||||
{
|
||||
Assert.That(manager.GetTowerAtPad(padId), Is.Null,
|
||||
$"Restart must clear tower state at pad {padId}.");
|
||||
Assert.That(manager.GetPadPosition(padId), Is.EqualTo(world.RunLayout.PadPositions[padId]),
|
||||
$"Restart must retain the selected profile's position for pad {padId}.");
|
||||
}
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator SuccessfulUpgradeDeductsCostAndPlaysOneFacilityEffect()
|
||||
{
|
||||
Defense25DFacilityVisualConstants facilitySettings =
|
||||
AssetDatabase.LoadAssetAtPath<Defense25DFacilityVisualConstants>(
|
||||
"Assets/_Project/Constants/Defense25DComparison/FacilityVisualConstants.asset");
|
||||
Assert.That(facilitySettings, Is.Not.Null);
|
||||
facilityVisuals = new GameObject("Manager facility visual test")
|
||||
.AddComponent<Defense25DFacilityVisuals>();
|
||||
Assert.That(facilityVisuals.Bind(world, facilitySettings, 21, out string error),
|
||||
Is.True, error);
|
||||
world.TowerManager.SetFacilityVisuals(facilityVisuals);
|
||||
|
||||
Vector2 pad = world.TowerManager.GetPadPosition(17);
|
||||
world.Player.ResetAt(pad);
|
||||
Assert.That(world.TowerManager.TryBuildTowerAtPad(17, DefenseTowerKind.Direct), Is.True);
|
||||
int beforeUpgrade = world.TowerManager.ConstructionCurrency;
|
||||
int upgradeCost = GameplayConstants.Current.Defense.Tier2UpgradeCost;
|
||||
|
||||
Assert.That(world.TowerManager.TryUpgradeTowerAtPad(17, DefenseTowerBranch.None), Is.True);
|
||||
Assert.That(world.TowerManager.ConstructionCurrency, Is.EqualTo(beforeUpgrade - upgradeCost));
|
||||
Assert.That(world.TowerManager.GetTowerAtPad(17).Tier, Is.EqualTo(2));
|
||||
Assert.That(facilityVisuals.transform.childCount, Is.EqualTo(1),
|
||||
"One successful upgrade invokes the visual hook once.");
|
||||
GameObject effect = GameObject.Find("Facility Tower Upgrade Pad 17 T2");
|
||||
Assert.That(effect, Is.Not.Null);
|
||||
|
||||
int afterUpgrade = world.TowerManager.ConstructionCurrency;
|
||||
Assert.That(world.TowerManager.TryUpgradeTowerAtPad(17, DefenseTowerBranch.First), Is.False,
|
||||
"A rejected branch cannot invoke the facility hook or consume currency.");
|
||||
Assert.That(world.TowerManager.ConstructionCurrency, Is.EqualTo(afterUpgrade));
|
||||
Assert.That(facilityVisuals.transform.childCount, Is.EqualTo(1));
|
||||
Assert.That(GameObject.Find("Facility Tower Upgrade Pad 17 T2"), Is.SameAs(effect));
|
||||
yield return null;
|
||||
}
|
||||
|
||||
private static ComparisonSpriteFrames ReadCharacter(
|
||||
string folder,
|
||||
string prefix,
|
||||
string attack)
|
||||
{
|
||||
Sprite[] Read(string suffix) => AssetDatabase.LoadAllAssetsAtPath(
|
||||
$"Assets/_Project/Art/Characters/{folder}/{prefix}_{suffix}.png")
|
||||
.OfType<Sprite>()
|
||||
.OrderBy(sprite => int.TryParse(
|
||||
sprite.name[(sprite.name.LastIndexOf('_') + 1)..], out int frame)
|
||||
? frame
|
||||
: 0)
|
||||
.ToArray();
|
||||
return new ComparisonSpriteFrames
|
||||
{
|
||||
Idle = Read("Idle"),
|
||||
Walk = Read("Walk"),
|
||||
Attack = Read(attack),
|
||||
Hurt = Read("Hurt"),
|
||||
Death = Read("Death"),
|
||||
};
|
||||
}
|
||||
|
||||
private void AssertDefaultSummonPack(Defense25DTower tower, int expectedCount)
|
||||
{
|
||||
Defense25DSummon[] pack = world.Summons
|
||||
.Where(summon => summon != null && summon.Owner == tower)
|
||||
.OrderBy(summon => summon.SlotIndex)
|
||||
.ToArray();
|
||||
Assert.That(pack, Has.Length.EqualTo(expectedCount));
|
||||
CollectionAssert.AreEqual(Enumerable.Range(0, expectedCount),
|
||||
pack.Select(summon => summon.SlotIndex));
|
||||
Assert.That(pack.All(summon => summon.Owner == tower
|
||||
&& summon.Actor != null && summon.Actor.IsAlive), Is.True);
|
||||
Assert.That(pack.All(summon => Vector2.Distance(summon.Actor.Position, tower.Position) < 2f),
|
||||
Is.True, "The default summon pack must start anchored around its owning pad.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 05eb1328f29145a8ab39bc8a85596458
|
||||
@@ -0,0 +1,149 @@
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Defense;
|
||||
using BumpCombat.Defense.Comparison;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace BumpCombat.PlayModeTests
|
||||
{
|
||||
public sealed class Defense25DTowerModifierPlayModeTests
|
||||
{
|
||||
[Test]
|
||||
public void AttackAndAreaStacksShareCapsAcrossRaritiesAndSightIsAppliedOnce()
|
||||
{
|
||||
Defense25DRewardConstants constants =
|
||||
UnityEngine.ScriptableObject.CreateInstance<Defense25DRewardConstants>();
|
||||
try
|
||||
{
|
||||
Defense25DTowerModifiers modifiers = new(constants);
|
||||
Defense25DTowerModifierDefinition commonAttack = Find(
|
||||
constants,
|
||||
"fire.attack.common");
|
||||
Defense25DTowerModifierDefinition rareAttack = Find(
|
||||
constants,
|
||||
"fire.attack.rare");
|
||||
Defense25DTowerModifierDefinition commonArea = Find(
|
||||
constants,
|
||||
"fire.area.common");
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
Assert.That(modifiers.Apply(
|
||||
Defense25DRewardChoice.CreateTowerModifier(commonAttack)),
|
||||
Is.True);
|
||||
}
|
||||
Assert.That(modifiers.Apply(
|
||||
Defense25DRewardChoice.CreateTowerModifier(rareAttack)),
|
||||
Is.False,
|
||||
"Rarity variants share the family/effect stack cap.");
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
Assert.That(modifiers.Apply(
|
||||
Defense25DRewardChoice.CreateTowerModifier(commonArea)),
|
||||
Is.True);
|
||||
}
|
||||
Assert.That(modifiers.GetStackCount(
|
||||
DefenseTowerKind.Direct,
|
||||
Defense25DTowerModifierEffect.AreaRadiusIncreased), Is.EqualTo(3));
|
||||
|
||||
Defense25DTowerStatInput tower = new(
|
||||
DefenseTowerKind.Direct,
|
||||
baseDamage: 100f,
|
||||
baseAreaRadius: 2f,
|
||||
hasAreaEffect: true,
|
||||
tierDamageMultiplier: 1.2f,
|
||||
supportDamageMultiplier: 1.1f,
|
||||
relicDamageMultiplier: 1.15f,
|
||||
tierAreaMultiplier: 1f,
|
||||
relicAreaMultiplier: 1f,
|
||||
sightEfficiency: 0.7f);
|
||||
Defense25DTowerEffectiveStats effective =
|
||||
modifiers.GetEffectiveStats(tower);
|
||||
|
||||
Assert.That(effective.DamageBeforeSight,
|
||||
Is.EqualTo(100f * 1.2f * 1.1f * 1.15f * 1.5f).Within(0.001f));
|
||||
Assert.That(effective.Damage,
|
||||
Is.EqualTo(effective.DamageBeforeSight * 0.7f).Within(0.001f));
|
||||
Assert.That(effective.AreaRadius,
|
||||
Is.EqualTo(2f * 1.3f).Within(0.001f));
|
||||
}
|
||||
finally
|
||||
{
|
||||
UnityEngine.Object.DestroyImmediate(constants);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ProjectileEffectsApplyOnlyToEligibleColdProjectilesAndResetClearsRunState()
|
||||
{
|
||||
Defense25DRewardConstants constants =
|
||||
UnityEngine.ScriptableObject.CreateInstance<Defense25DRewardConstants>();
|
||||
try
|
||||
{
|
||||
Defense25DTowerModifiers modifiers = new(constants);
|
||||
Assert.That(modifiers.Apply(Defense25DRewardChoice.CreateTowerModifier(
|
||||
Find(constants, "cold.projectile.epic"))), Is.True);
|
||||
Assert.That(modifiers.Apply(Defense25DRewardChoice.CreateTowerModifier(
|
||||
Find(constants, "cold.split.epic"))), Is.True);
|
||||
Assert.That(modifiers.Apply(Defense25DRewardChoice.CreateTowerModifier(
|
||||
Find(constants, "cold.return.epic"))), Is.True);
|
||||
|
||||
Defense25DTowerEffectiveStats coldBolt = modifiers.GetEffectiveStats(
|
||||
new Defense25DTowerStatInput(
|
||||
DefenseTowerKind.Cold,
|
||||
baseDamage: 10f,
|
||||
baseAreaRadius: 0f,
|
||||
isProjectileAttack: true));
|
||||
Assert.That(coldBolt.ProjectileCount, Is.EqualTo(2));
|
||||
Assert.That(coldBolt.SplitsOnFirstValidHit, Is.True);
|
||||
Assert.That(coldBolt.ReturnsOnceToOrigin, Is.True);
|
||||
|
||||
Defense25DTowerEffectiveStats blizzard = modifiers.GetEffectiveStats(
|
||||
new Defense25DTowerStatInput(
|
||||
DefenseTowerKind.Cold,
|
||||
baseDamage: 10f,
|
||||
baseAreaRadius: 2f,
|
||||
hasAreaEffect: true,
|
||||
isProjectileAttack: false));
|
||||
Assert.That(blizzard.ProjectileCount, Is.EqualTo(1));
|
||||
Assert.That(blizzard.SplitsOnFirstValidHit, Is.False);
|
||||
Assert.That(blizzard.ReturnsOnceToOrigin, Is.False);
|
||||
|
||||
modifiers.Reset();
|
||||
Assert.That(modifiers.AppliedChoiceCount, Is.Zero);
|
||||
Assert.That(modifiers.GetEffectiveStats(coldBoltInput()).ProjectileCount,
|
||||
Is.EqualTo(1));
|
||||
}
|
||||
finally
|
||||
{
|
||||
UnityEngine.Object.DestroyImmediate(constants);
|
||||
}
|
||||
}
|
||||
|
||||
private static Defense25DTowerStatInput coldBoltInput()
|
||||
{
|
||||
return new Defense25DTowerStatInput(
|
||||
DefenseTowerKind.Cold,
|
||||
baseDamage: 10f,
|
||||
baseAreaRadius: 0f,
|
||||
isProjectileAttack: true);
|
||||
}
|
||||
|
||||
private static Defense25DTowerModifierDefinition Find(
|
||||
Defense25DRewardConstants constants,
|
||||
string definitionId)
|
||||
{
|
||||
foreach (Defense25DTowerModifierDefinition definition
|
||||
in constants.TowerModifierCatalog)
|
||||
{
|
||||
if (definition.DefinitionId == definitionId)
|
||||
{
|
||||
return definition;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Fail($"Missing test tower modifier '{definitionId}'.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c93fcf205e7547cca9cd76bc0d87672e
|
||||
Reference in New Issue
Block a user