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
@@ -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);
}
}
}