using System.Collections.Generic; using BumpCombat.Combat; using BumpCombat.Constants; using BumpCombat.Defense.Comparison; using BumpCombat.Enemies; using BumpCombat.Player; using BumpCombat.Progression; using NUnit.Framework; using UnityEngine; using Object = UnityEngine.Object; namespace BumpCombat.Tests { public sealed class Defense25DRelicRuntimeTests { private PassiveRelicConstants tuning; private Defense25DLoadout loadout; private readonly List artifactDefinitions = new(); [SetUp] public void SetUp() { tuning = ScriptableObject.CreateInstance(); loadout = new Defense25DLoadout(0f, 100f, 6, 6); } [TearDown] public void TearDown() { Object.DestroyImmediate(tuning); foreach (ActiveArtifactDefinition definition in artifactDefinitions) Object.DestroyImmediate(definition); artifactDefinitions.Clear(); } [Test] public void TwinHornOnlyDoublesOrdinaryUncoloredBumpsWhenTheConfiguredRollSucceeds() { tuning.TwinHornDoubleDamageChance = 1f; loadout.TryAcquireRelic(PassiveRelicId.TwinHornTalisman); Defense25DRelicRuntime runtime = NewRuntime(new Defense25DRelicEffects()); Assert.That(runtime.ApplyOrdinaryBumpDamage(10f, true, false), Is.EqualTo(20f)); Assert.That(runtime.ApplyOrdinaryBumpDamage(10f, false, false), Is.EqualTo(10f)); Assert.That(runtime.ApplyOrdinaryBumpDamage(10f, true, true), Is.EqualTo(10f)); } [Test] public void WaveNailAppliesSourceDamageToNearbyEnemiesAndStartsCooldownOnlyOnAppliedHit() { tuning.WaveNailChance = 1f; tuning.WaveNailDamageFraction = .6f; tuning.WaveNailCooldownSeconds = 1.5f; loadout.TryAcquireRelic(PassiveRelicId.WaveNail); int applied = 0; int attempts = 0; bool allowDamage = false; float observedDamage = 0f; EnemyDamageSource observedSource = EnemyDamageSource.None; Defense25DRelicTarget nearby = Target(2, new Vector3(1f, 0f, 0f)); Defense25DRelicEffects effects = new() { QueryEnemies = (_, _) => new[] { nearby }, DamageEnemy = (_, damage, _, source) => { attempts++; observedDamage = damage; observedSource = source; if (!allowDamage) return false; applied++; return true; }, }; Defense25DRelicRuntime runtime = NewRuntime(effects); runtime.BodyDiameterProvider = () => 1f; runtime.NotifyHit(new Defense25DRelicHit(Target(1, Vector3.zero), Vector3.zero, Vector3.right, 10f, 10f, true), 100f); allowDamage = true; runtime.NotifyHit(new Defense25DRelicHit(Target(0, Vector3.zero), Vector3.zero, Vector3.right, 10f, 10f, true), 100f); runtime.Advance(1f, 100f); runtime.NotifyHit(new Defense25DRelicHit(Target(0, Vector3.zero), Vector3.zero, Vector3.right, 10f, 10f, true), 100f); Assert.That(applied, Is.EqualTo(1)); Assert.That(attempts, Is.EqualTo(2)); Assert.That(observedDamage, Is.EqualTo(6f)); Assert.That(observedSource, Is.EqualTo(EnemyDamageSource.PassiveRelicAttack)); } [Test] public void CounterBellSlowsEveryLivingEnemyInItsQueryInsteadOfStoppingAtFirstSuccess() { tuning.CounterBellChance = 1f; loadout.TryAcquireRelic(PassiveRelicId.CounterBell); List slowed = new(); Defense25DRelicTarget[] targets = { Target(1, Vector3.zero), Target(2, Vector3.right), new(3, Vector3.forward, 10f, 10f, EnemyDamageSource.None, true), }; Defense25DRelicRuntime runtime = NewRuntime(new Defense25DRelicEffects { QueryEnemies = (_, _) => targets, ApplySlow = (target, _, _) => { slowed.Add(target.Identity); return true; }, }); runtime.BodyDiameterProvider = () => 1f; runtime.NotifyPlayerDamageTaken(1f, 99f, 100f, Vector3.zero); Assert.That(slowed, Is.EquivalentTo(new[] { 1, 2 })); } [Test] public void LethalValidHitStillRunsWaveNailBloodFangAndResonanceCore() { tuning.WaveNailChance = 1f; tuning.ResonanceEchoChance = 1f; tuning.BloodFangChance = 1f; loadout.TryAcquireRelic(PassiveRelicId.WaveNail); loadout.TryAcquireRelic(PassiveRelicId.BloodFang); loadout.TryAcquireRelic(PassiveRelicId.ResonanceCore); ActiveArtifactDefinition artifact = Artifact(ActiveArtifactEffect.Pulse); Assert.That(loadout.TryAddArtifact(artifact), Is.True); int damageCalls = 0, healCalls = 0, echoCalls = 0; Defense25DRelicTarget nearby = Target(11, Vector3.right); Defense25DRelicRuntime runtime = NewRuntime(new Defense25DRelicEffects { QueryEnemies = (_, _) => new[] { nearby }, DamageEnemy = (_, _, _, _) => { damageCalls++; return true; }, HealPlayer = _ => { healCalls++; return true; }, EmitEcho = (use, _, _, _) => { Assert.That(use.Definition, Is.SameAs(artifact)); echoCalls++; return true; }, }); runtime.BodyDiameterProvider = () => 1f; runtime.NotifyHit(new Defense25DRelicHit( Target(10, Vector3.zero, isDead: true), Vector3.zero, Vector3.right, 10f, 10f, true), 50f, 100f); Assert.That(damageCalls, Is.EqualTo(1)); Assert.That(healCalls, Is.EqualTo(1)); Assert.That(echoCalls, Is.EqualTo(1)); } [Test] public void WaveNailCanDamageTheOriginalHitTargetWhenItSurvivesTheBump() { tuning.WaveNailChance = 1f; loadout.TryAcquireRelic(PassiveRelicId.WaveNail); List damaged = new(); Defense25DRelicTarget struck = Target(0, Vector3.zero); Defense25DRelicTarget neighbor = Target(1, Vector3.right); Defense25DRelicRuntime runtime = NewRuntime(new Defense25DRelicEffects { QueryEnemies = (_, _) => new[] { struck, neighbor }, DamageEnemy = (target, _, _, _) => { damaged.Add(target.Identity); return true; }, }); runtime.BodyDiameterProvider = () => 1f; runtime.NotifyHit(new Defense25DRelicHit(struck, Vector3.zero, Vector3.right, 10f, 10f, true), 100f); Assert.That(damaged, Is.EquivalentTo(new[] { 0, 1 })); } [Test] public void WaveNailIncludesAnEnemyWhoseBodyOverlapsTheAreaAtItsOuterEdge() { tuning.WaveNailChance = 1f; loadout.TryAcquireRelic(PassiveRelicId.WaveNail); List damaged = new(); Defense25DRelicTarget boundaryTarget = Target(12, new Vector3(3.2f, 0f, 0f)); const float targetBodyRadius = .3f; Defense25DRelicRuntime runtime = NewRuntime(new Defense25DRelicEffects { QueryEnemies = (center, areaRadius) => Vector3.Distance(new Vector3(center.x, 0f, center.z), new Vector3(boundaryTarget.Position.x, 0f, boundaryTarget.Position.z)) <= areaRadius + targetBodyRadius ? new[] { boundaryTarget } : System.Array.Empty(), DamageEnemy = (target, _, _, _) => { damaged.Add(target.Identity); return true; }, }); runtime.BodyDiameterProvider = () => 1f; runtime.NotifyHit(new Defense25DRelicHit(Target(0, Vector3.zero), Vector3.zero, Vector3.right, 10f, 10f, true), 100f); Assert.That(damaged, Is.EqualTo(new[] { boundaryTarget.Identity })); } [Test] public void RiftWedgeAppliesThunderCrashVulnerabilityOnTheConfiguredOrdinaryHitCount() { tuning.RiftWedgeRequiredHits = 3; loadout.TryAcquireRelic(PassiveRelicId.RiftWedge); ActiveArtifactDefinition thunder = Artifact(ActiveArtifactEffect.ThunderCrash, normalVulnerabilityDuration: 2.5f, normalVulnerabilityIncrease: .3f); loadout.ConfigureCatalog(new[] { thunder }); int applied = 0; Defense25DRelicEffects effects = new() { ApplyVulnerability = (_, duration, increase) => { Assert.That(duration, Is.EqualTo(2.5f)); Assert.That(increase, Is.EqualTo(.3f)); applied++; return true; }, }; Defense25DRelicRuntime runtime = NewRuntime(effects); Defense25DRelicTarget target = Target(7, Vector3.zero); for (int i = 0; i < 2; i++) runtime.NotifyHit(new Defense25DRelicHit(target, Vector3.zero, Vector3.right, 1f, 0f, true), 100f); Assert.That(applied, Is.Zero); runtime.NotifyHit(new Defense25DRelicHit(target, Vector3.zero, Vector3.right, 1f, 0f, true), 100f); Assert.That(applied, Is.EqualTo(1)); } [Test] public void BondLedgerPaysOnlyForTowerKillsAfterRecentBumpAndDeduplicatesDeaths() { tuning.BondLedgerChance = 1f; tuning.BondLedgerCurrency = 2; tuning.BondLedgerBumpWindowSeconds = 3f; loadout.TryAcquireRelic(PassiveRelicId.BondLedger); int currency = 0; Defense25DRelicRuntime runtime = NewRuntime(new Defense25DRelicEffects { AwardCurrency = amount => { currency += amount; return true; }, }); Defense25DRelicTarget target = Target(0, Vector3.zero); runtime.NotifyHit(new Defense25DRelicHit(target, Vector3.zero, Vector3.right, 1f, 0f, true), 100f); runtime.Advance(1f, 100f); Defense25DRelicTarget towerKill = Target(0, Vector3.zero, EnemyDamageSource.Tower); runtime.NotifyEnemyDeath(towerKill, 100f); runtime.NotifyEnemyDeath(towerKill, 100f); Assert.That(currency, Is.EqualTo(2)); } [Test] public void RunResetClearsRuntimeTimersAndTemporaryModifiersThroughOwnerCallbacks() { tuning.HunterFeatherChance = 1f; loadout.TryAcquireRelic(PassiveRelicId.HunterFeather); int added = 0, removed = 0; Defense25DRelicRuntime runtime = NewRuntime(new Defense25DRelicEffects { SetTimedPlayerModifier = (_, _) => added++, RemovePlayerModifier = _ => removed++, }); runtime.NotifyEnemyDeath(Target(8, Vector3.zero, EnemyDamageSource.Player), 100f); Assert.That(runtime.HunterExpiresAt, Is.GreaterThan(0f)); runtime.ResetRun(4f); Assert.That(runtime.IsActive, Is.False); Assert.That(runtime.HunterExpiresAt, Is.Zero); Assert.That(added, Is.EqualTo(1)); Assert.That(removed, Is.GreaterThanOrEqualTo(1)); } [Test] public void ResonantCrystalRepeatExpiresIfItsDelayCompletesWhilePlayerIsDead() { tuning.ResonantCrystalChance = 1f; tuning.ResonantCrystalDelaySeconds = 1f; ActiveArtifactDefinition artifact = Artifact(ActiveArtifactEffect.Cyclone); Assert.That(loadout.TryAddArtifact(artifact), Is.True); Assert.That(loadout.TryAcquireRelic(PassiveRelicId.ResonantCrystal), Is.True); int repeats = 0; Defense25DRelicRuntime runtime = NewRuntime(new Defense25DRelicEffects { EmitRepeat = (_, _, _, _) => { repeats++; return true; }, }); runtime.NotifyArtifactUse(new Defense25DArtifactUse(artifact, false), Vector3.zero, Vector3.right, 100f); Assert.That(runtime.IsRepeatPending, Is.True); runtime.Advance(1f, 0f); runtime.Advance(2f, 100f); Assert.That(runtime.IsRepeatPending, Is.False); Assert.That(repeats, Is.Zero); } [Test] public void ResonantCrystalRepeatStillFiresAfterDeathIfDelayHasNotEndedYet() { tuning.ResonantCrystalChance = 1f; tuning.ResonantCrystalDelaySeconds = 1f; ActiveArtifactDefinition artifact = Artifact(ActiveArtifactEffect.Cyclone); Assert.That(loadout.TryAddArtifact(artifact), Is.True); Assert.That(loadout.TryAcquireRelic(PassiveRelicId.ResonantCrystal), Is.True); int repeats = 0; Defense25DRelicRuntime runtime = NewRuntime(new Defense25DRelicEffects { EmitRepeat = (_, _, _, _) => { repeats++; return true; }, }); runtime.NotifyArtifactUse(new Defense25DArtifactUse(artifact, false), Vector3.zero, Vector3.right, 100f); runtime.Advance(.5f, 0f); Assert.That(runtime.IsRepeatPending, Is.True); runtime.Advance(1f, 100f); Assert.That(runtime.IsRepeatPending, Is.False); Assert.That(repeats, Is.EqualTo(1)); } private Defense25DRelicRuntime NewRuntime(Defense25DRelicEffects effects) { Defense25DRelicRuntime runtime = new(loadout, tuning, effects, () => 0f); runtime.BeginRun(); return runtime; } private ActiveArtifactDefinition Artifact( ActiveArtifactEffect effect, float normalVulnerabilityDuration = 0f, float normalVulnerabilityIncrease = 0f) { ActiveArtifactDefinition definition = ScriptableObject.CreateInstance(); definition.Configure(effect.ToString(), effect.ToString(), "*", effect, DamageTag.Collision, Color.white, Color.white, 0f, 0f, 1f, 1f, 1f, 1f, 1f, 1f, 1f, normalBumpVulnerabilityIncrease: normalVulnerabilityIncrease, normalBumpVulnerabilityDuration: normalVulnerabilityDuration); artifactDefinitions.Add(definition); return definition; } private static Defense25DRelicTarget Target(int id, Vector3 position, EnemyDamageSource source = EnemyDamageSource.None, bool isDead = false) => new(id, position, 100f, 100f, source, isDead); } }