2.5 update

This commit is contained in:
2026-09-28 11:07:17 +09:00
parent abd2da2057
commit 7978d82196
976 changed files with 111965 additions and 313 deletions
@@ -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));
}
}
}