401 lines
20 KiB
C#
401 lines
20 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|