using System.Reflection; using System.Collections.Generic; using BumpCombat.Combat; using BumpCombat.Constants; using BumpCombat.Core; using BumpCombat.Enemies; using BumpCombat.Player; using BumpCombat.Progression; using BumpCombat.Spawning; using NUnit.Framework; using UnityEditor; using UnityEngine; namespace BumpCombat.Tests { public sealed class ActiveArtifactControllerTests { private GameObject owner; private GameObject runManagerObject; private RunManager previousRunManagerInstance; private ActiveArtifactController controller; [SetUp] public void SetUp() { RunManager existingRunManager = RunManager.Instance; previousRunManagerInstance = existingRunManager == null ? null : existingRunManager; RunManager.ForceProductionModeForTests = false; owner = new GameObject("Active Artifact Tests"); owner.AddComponent(); owner.AddComponent(); controller = owner.AddComponent(); typeof(ActiveArtifactController) .GetField( "maxOwnedArtifacts", BindingFlags.Instance | BindingFlags.NonPublic) ?.SetValue(controller, 6); typeof(ActiveArtifactController) .GetField( "startingGauge", BindingFlags.Instance | BindingFlags.NonPublic) ?.SetValue(controller, 100f); typeof(ActiveArtifactController) .GetMethod( "Awake", BindingFlags.Instance | BindingFlags.NonPublic) ?.Invoke(controller, null); } [TearDown] public void TearDown() { RunManager.ForceProductionModeForTests = false; Object.DestroyImmediate(owner); if (runManagerObject != null) { Object.DestroyImmediate(runManagerObject); runManagerObject = null; } RestoreRunManagerInstance(previousRunManagerInstance); previousRunManagerInstance = null; } [Test] public void DebugGrantCatalogArtifacts_TestHarnessAcceptsSixAndRestoresThreeSlotCap() { ActiveArtifactDefinition first = CreatePulse("first", 25f, 60f); ActiveArtifactDefinition duplicate = CreatePulse("first", 10f, 20f); ActiveArtifactDefinition second = CreatePulse( "second", 25f, 60f, ActiveArtifactEffect.Dash); ActiveArtifactDefinition third = CreatePulse( "third", 25f, 60f, ActiveArtifactEffect.ThunderCrash); ActiveArtifactDefinition fourth = CreatePulse( "fourth", 25f, 60f, ActiveArtifactEffect.Cyclone); ActiveArtifactDefinition fifth = CreatePulse( "fifth", 25f, 60f, ActiveArtifactEffect.Phoenix); ActiveArtifactDefinition sixth = CreatePulse( "sixth", 25f, 60f, ActiveArtifactEffect.ChainLightning); controller.Configure( new[] { first, second, third, fourth, fifth, sixth }); controller.DebugGrantCatalogArtifacts(); Assert.That(controller.OwnedArtifactCount, Is.EqualTo(6)); Assert.That(controller.MaxOwnedArtifacts, Is.EqualTo(3)); Assert.That(controller.MaxArtifactsPerColor, Is.EqualTo(1)); Assert.That(controller.CurrentGauge, Is.EqualTo(controller.MaxGauge)); Assert.That(controller.TryAddArtifact(fourth), Is.False); Assert.That(controller.TryAddArtifact(duplicate), Is.False); DestroyDefinitions( first, duplicate, second, third, fourth, fifth, sixth); } [Test] public void EditorDebugBuild_EnablesArtifactStartupSelection() { GameObject runManagerObject = new("Debug Run Manager"); try { RunManager runManager = runManagerObject.AddComponent(); typeof(RunManager) .GetField( "useDebugEventTimes", BindingFlags.Instance | BindingFlags.NonPublic) ?.SetValue(runManager, false); typeof(RunManager) .GetMethod( "Awake", BindingFlags.Instance | BindingFlags.NonPublic) ?.Invoke(runManager, null); Assert.That(RunManager.Instance.UseDebugEventTimes, Is.False); Assert.That(Debug.isDebugBuild, Is.True); Assert.That(RunManager.Instance.UseDebugArtifactSelection, Is.True); } finally { Object.DestroyImmediate(runManagerObject); } } [Test] public void SelectNext_CyclesAcrossAllTestArtifacts() { // This is the explicit six-item debug harness; normal ownership // remains one artifact per color and three total. typeof(ActiveArtifactController) .GetField( "maxArtifactsPerColor", BindingFlags.Instance | BindingFlags.NonPublic) ?.SetValue(controller, 6); ActiveArtifactDefinition[] definitions = { CreatePulse("first", 25f, 60f), CreatePulse("second", 25f, 60f), CreatePulse("third", 25f, 60f), CreatePulse("fourth", 25f, 60f), CreatePulse("fifth", 25f, 60f), CreatePulse("sixth", 25f, 60f), }; foreach (ActiveArtifactDefinition definition in definitions) { controller.TryAddArtifact(definition); } Assert.That(controller.CurrentArtifact, Is.SameAs(definitions[0])); for (int i = 1; i < definitions.Length; i++) { Assert.That(controller.SelectNext(), Is.True); Assert.That( controller.CurrentArtifact, Is.SameAs(definitions[i])); } Assert.That(controller.SelectNext(), Is.True); Assert.That(controller.CurrentArtifact, Is.SameAs(definitions[0])); DestroyDefinitions(definitions); } [Test] public void NormalAndChargedUse_SpendOneSharedGauge() { ActiveArtifactDefinition pulse = CreatePulse("pulse", 25f, 60f); controller.TryAddArtifact(pulse); Assert.That(controller.TryUseCurrent(false), Is.True); Assert.That(controller.CurrentGauge, Is.EqualTo(75f)); Assert.That(controller.TryUseCurrent(true), Is.True); Assert.That(controller.CurrentGauge, Is.EqualTo(15f)); Assert.That(controller.TryUseCurrent(false), Is.False); Assert.That(controller.CurrentGauge, Is.EqualTo(15f)); DestroyDefinitions(pulse); } [Test] public void ProductionChargeLocked_HoldOnlyUsesNormalArtifactOnceOnRelease() { RunManager runManager = CreateRunManager(RunMode.Production); ActiveArtifactDefinition pulse = CreatePulse("locked-pulse", 25f, 60f); try { Assert.That(runManager.IsArtifactChargeUnlocked, Is.False); Assert.That(controller.TryAddArtifact(pulse), Is.True); int chargeStartedCount = 0; int useCount = 0; bool lastUseWasCharged = true; controller.OnArtifactChargeStarted += _ => chargeStartedCount++; controller.OnArtifactUseSucceeded += (_, charged) => { useCount++; lastUseWasCharged = charged; }; InvokeControllerMethod("HandleArtifactButtonPressed"); Assert.That(controller.IsCharging, Is.False); Assert.That(controller.ChargeProgress, Is.Zero); Assert.That(chargeStartedCount, Is.Zero); Assert.That(controller.CurrentGauge, Is.EqualTo(100f)); Assert.That(controller.TryUseCurrent(true), Is.False); Assert.That(controller.CurrentGauge, Is.EqualTo(100f)); InvokeControllerMethod("HandleArtifactButtonReleased"); InvokeControllerMethod("HandleArtifactButtonReleased"); Assert.That(controller.CurrentGauge, Is.EqualTo(75f)); Assert.That(useCount, Is.EqualTo(1)); Assert.That(lastUseWasCharged, Is.False); Assert.That(controller.IsCharging, Is.False); } finally { DestroyDefinitions(pulse); } } [Test] public void ProductionChargeUnlock_RestoresChargedHoldUse() { RunManager runManager = CreateRunManager(RunMode.Production); ActiveArtifactDefinition pulse = CreatePulse("unlocked-pulse", 25f, 60f); try { Assert.That(controller.TryAddArtifact(pulse), Is.True); Assert.That(runManager.IsArtifactChargeUnlocked, Is.False); runManager.UnlockArtifactCharge(); Assert.That(runManager.IsArtifactChargeUnlocked, Is.True); int chargedUseCount = 0; controller.OnArtifactUseSucceeded += (_, charged) => { if (charged) { chargedUseCount++; } }; InvokeControllerMethod("HandleArtifactButtonPressed"); Assert.That(controller.IsCharging, Is.True); typeof(ActiveArtifactController) .GetField( "chargeStartedAt", BindingFlags.Instance | BindingFlags.NonPublic) ?.SetValue( controller, Time.time - pulse.ChargeDuration - 0.01f); InvokeControllerMethod("HandleArtifactButtonReleased"); Assert.That(controller.IsCharging, Is.False); Assert.That(controller.CurrentGauge, Is.EqualTo(40f)); Assert.That(chargedUseCount, Is.EqualTo(1)); } finally { DestroyDefinitions(pulse); } } [Test] public void DevelopmentRun_KeepsArtifactChargingUnlocked() { RunManager runManager = CreateRunManager(RunMode.Development); ActiveArtifactDefinition pulse = CreatePulse("development-pulse", 25f, 60f); try { Assert.That(runManager.IsArtifactChargeUnlocked, Is.True); Assert.That(controller.TryAddArtifact(pulse), Is.True); InvokeControllerMethod("HandleArtifactButtonPressed"); Assert.That(controller.IsCharging, Is.True); } finally { DestroyDefinitions(pulse); } } [Test] public void ProductionChargeLocked_SwitchingArtifactCancelsPendingNormalUse() { CreateRunManager(RunMode.Production); typeof(ActiveArtifactController) .GetField( "maxArtifactsPerColor", BindingFlags.Instance | BindingFlags.NonPublic) ?.SetValue(controller, 2); ActiveArtifactDefinition first = CreatePulse("locked-first", 25f, 60f); ActiveArtifactDefinition second = CreatePulse("locked-second", 25f, 60f); try { Assert.That(controller.TryAddArtifact(first), Is.True); Assert.That(controller.TryAddArtifact(second), Is.True); int useCount = 0; controller.OnArtifactUseSucceeded += (_, _) => useCount++; InvokeControllerMethod("HandleArtifactButtonPressed"); Assert.That(controller.SelectNext(), Is.True); InvokeControllerMethod("HandleArtifactButtonReleased"); Assert.That(controller.CurrentGauge, Is.EqualTo(100f)); Assert.That(useCount, Is.Zero); } finally { DestroyDefinitions(first, second); } } [Test] public void MovementAndNormalHit_AddConfiguredGaugeAmounts() { ActiveArtifactDefinition pulse = CreatePulse("pulse", 100f, 100f); controller.TryAddArtifact(pulse); Assert.That(controller.TryUseCurrent(false), Is.True); Assert.That(controller.CurrentGauge, Is.Zero); controller.AddMovementCharge(1f); controller.AddNormalHitCharge(); Assert.That(controller.CurrentGauge, Is.EqualTo(6f)); DestroyDefinitions(pulse); } [Test] public void PrototypeDefinitions_PhoenixPiercesAndThunderUsesFrontEffect() { ActiveArtifactDefinition phoenix = AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Artifacts/PhoenixArtifact.asset"); ActiveArtifactDefinition thunder = AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Artifacts/ThunderCrashArtifact.asset"); Assert.That(phoenix, Is.Not.Null); Assert.That(phoenix.Effect, Is.EqualTo(ActiveArtifactEffect.Phoenix)); Assert.That(phoenix.DisplayName, Is.EqualTo("잿불의 마검")); Assert.That(phoenix.PlaceholderSymbol, Is.EqualTo("SR")); Assert.That(phoenix.NormalRange, Is.EqualTo(3f)); Assert.That(phoenix.NormalWidth, Is.EqualTo(0.4375f)); Assert.That(phoenix.NormalDamage, Is.EqualTo(35f)); Assert.That(phoenix.ChargedDamage, Is.EqualTo(70f)); Assert.That(phoenix.NormalKnockback, Is.EqualTo(0.2f)); Assert.That(phoenix.ChargedKnockback, Is.EqualTo(0.3f)); Assert.That(phoenix.ChargedRange, Is.EqualTo(6f)); Assert.That(phoenix.ChargedWidth, Is.EqualTo(0.875f)); Assert.That(phoenix.ChargedDuration, Is.EqualTo(0.3f)); Assert.That(phoenix.ChargedMaxHitsPerTarget, Is.EqualTo(1)); Assert.That(phoenix.NormalIgniteDuration, Is.EqualTo(3f)); Assert.That(phoenix.NormalIgniteInterval, Is.EqualTo(0.5f)); Assert.That(phoenix.NormalIgniteTickDamage, Is.EqualTo(3f)); Assert.That(phoenix.ChargedIgniteTickDamage, Is.EqualTo(5f)); Assert.That(thunder, Is.Not.Null); Assert.That( thunder.Effect, Is.EqualTo(ActiveArtifactEffect.ThunderCrash)); Assert.That(thunder.NormalRange, Is.EqualTo(2f)); Assert.That(thunder.ChargedRange, Is.EqualTo(3f)); Assert.That(thunder.NormalDuration, Is.EqualTo(0.3f)); Assert.That(thunder.ChargedDuration, Is.EqualTo(0.55f)); Assert.That(thunder.NormalKnockback, Is.Zero); Assert.That(thunder.ChargedKnockback, Is.Zero); } [Test] public void PrototypeArtifactDefinitions_UseConfirmedEquipmentDisplayNames() { string[] paths = { "Assets/_Project/Constants/Artifacts/DashArtifact.asset", "Assets/_Project/Constants/Artifacts/PulseArtifact.asset", "Assets/_Project/Constants/Artifacts/PhoenixArtifact.asset", "Assets/_Project/Constants/Artifacts/CycloneArtifact.asset", "Assets/_Project/Constants/Artifacts/ThunderCrashArtifact.asset", "Assets/_Project/Constants/Artifacts/ChainLightningArtifact.asset", }; string[] expectedNames = { "척후의 장화", "군주의 방패", "잿불의 마검", "바람깃 브로치", "뇌격의 수갑", "창뢰의 보주", }; ActiveArtifactEffect[] expectedEffects = { ActiveArtifactEffect.Dash, ActiveArtifactEffect.Pulse, ActiveArtifactEffect.Phoenix, ActiveArtifactEffect.Cyclone, ActiveArtifactEffect.ThunderCrash, ActiveArtifactEffect.ChainLightning, }; for (int i = 0; i < paths.Length; i++) { ActiveArtifactDefinition definition = AssetDatabase.LoadAssetAtPath(paths[i]); Assert.That(definition, Is.Not.Null, paths[i]); Assert.That(definition.Effect, Is.EqualTo(expectedEffects[i])); Assert.That(definition.DisplayName, Is.EqualTo(expectedNames[i])); } } [Test] public void PrototypeDefinition_PulseUsesRepelAndDefenseValues() { ActiveArtifactDefinition pulse = AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Artifacts/PulseArtifact.asset"); Assert.That(pulse, Is.Not.Null); Assert.That(pulse.Effect, Is.EqualTo(ActiveArtifactEffect.Pulse)); Assert.That(pulse.NormalDamage, Is.Zero); Assert.That(pulse.ChargedDamage, Is.Zero); Assert.That(pulse.NormalKnockback, Is.EqualTo(1.5f)); Assert.That(pulse.ChargedKnockback, Is.EqualTo(2f)); Assert.That(pulse.NormalDuration, Is.EqualTo(0.2f)); Assert.That(pulse.ChargedDuration, Is.EqualTo(0.3f)); Assert.That(pulse.NormalDamageReduction, Is.EqualTo(0.2f)); Assert.That(pulse.ChargedDamageReduction, Is.EqualTo(0.35f)); Assert.That(pulse.NormalBuffDuration, Is.EqualTo(3f)); Assert.That(pulse.ChargedBuffDuration, Is.EqualTo(4f)); } [Test] public void PrototypeDefinition_CycloneUsesUpdatedNormalDuration() { ActiveArtifactDefinition cyclone = AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Artifacts/CycloneArtifact.asset"); Assert.That(cyclone, Is.Not.Null); Assert.That(cyclone.Effect, Is.EqualTo(ActiveArtifactEffect.Cyclone)); Assert.That(cyclone.NormalDamage, Is.EqualTo(6f)); Assert.That(cyclone.ChargedDamage, Is.EqualTo(8f)); Assert.That(cyclone.NormalDuration, Is.EqualTo(0.36f)); Assert.That(cyclone.ChargedDuration, Is.EqualTo(0.65f)); Assert.That(cyclone.NormalMoveSpeedIncrease, Is.EqualTo(0.15f)); Assert.That(cyclone.ChargedMoveSpeedIncrease, Is.EqualTo(0.25f)); Assert.That(cyclone.NormalKnockback, Is.EqualTo(0.3f)); Assert.That(cyclone.ChargedKnockback, Is.EqualTo(0.5f)); } [Test] public void PrototypeDefinitions_UseThunderAndChainSecondaryEffects() { ActiveArtifactDefinition thunder = AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Artifacts/ThunderCrashArtifact.asset"); ActiveArtifactDefinition chain = AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Artifacts/ChainLightningArtifact.asset"); Assert.That(thunder.NormalDamage, Is.EqualTo(4f)); Assert.That(thunder.ChargedDamage, Is.EqualTo(6f)); Assert.That(thunder.NormalVulnerabilityIncrease, Is.EqualTo(0.25f)); Assert.That(thunder.ChargedVulnerabilityIncrease, Is.EqualTo(0.4f)); Assert.That(thunder.NormalVulnerabilityDuration, Is.EqualTo(3f)); Assert.That(thunder.ChargedVulnerabilityDuration, Is.EqualTo(4f)); Assert.That(chain.NormalDamage, Is.EqualTo(10f)); Assert.That(chain.ChargedDamage, Is.EqualTo(14f)); Assert.That(chain.DamageMultiplierPerChain, Is.EqualTo(1.2f)); Assert.That(chain.NormalShockChance, Is.EqualTo(0.2f)); Assert.That(chain.ChargedShockChance, Is.EqualTo(0.35f)); Assert.That(chain.ChargedShockIncrease, Is.EqualTo(0.3f)); } [Test] public void ScorchingRayVisual_UsesMeasuredBodyScaleAndNormalizedPivots() { string[] paths = { "Assets/_Project/Resources/Artifacts/ScorchingRay/" + "ScorchingRay-Ghostfire-Normal-v1.png", "Assets/_Project/Resources/Artifacts/ScorchingRay/" + "ScorchingRay-Ghostfire-Charged-v1.png", }; int[] widths = { 768, 1536 }; int[] heights = { 64, 128 }; for (int i = 0; i < paths.Length; i++) { Texture2D texture = AssetDatabase.LoadAssetAtPath(paths[i]); TextureImporter importer = AssetImporter.GetAtPath(paths[i]) as TextureImporter; Assert.That(texture, Is.Not.Null, paths[i]); Assert.That(texture.width, Is.EqualTo(widths[i])); Assert.That(texture.height, Is.EqualTo(heights[i])); Assert.That(importer, Is.Not.Null, paths[i]); Assert.That(importer.filterMode, Is.EqualTo(FilterMode.Point)); Assert.That(importer.mipmapEnabled, Is.False); Assert.That( importer.textureCompression, Is.EqualTo(TextureImporterCompression.Uncompressed)); } FieldInfo durations = typeof(ActiveArtifactController).GetField( "ScorchingRayFrameDurations", BindingFlags.Static | BindingFlags.NonPublic); Assert.That( (float[])durations.GetValue(null), Is.EqualTo(new[] { 0.02f, 0.03f, 0.03f, 0.03f, 0.02f, 0.02f })); MethodInfo load = typeof(ActiveArtifactController).GetMethod( "TryLoadScorchingRayFrames", BindingFlags.Instance | BindingFlags.NonPublic); MethodInfo create = typeof(ActiveArtifactController).GetMethod( "CreateScorchingRaySprite", BindingFlags.Instance | BindingFlags.NonPublic); FieldInfo normalField = typeof(ActiveArtifactController).GetField( "scorchingRayNormalFrames", BindingFlags.Instance | BindingFlags.NonPublic); FieldInfo chargedField = typeof(ActiveArtifactController).GetField( "scorchingRayChargedFrames", BindingFlags.Instance | BindingFlags.NonPublic); Assert.That(load.Invoke(controller, new object[] { false }), Is.True); Assert.That(load.Invoke(controller, new object[] { true }), Is.True); Sprite[] normal = (Sprite[])normalField.GetValue(controller); Sprite[] charged = (Sprite[])chargedField.GetValue(controller); Assert.That(normal[0].pivot, Is.EqualTo(new Vector2(8f, 32f))); Assert.That(charged[0].pivot, Is.EqualTo(new Vector2(16f, 64f))); GameObject normalVisual = new("Normal Scorching Ray Visual Test"); GameObject chargedVisual = new("Charged Scorching Ray Visual Test"); try { SpriteRenderer normalRenderer = (SpriteRenderer)create.Invoke( controller, new object[] { normalVisual, false, Vector2.right, 3f, 0.4375f }); SpriteRenderer chargedRenderer = (SpriteRenderer)create.Invoke( controller, new object[] { chargedVisual, true, Vector2.right, 6f, 0.875f }); Assert.That(normalRenderer.transform.localScale, Is.EqualTo(Vector3.one)); Assert.That(chargedRenderer.transform.localScale, Is.EqualTo(Vector3.one)); } finally { Object.DestroyImmediate(normalVisual); Object.DestroyImmediate(chargedVisual); typeof(ActiveArtifactController) .GetMethod( "DestroyScorchingRayFrames", BindingFlags.Instance | BindingFlags.NonPublic) .Invoke(controller, null); } } [Test] public void ScheduledHitCount_UsesDurationAndIntervalBoundaries() { MethodInfo getCount = typeof(ActiveArtifactController).GetMethod( "GetScheduledHitCount", BindingFlags.Static | BindingFlags.NonPublic); Assert.That(getCount.Invoke(null, new object[] { 0.36f, 0.14f }), Is.EqualTo(3)); Assert.That(getCount.Invoke(null, new object[] { 0.65f, 0.14f }), Is.EqualTo(5)); Assert.That(getCount.Invoke(null, new object[] { 0.30f, 0.13f }), Is.EqualTo(3)); Assert.That(getCount.Invoke(null, new object[] { 0.55f, 0.13f }), Is.EqualTo(5)); ActiveArtifactDefinition thunder = AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Artifacts/ThunderCrashArtifact.asset"); Assert.That(thunder, Is.Not.Null); Assert.That( getCount.Invoke(null, new object[] { thunder.NormalDuration, thunder.HitInterval }), Is.EqualTo(3)); Assert.That( getCount.Invoke(null, new object[] { thunder.ChargedDuration, thunder.HitInterval }), Is.EqualTo(5)); } [Test] public void EnemyIgniteTicksUseDamageTakenEffectsAtEachScheduledTime() { EnemyController enemy = CreateEnemyTarget( "Timed Status Target", Vector2.zero); typeof(EnemyController) .GetProperty("CurrentHealth") ?.SetValue(enemy, 100f); Assert.That(enemy.ApplyShock(0.75f, 0.2f), Is.True); Assert.That(enemy.ApplyIgnite(3f, 0.5f, 5.5f), Is.True); MethodInfo updateTimedEffects = typeof(EnemyController).GetMethod( "UpdateTimedEffects", BindingFlags.Instance | BindingFlags.NonPublic); updateTimedEffects.Invoke(enemy, new object[] { 1f }); Assert.That( enemy.CurrentHealth, Is.EqualTo(89f).Within(0.001f), "The 0.5s ignite tick receives shock before its own damage floor; the 1.0s tick does not."); } [Test] public void PulseRingRetouchedSheets_UseSevenFramesAndExpectedImport() { string[] paths = { "Assets/_Project/Resources/Artifacts/PulseRing/PulseRing-normal-retouched-v1.png", "Assets/_Project/Resources/Artifacts/PulseRing/PulseRing-charged-retouched-v1.png" }; int[] widths = { 1120, 1792 }; int[] heights = { 100, 104 }; for (int i = 0; i < paths.Length; i++) { Texture2D texture = AssetDatabase.LoadAssetAtPath(paths[i]); TextureImporter importer = AssetImporter.GetAtPath(paths[i]) as TextureImporter; Assert.That(texture, Is.Not.Null); Assert.That(texture.width, Is.EqualTo(widths[i])); Assert.That(texture.height, Is.EqualTo(heights[i])); Assert.That(texture.filterMode, Is.EqualTo(FilterMode.Point)); Assert.That(importer, Is.Not.Null); Assert.That(importer.textureCompression, Is.EqualTo(TextureImporterCompression.Uncompressed)); Assert.That(importer.mipmapEnabled, Is.False); Assert.That(importer.alphaIsTransparency, Is.True); Assert.That(texture.width / (i == 0 ? 160 : 256), Is.EqualTo(7)); } MethodInfo load = typeof(ActiveArtifactController).GetMethod( "TryLoadPulseRingFrames", BindingFlags.Instance | BindingFlags.NonPublic); MethodInfo destroy = typeof(ActiveArtifactController).GetMethod( "DestroyPulseRingFrames", BindingFlags.Instance | BindingFlags.NonPublic); FieldInfo normalFrames = typeof(ActiveArtifactController).GetField( "pulseRingNormalFrames", BindingFlags.Instance | BindingFlags.NonPublic); FieldInfo chargedFrames = typeof(ActiveArtifactController).GetField( "pulseRingChargedFrames", BindingFlags.Instance | BindingFlags.NonPublic); Assert.That(load.Invoke(controller, new object[] { false }), Is.True); Assert.That(load.Invoke(controller, new object[] { true }), Is.True); Sprite[] normal = (Sprite[])normalFrames.GetValue(controller); Sprite[] charged = (Sprite[])chargedFrames.GetValue(controller); Assert.That(normal, Has.Length.EqualTo(7)); Assert.That(charged, Has.Length.EqualTo(7)); Assert.That(normal[0].rect, Is.EqualTo(new Rect(0f, 0f, 160f, 100f))); Assert.That(charged[0].rect, Is.EqualTo(new Rect(0f, 0f, 256f, 104f))); Assert.That(normal[0].pixelsPerUnit, Is.EqualTo(32f)); Assert.That(charged[0].pixelsPerUnit, Is.EqualTo(32f)); Assert.That(normal[0].pivot, Is.EqualTo(new Vector2(80f, 50.5f))); Assert.That(charged[0].pivot, Is.EqualTo(new Vector2(128f, 52f))); destroy.Invoke(controller, null); Assert.That(normalFrames.GetValue(controller), Is.Null); Assert.That(chargedFrames.GetValue(controller), Is.Null); } [Test] public void Charging_DoesNotCreatePreviewOrPreliminaryRange() { ActiveArtifactDefinition pulse = CreatePulse("charge-test", 25f, 25f); try { Assert.That(controller.TryAddArtifact(pulse), Is.True); MethodInfo beginCharge = typeof(ActiveArtifactController).GetMethod( "BeginCharge", BindingFlags.Instance | BindingFlags.NonPublic); MethodInfo cancelCharge = typeof(ActiveArtifactController).GetMethod( "CancelCharge", BindingFlags.Instance | BindingFlags.NonPublic); beginCharge.Invoke(controller, null); Assert.That(controller.IsCharging, Is.True); Assert.That( owner.GetComponentsInChildren(true), Is.Empty); Assert.That( owner.transform.Find("Artifact Charge Preview"), Is.Null); cancelCharge.Invoke(controller, null); Assert.That(controller.IsCharging, Is.False); } finally { Object.DestroyImmediate(pulse); } } [Test] public void ChainLightningArc_UsesEightCroppedPointSampledFrames() { const string path = "Assets/_Project/Resources/Artifacts/ChainLightning/" + "Ghostfire-Arc-edit-v1.png"; Texture2D texture = AssetDatabase.LoadAssetAtPath(path); TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter; Assert.That(texture, Is.Not.Null); Assert.That(texture.width, Is.EqualTo(800)); Assert.That(texture.height, Is.EqualTo(100)); Assert.That(importer, Is.Not.Null); Assert.That(importer.filterMode, Is.EqualTo(FilterMode.Point)); Assert.That(importer.textureCompression, Is.EqualTo(TextureImporterCompression.Uncompressed)); Assert.That(importer.mipmapEnabled, Is.False); MethodInfo load = typeof(ActiveArtifactController).GetMethod( "TryLoadChainLightningArcFrames", BindingFlags.Instance | BindingFlags.NonPublic); MethodInfo destroy = typeof(ActiveArtifactController).GetMethod( "DestroyChainLightningArcFrames", BindingFlags.Instance | BindingFlags.NonPublic); FieldInfo frames = typeof(ActiveArtifactController).GetField( "chainLightningArcFrames", BindingFlags.Instance | BindingFlags.NonPublic); Assert.That(load.Invoke(controller, null), Is.True); Sprite[] loadedFrames = (Sprite[])frames.GetValue(controller); Assert.That(loadedFrames, Has.Length.EqualTo(8)); Assert.That(loadedFrames[0].rect, Is.EqualTo(new Rect(8f, 50f, 81f, 15f))); Assert.That(loadedFrames[7].rect, Is.EqualTo(new Rect(708f, 50f, 81f, 15f))); destroy.Invoke(controller, null); Assert.That(frames.GetValue(controller), Is.Null); } [Test] public void CycloneOrbitVisual_UsesFourSheetsAndMatchesOrbitBounds() { string root = "Assets/_Project/Resources/Artifacts/ThreeColor-v1/" + "CycloneOrbit-v3/"; string[] assets = { "Cyclone-Normal-Back-v3.png", "Cyclone-Normal-Front-v3.png", "Cyclone-Charged-Back-v3.png", "Cyclone-Charged-Front-v3.png", }; foreach (string asset in assets) { string path = root + asset; Texture2D texture = AssetDatabase.LoadAssetAtPath(path); TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter; Assert.That(texture, Is.Not.Null, path); Assert.That(texture.width, Is.EqualTo(800), path); Assert.That(texture.height, Is.EqualTo(100), path); Assert.That(importer, Is.Not.Null, path); Assert.That(importer.filterMode, Is.EqualTo(FilterMode.Point), path); Assert.That(importer.mipmapEnabled, Is.False, path); Assert.That(importer.textureCompression, Is.EqualTo(TextureImporterCompression.Uncompressed), path); AssertCyclonePixelsInsideOrbit( texture, asset.Contains("Charged"), path); } MethodInfo load = typeof(ActiveArtifactController).GetMethod( "TryLoadCycloneOrbitFrames", BindingFlags.Instance | BindingFlags.NonPublic); MethodInfo destroy = typeof(ActiveArtifactController).GetMethod( "DestroyCycloneOrbitFrames", BindingFlags.Instance | BindingFlags.NonPublic, null, System.Type.EmptyTypes, null); Assert.That((bool)load.Invoke(controller, new object[] { false }), Is.True); Assert.That((bool)load.Invoke(controller, new object[] { true }), Is.True); AssertCycloneFrameSet("cycloneNormalBackFrames"); AssertCycloneFrameSet("cycloneNormalFrontFrames"); AssertCycloneFrameSet("cycloneChargedBackFrames"); AssertCycloneFrameSet("cycloneChargedFrontFrames"); owner.transform.localScale = Vector3.one * 1.25f; SpriteRenderer playerRenderer = owner.AddComponent(); playerRenderer.sortingOrder = 120; YSortRenderer ySort = owner.AddComponent(); GameObject visual = new("Cyclone Orbit Layer Test"); MethodInfo create = typeof(ActiveArtifactController).GetMethod( "CreateCycloneSpriteLayers", BindingFlags.Instance | BindingFlags.NonPublic); MethodInfo setFrame = typeof(ActiveArtifactController).GetMethod( "SetCycloneSpriteFrame", BindingFlags.Instance | BindingFlags.NonPublic); object[] normalArgs = { visual, false, 1f, null, null }; create.Invoke(controller, normalArgs); SpriteRenderer normalBack = (SpriteRenderer)normalArgs[3]; SpriteRenderer normalFront = (SpriteRenderer)normalArgs[4]; Assert.That(normalBack, Is.Not.Null); Assert.That(normalFront, Is.Not.Null); Assert.That(normalBack.sortingOrder, Is.EqualTo(999)); Assert.That(normalFront.sortingOrder, Is.EqualTo(1001)); AssertUniformScale(normalBack.transform.lossyScale, 1f); AssertUniformScale(normalFront.transform.lossyScale, 1f); owner.transform.position = new Vector3(0f, 0.25f, 0f); typeof(ActiveArtifactController).GetMethod( "UpdateCycloneSorting", BindingFlags.Instance | BindingFlags.NonPublic) .Invoke(controller, new object[] { normalBack, normalFront }); Assert.That(ySort.CalculateSortingOrder(), Is.EqualTo(975)); Assert.That(normalBack.sortingOrder, Is.EqualTo(974)); Assert.That(normalFront.sortingOrder, Is.EqualTo(976)); GameObject chargedVisual = new("Charged Cyclone Orbit Layer Test"); object[] chargedArgs = { chargedVisual, true, 1f, null, null }; create.Invoke(controller, chargedArgs); SpriteRenderer chargedBack = (SpriteRenderer)chargedArgs[3]; SpriteRenderer chargedFront = (SpriteRenderer)chargedArgs[4]; setFrame.Invoke( controller, new object[] { normalBack, normalFront, false, 0.1f }); setFrame.Invoke( controller, new object[] { chargedBack, chargedFront, true, 0.15f }); Assert.That( normalBack.sprite, Is.EqualTo(GetCycloneFrames("cycloneNormalBackFrames")[2])); Assert.That( normalFront.sprite, Is.EqualTo(GetCycloneFrames("cycloneNormalFrontFrames")[2])); Assert.That( chargedBack.sprite, Is.EqualTo(GetCycloneFrames("cycloneChargedBackFrames")[3])); Assert.That( chargedFront.sprite, Is.EqualTo(GetCycloneFrames("cycloneChargedFrontFrames")[3])); AssertUniformScale(chargedBack.transform.lossyScale, 1f); AssertUniformScale(chargedFront.transform.lossyScale, 1f); MethodInfo scale = typeof(ActiveArtifactController).GetMethod( "GetCycloneVisualScale", BindingFlags.Static | BindingFlags.NonPublic); MethodInfo getOffset = typeof(ActiveArtifactController).GetMethod( "GetCycloneHitCenterOffset", BindingFlags.Instance | BindingFlags.NonPublic); MethodInfo getRadii = typeof(ActiveArtifactController).GetMethod( "GetCycloneHitRadii", BindingFlags.Static | BindingFlags.NonPublic); Assert.That((float)scale.Invoke(null, new object[] { 0.85f, false }), Is.EqualTo(1f)); Assert.That((float)scale.Invoke(null, new object[] { 1.25f, true }), Is.EqualTo(1f)); Assert.That((float)scale.Invoke(null, new object[] { 1.7f, false }), Is.EqualTo(2f)); Vector2 normalCenterOffset = (Vector2)getOffset.Invoke( controller, new object[] { 0.85f, false }); Vector2 chargedCenterOffset = (Vector2)getOffset.Invoke( controller, new object[] { 1.25f, true }); Vector2 expectedShadowOffset = new Vector2(0f, -7.5f / 32f * 1.25f); AssertVector2Close( normalCenterOffset, expectedShadowOffset + Vector2.up * (5f / 32f)); AssertVector2Close(chargedCenterOffset, normalCenterOffset); Vector2 normalRadii = (Vector2)getRadii.Invoke( null, new object[] { 0.85f, false }); Vector2 chargedRadii = (Vector2)getRadii.Invoke( null, new object[] { 1.25f, true }); AssertVector2Close(normalRadii, new Vector2(38.0625f, 15.03125f) / 32f); AssertVector2Close(chargedRadii, new Vector2(46f, 18f) / 32f); Assert.That(chargedRadii.x, Is.GreaterThan(normalRadii.x)); MethodInfo evaluate = typeof(ActiveArtifactController).GetMethod( "EvaluateCycloneFrameIndex", BindingFlags.Static | BindingFlags.NonPublic); Assert.That(evaluate.Invoke(null, new object[] { 0f }), Is.EqualTo(0)); Assert.That(evaluate.Invoke(null, new object[] { 0.05f }), Is.EqualTo(1)); Assert.That(evaluate.Invoke(null, new object[] { 0.39999f }), Is.EqualTo(7)); Assert.That(evaluate.Invoke(null, new object[] { 0.4f }), Is.EqualTo(0)); Object.DestroyImmediate(visual); Object.DestroyImmediate(chargedVisual); destroy.Invoke(controller, null); Assert.That(GetCycloneFrames("cycloneNormalBackFrames"), Is.Null); Assert.That(GetCycloneFrames("cycloneNormalFrontFrames"), Is.Null); Assert.That(GetCycloneFrames("cycloneChargedBackFrames"), Is.Null); Assert.That(GetCycloneFrames("cycloneChargedFrontFrames"), Is.Null); } private void AssertCycloneFrameSet(string fieldName) { Sprite[] frames = GetCycloneFrames(fieldName); Assert.That(frames, Has.Length.EqualTo(8), fieldName); Assert.That(frames[0].pixelsPerUnit, Is.EqualTo(32f), fieldName); Assert.That(Vector2.Distance(frames[0].pivot, new Vector2(50f, 30f)), Is.LessThan(0.001f), fieldName); Assert.That(frames[7].rect, Is.EqualTo(new Rect(700f, 0f, 100f, 100f)), fieldName); } private Sprite[] GetCycloneFrames(string fieldName) { return (Sprite[])typeof(ActiveArtifactController).GetField( fieldName, BindingFlags.Instance | BindingFlags.NonPublic).GetValue(controller); } private static void AssertCyclonePixelsInsideOrbit( Texture2D texture, bool charged, string context) { float radiusX = charged ? 46f : 38.0625f; float radiusY = charged ? 18f : 15.03125f; Color32[] pixels = texture.GetPixels32(); int visiblePixelCount = 0; for (int y = 0; y < texture.height; y++) { float topY = texture.height - 1 - y; for (int x = 0; x < texture.width; x++) { if (pixels[y * texture.width + x].a == 0) { continue; } visiblePixelCount++; float localX = x % 100; float nextX = localX + 1f; float nextY = topY + 1f; AssertCycloneCornerInside(localX, topY, radiusX, radiusY, context); AssertCycloneCornerInside(nextX, topY, radiusX, radiusY, context); AssertCycloneCornerInside(localX, nextY, radiusX, radiusY, context); AssertCycloneCornerInside(nextX, nextY, radiusX, radiusY, context); } } Assert.That(visiblePixelCount, Is.GreaterThan(0), context); } private static void AssertCycloneCornerInside( float x, float y, float radiusX, float radiusY, string context) { float dx = x - 50f; float dy = y - 65f; float ratio = dx * dx / (radiusX * radiusX) + dy * dy / (radiusY * radiusY); Assert.That(ratio, Is.LessThanOrEqualTo(1f), context); } [Test] public void ThunderCrashVisuals_UseThreeColorDedicatedSheetAndExpandedDefinitionScale() { const string path = "Assets/_Project/Resources/Artifacts/ThreeColor-v1/ThunderCrash/ThunderCrash-Combined-v6.png"; var texture = AssetDatabase.LoadAssetAtPath(path); var importer = (TextureImporter)AssetImporter.GetAtPath(path); Assert.That(texture, Is.Not.Null); Assert.That(texture.width, Is.EqualTo(600)); Assert.That(texture.height, Is.EqualTo(100)); Assert.That(importer.filterMode, Is.EqualTo(FilterMode.Point)); Assert.That(importer.mipmapEnabled, Is.False); Assert.That(importer.textureCompression, Is.EqualTo(TextureImporterCompression.Uncompressed)); const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; var type = typeof(ActiveArtifactController); Assert.That(type.GetMethod("TryLoadThunderCrashFrames", flags).Invoke(controller, null), Is.True); var field = type.GetField("thunderCrashFrames", flags); var frames = (Sprite[])field.GetValue(controller); Assert.That(frames, Has.Length.EqualTo(6)); Assert.That(frames[0].texture, Is.SameAs(texture)); Assert.That(frames[0].pixelsPerUnit, Is.EqualTo(32f)); Assert.That(frames[0].pivot, Is.EqualTo(new Vector2(50.5f,42.5f))); Assert.That(frames[5].rect, Is.EqualTo(new Rect(500f,0f,100f,100f))); var definition = AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Artifacts/ThunderCrashArtifact.asset"); Assert.That(definition.NormalRange, Is.EqualTo(2f)); Assert.That(definition.ChargedRange, Is.EqualTo(3f)); foreach (float range in new[] { definition.NormalRange, definition.ChargedRange }) { var owner = new GameObject("Thunder Single Sheet Test"); var renderer = (SpriteRenderer)type.GetMethod("CreateThunderCrashSprite", flags) .Invoke(controller, new object[] { owner, frames, range }); Assert.That(owner.GetComponentsInChildren(), Has.Length.EqualTo(1)); Assert.That(renderer.transform.localPosition, Is.EqualTo(Vector3.zero)); AssertUniformScale(renderer.transform.localScale, 1.05f * range); Assert.That(renderer.sortingOrder, Is.EqualTo(211)); var setFrame = type.GetMethod("SetThunderCrashSpriteFrame", flags); setFrame.Invoke(controller, new object[] { renderer, frames, .02f }); Assert.That(renderer.sprite, Is.SameAs(frames[1])); setFrame.Invoke(controller, new object[] { renderer, frames, .13f }); Assert.That(renderer.sprite, Is.SameAs(frames[5])); setFrame.Invoke(controller, new object[] { renderer, frames, .16f }); Assert.That(renderer.enabled, Is.False); Object.DestroyImmediate(owner); } type.GetMethod("DestroyThunderCrashFrames", flags).Invoke(controller, null); Assert.That(field.GetValue(controller), Is.Null); } [Test] public void ThunderCrashGroundFootprint_ContainsEveryVisibleGroundPixelCorner() { const string path = "Assets/_Project/Resources/Artifacts/ThunderCrash/ThunderCrash-Combined-v6.png"; var texture = AssetDatabase.LoadAssetAtPath(path); var pixels = texture.GetPixels32(); const BindingFlags flags = BindingFlags.Static | BindingFlags.NonPublic; var type = typeof(ActiveArtifactController); foreach (float range in new[] { 1f, 1.5f }) { var center = (Vector2)type.GetMethod("GetThunderCrashHitCenterOffset", flags) .Invoke(null, new object[] { range, Vector2.right }); var radii = (Vector2)type.GetMethod("GetThunderCrashHitRadii", flags) .Invoke(null, new object[] { range, Vector2.right }); int samples = 0; for (int frame = 0; frame < 6; frame++) for (int y = 38; y < 48; y++) // Source ground rows 52..61 from top. for (int x = 0; x < 100; x++) { if (pixels[y * texture.width + frame * 100 + x].a == 0) continue; for (int dy = 0; dy <= 1; dy++) for (int dx = 0; dx <= 1; dx++) { Vector2 point = (new Vector2(x + dx, y + dy) - new Vector2(50.5f, 42.5f)) / 32f * (1.05f * range); Vector2 offset = point - center; float distance = offset.x * offset.x / (radii.x * radii.x) + offset.y * offset.y / (radii.y * radii.y); Assert.That(distance, Is.LessThanOrEqualTo(1.0001f), $"Visible ground pixel outside hit area: frame {frame}, ({x},{y}), range {range}"); samples++; } } Assert.That(samples, Is.GreaterThan(0)); } } [Test] public void ThunderCrashDirection_UsesFacingAndAuthoredGroundAnchor() { GameObject actor = new("Thunder Direction Test"); try { actor.AddComponent(); actor.AddComponent(); actor.AddComponent(); actor.AddComponent(); PlayerController player = actor.AddComponent(); ActiveArtifactController artifacts = actor.AddComponent(); BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; FieldInfo facing = typeof(PlayerController).GetField( "k__BackingField", flags); FieldInfo moving = typeof(PlayerController).GetField( "k__BackingField", flags); MethodInfo resolveDirection = typeof(ActiveArtifactController).GetMethod( "GetThunderCrashDirection", flags); MethodInfo resolveOrigin = typeof(ActiveArtifactController).GetMethod( "GetThunderCrashOrigin", flags); typeof(ActiveArtifactController).GetField( "playerController", flags).SetValue(artifacts, player); typeof(ActiveArtifactController).GetField( "body", flags).SetValue( artifacts, actor.GetComponent()); Assert.That(facing, Is.Not.Null); Assert.That(moving, Is.Not.Null); Assert.That(resolveDirection, Is.Not.Null); Assert.That(resolveOrigin, Is.Not.Null); Vector2[] directions = { Vector2.right, Vector2.left, Vector2.up, Vector2.down, new Vector2(1f, 1f).normalized, new Vector2(-1f, 1f).normalized, new Vector2(1f, -1f).normalized, new Vector2(-1f, -1f).normalized, }; foreach (Vector2 direction in directions) { facing.SetValue(player, direction); // A held movement input can be changed while the cast // locks movement; the cast must retain the facing value. moving.SetValue(player, -direction); Vector2 resolved = (Vector2)resolveDirection.Invoke( artifacts, null); Assert.That( Vector2.Distance(resolved, direction), Is.LessThan(0.0001f), $"Thunder Crash direction changed for {direction}"); } facing.SetValue(player, new Vector2(-0.17f, 0.98f)); moving.SetValue(player, Vector2.right); Assert.That( Vector2.Distance( (Vector2)resolveDirection.Invoke(artifacts, null), Vector2.up), Is.LessThan(0.0001f)); Rigidbody2D body = actor.GetComponent(); CircleCollider2D collider = actor.AddComponent(); actor.transform.localScale = Vector3.one * 1.25f; collider.offset = new Vector2(0.125f, -0.28f); body.position = new Vector2(2.5f, -1.75f); Vector2 expectedOrigin = body.position + (Vector2)actor.transform.TransformVector( new Vector2(0f, -0.1f)); Vector2 resolvedOrigin = (Vector2)resolveOrigin.Invoke( artifacts, null); Assert.That( Vector2.Distance(resolvedOrigin, expectedOrigin), Is.LessThan(0.0001f)); collider.offset = new Vector2(-0.75f, 0.9f); Vector2 offsetIndependentOrigin = (Vector2)resolveOrigin.Invoke( artifacts, null); Assert.That( Vector2.Distance(offsetIndependentOrigin, expectedOrigin), Is.LessThan(0.0001f)); } finally { Object.DestroyImmediate(actor); } } [Test] public void CycloneAndThunderFootprints_UseAuthoredEllipseBoundaries() { MethodInfo getCycloneOffset = typeof(ActiveArtifactController).GetMethod( "GetCycloneHitCenterOffset", BindingFlags.Instance | BindingFlags.NonPublic); MethodInfo getCycloneRadii = typeof(ActiveArtifactController).GetMethod( "GetCycloneHitRadii", BindingFlags.Static | BindingFlags.NonPublic); MethodInfo getThunderOffset = typeof(ActiveArtifactController).GetMethod( "GetThunderCrashHitCenterOffset", BindingFlags.Static | BindingFlags.NonPublic); MethodInfo getThunderRadii = typeof(ActiveArtifactController).GetMethod( "GetThunderCrashHitRadii", BindingFlags.Static | BindingFlags.NonPublic); MethodInfo distance = typeof(ActiveArtifactController).GetMethod( "GetEllipseDistance", BindingFlags.Static | BindingFlags.NonPublic); owner.transform.localScale = Vector3.one * 1.25f; Vector2 cycloneOffset = (Vector2)getCycloneOffset.Invoke( controller, new object[] { 0.85f, false }); Vector2 cycloneRadii = (Vector2)getCycloneRadii.Invoke( null, new object[] { 0.85f, false }); Vector2 chargedCycloneOffset = (Vector2)getCycloneOffset.Invoke( controller, new object[] { 1.25f, true }); Vector2 chargedCycloneRadii = (Vector2)getCycloneRadii.Invoke( null, new object[] { 1.25f, true }); Vector2 expectedCenterOffset = new Vector2( 0f, -7.5f / 32f * 1.25f + 5f / 32f); AssertVector2Close(cycloneOffset, expectedCenterOffset); AssertVector2Close(chargedCycloneOffset, expectedCenterOffset); AssertVector2Close( cycloneRadii, new Vector2(38.0625f, 15.03125f) / 32f); AssertVector2Close( chargedCycloneRadii, new Vector2(46f, 18f) / 32f); Assert.That(chargedCycloneRadii.x, Is.GreaterThan(cycloneRadii.x)); AssertEllipseBoundary(distance, cycloneRadii); AssertEllipseBoundary(distance, chargedCycloneRadii); Vector2[] directions = { Vector2.right, Vector2.left, Vector2.up, Vector2.down, new Vector2(1f, 1f), new Vector2(-1f, 1f), new Vector2(1f, -1f), new Vector2(-1f, -1f), }; float[] ranges = { 1f, 1.5f }; foreach (float range in ranges) { foreach (Vector2 direction in directions) { Vector2 thunderOffset = (Vector2)getThunderOffset.Invoke( null, new object[] { range, direction }); Vector2 thunderRadii = (Vector2)getThunderRadii.Invoke( null, new object[] { range, direction }); Vector2 expectedOffset = new Vector2(-0.5f, 0.5f) / 32f * (1.05f * range); Vector2 expectedRadii = new Vector2(17f, 7.25f) / 32f * (1.05f * range); AssertVector2Close(thunderOffset, expectedOffset); AssertVector2Close(thunderRadii, expectedRadii); AssertEllipseBoundary(distance, thunderRadii); } } } [Test] public void ThunderCrashBodyContact_UsesExpandedEllipseAndRejectsOutsideBody() { MethodInfo getOffset = typeof(ActiveArtifactController).GetMethod( "GetThunderCrashHitCenterOffset", BindingFlags.Static | BindingFlags.NonPublic, null, new[] { typeof(float), typeof(Vector2) }, null); MethodInfo getRadii = typeof(ActiveArtifactController).GetMethod( "GetThunderCrashHitRadii", BindingFlags.Static | BindingFlags.NonPublic, null, new[] { typeof(float), typeof(Vector2) }, null); MethodInfo overlap = typeof(ActiveArtifactController).GetMethod( "DoesBodyColliderOverlapEllipse", BindingFlags.Static | BindingFlags.NonPublic); MethodInfo distance = typeof(ActiveArtifactController).GetMethod( "GetEllipseDistance", BindingFlags.Static | BindingFlags.NonPublic); Assert.That(getOffset, Is.Not.Null); Assert.That(getRadii, Is.Not.Null); Assert.That(overlap, Is.Not.Null); Assert.That(distance, Is.Not.Null); const float range = 1f; Vector2 direction = Vector2.right; Vector2 effectCenter = direction * 0.45f; Vector2 hitCenter = effectCenter + (Vector2)getOffset.Invoke( null, new object[] { range, direction }); Vector2 hitRadii = (Vector2)getRadii.Invoke( null, new object[] { range, direction }); EnemyController enemy = CreateEnemyTarget( "Thunder Crash Body Contact", hitCenter + Vector2.right * (hitRadii.x + 0.1f)); CircleCollider2D collider = enemy.GetComponent(); collider.radius = 0.2f; Physics2D.SyncTransforms(); Assert.That( (float)distance.Invoke( null, new object[] { enemy.GroundAnchorPosition - hitCenter, hitRadii, }), Is.GreaterThan(1f), "The ground anchor is outside the authored Thunder ellipse."); Assert.That( (bool)overlap.Invoke( null, new object[] { enemy, hitCenter, hitRadii }), Is.True, "A body touching the expanded Thunder ellipse must hit."); enemy.transform.position += Vector3.right * 0.4f; Physics2D.SyncTransforms(); Assert.That( (bool)overlap.Invoke( null, new object[] { enemy, hitCenter, hitRadii }), Is.False, "A body fully outside the expanded Thunder ellipse must miss."); } [Test] public void ThunderCrashHitQuery_IncludesFrontContactAcrossEightDirections() { MethodInfo getOffset = typeof(ActiveArtifactController).GetMethod( "GetThunderCrashHitCenterOffset", BindingFlags.Static | BindingFlags.NonPublic, null, new[] { typeof(float), typeof(Vector2) }, null); MethodInfo getRadii = typeof(ActiveArtifactController).GetMethod( "GetThunderCrashHitRadii", BindingFlags.Static | BindingFlags.NonPublic, null, new[] { typeof(float), typeof(Vector2) }, null); MethodInfo hit = typeof(ActiveArtifactController).GetMethod( "HitEnemiesInEllipse", BindingFlags.Instance | BindingFlags.NonPublic); ActiveArtifactDefinition definition = CreateThunderCrashDefinition( "thunder_front_contact"); Vector2[] directions = { Vector2.right, Vector2.left, Vector2.up, Vector2.down, new Vector2(1f, 1f), new Vector2(-1f, 1f), new Vector2(1f, -1f), new Vector2(-1f, -1f), }; float[] ranges = { 1f, 1.5f }; try { int caseIndex = 0; foreach (float range in ranges) { foreach (Vector2 direction in directions) { Vector2 normalized = direction.normalized; Vector2 effectCenter = normalized * 0.45f; Vector2 hitCenter = effectCenter + (Vector2)getOffset.Invoke( null, new object[] { range, direction }); Vector2 radii = (Vector2)getRadii.Invoke( null, new object[] { range, direction }); EnemyController enemy = CreateEnemyTarget( $"Thunder Front Contact {caseIndex}", hitCenter - normalized * (0.35f * range)); typeof(EnemyController) .GetField( "k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic) ?.SetValue(enemy, 100f); try { Physics2D.SyncTransforms(); int hitCount = (int)hit.Invoke( controller, new object[] { definition, hitCenter, radii, 1f, 0f, 5, false, false, 1000 + caseIndex, true, }); Assert.That( hitCount, Is.EqualTo(1), $"Direction {direction}, range {range}"); Assert.That(enemy.CurrentHealth, Is.EqualTo(99f).Within(0.001f)); Assert.That(enemy.BumpVulnerabilityRemaining, Is.GreaterThan(0f)); Assert.That(enemy.BumpVulnerabilityIncrease, Is.EqualTo(0.25f)); } finally { Object.DestroyImmediate(enemy.gameObject); } caseIndex++; } } } finally { Object.DestroyImmediate(definition); } } [Test] public void ThunderCrashHitQuery_DeduplicatesMultipleCollidersPerEnemy() { ActiveArtifactDefinition definition = CreateThunderCrashDefinition( "thunder_query"); EnemyController enemy = CreateEnemyTarget( "Thunder Crash Duplicate Collider", Vector2.zero); typeof(EnemyController) .GetField( "k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic) ?.SetValue(enemy, 100f); GameObject duplicate = new("Duplicate Collider"); duplicate.transform.SetParent(enemy.transform, false); CircleCollider2D duplicateCollider = duplicate.AddComponent(); duplicateCollider.radius = 0.25f; Physics2D.SyncTransforms(); MethodInfo hit = typeof(ActiveArtifactController).GetMethod( "HitEnemiesInEllipse", BindingFlags.Instance | BindingFlags.NonPublic); try { int hitCount = (int)hit.Invoke( controller, new object[] { definition, Vector2.zero, new Vector2(1f, 1f), 1f, 0f, 5, false, false, 123, true, }); Assert.That(hitCount, Is.EqualTo(1)); Assert.That(enemy.CurrentHealth, Is.EqualTo(99f).Within(0.001f)); Assert.That(enemy.BumpVulnerabilityRemaining, Is.GreaterThan(0f)); } finally { Object.DestroyImmediate(duplicate); Object.DestroyImmediate(definition); } } [Test] public void ThunderCrashHitQuery_RejectsOutsideBodyWithoutDamage() { ActiveArtifactDefinition definition = CreateThunderCrashDefinition( "thunder_outside"); EnemyController enemy = CreateEnemyTarget( "Thunder Crash Outside Body", new Vector2(0.68f, 0f)); CircleCollider2D collider = enemy.GetComponent(); collider.radius = 0.1f; typeof(EnemyController) .GetField( "k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic) ?.SetValue(enemy, 100f); Physics2D.SyncTransforms(); MethodInfo hit = typeof(ActiveArtifactController).GetMethod( "HitEnemiesInEllipse", BindingFlags.Instance | BindingFlags.NonPublic); try { int hitCount = (int)hit.Invoke( controller, new object[] { definition, Vector2.zero, new Vector2(17f, 7.25f) / 32f * 1.05f, 1f, 0f, 5, false, false, 124, true, }); Assert.That(hitCount, Is.EqualTo(0)); Assert.That(enemy.CurrentHealth, Is.EqualTo(100f)); Assert.That(enemy.BumpVulnerabilityRemaining, Is.EqualTo(0f)); } finally { Object.DestroyImmediate(definition); Object.DestroyImmediate(enemy.gameObject); } } [Test] public void ThunderCrashHitQuery_ContactsVisibleLowerGroundOverlap() { ActiveArtifactDefinition definition = CreateThunderCrashDefinition( "thunder_lower_ground_contact"); Vector2 effectCenter = Vector2.right * 0.45f; MethodInfo getOffset = typeof(ActiveArtifactController).GetMethod( "GetThunderCrashHitCenterOffset", BindingFlags.Static | BindingFlags.NonPublic, null, new[] { typeof(float), typeof(Vector2) }, null); MethodInfo getRadii = typeof(ActiveArtifactController).GetMethod( "GetThunderCrashHitRadii", BindingFlags.Static | BindingFlags.NonPublic, null, new[] { typeof(float), typeof(Vector2) }, null); Vector2 direction = Vector2.right; Vector2 hitCenter = effectCenter + (Vector2)getOffset.Invoke( null, new object[] { 1f, direction }); Vector2 hitRadii = (Vector2)getRadii.Invoke( null, new object[] { 1f, direction }); EnemyController enemy = CreateEnemyTarget( "Thunder Crash Lower Ground Contact", new Vector2(hitCenter.x, effectCenter.y - 0.06f)); CircleCollider2D collider = enemy.GetComponent(); collider.offset = new Vector2(0f, -0.28f); collider.radius = 0.22f; typeof(EnemyController) .GetField( "k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic) ?.SetValue(enemy, 100f); float colliderTop = enemy.transform.position.y + collider.offset.y + collider.radius; float previousEllipseBottom = effectCenter.y + (1.5f - 4f) / 32f * 1.05f; Assert.That( colliderTop, Is.LessThan(previousEllipseBottom), "This target reproduces the visible lower-ground gap from the previous ellipse."); Physics2D.SyncTransforms(); MethodInfo hit = typeof(ActiveArtifactController).GetMethod( "HitEnemiesInEllipse", BindingFlags.Instance | BindingFlags.NonPublic); try { int hitCount = (int)hit.Invoke( controller, new object[] { definition, hitCenter, hitRadii, 1f, 0f, 5, false, false, 126, true, }); Assert.That(hitCount, Is.EqualTo(1)); Assert.That(enemy.CurrentHealth, Is.EqualTo(99f).Within(0.001f)); Assert.That(enemy.BumpVulnerabilityRemaining, Is.GreaterThan(0f)); } finally { Object.DestroyImmediate(definition); Object.DestroyImmediate(enemy.gameObject); } } [Test] public void ThunderCrashWrongColorContact_IsShieldedWithoutDamage() { EnemyDefinition enemyDefinition = CreateProtectedEnemyDefinition(); GameObject enemyObject = null; ActiveArtifactDefinition definition = CreateThunderCrashDefinition( "thunder_protected"); try { EnemyController enemy = CreateProtectedEnemy( "Thunder Crash Protected Target", enemyDefinition, out enemyObject); typeof(EnemyController) .GetField( "k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic) ?.SetValue(enemy, 100f); Assert.That(enemy.IsDamageInvulnerable, Is.True); Physics2D.SyncTransforms(); MethodInfo hit = typeof(ActiveArtifactController).GetMethod( "HitEnemiesInEllipse", BindingFlags.Instance | BindingFlags.NonPublic); int hitCount = (int)hit.Invoke( controller, new object[] { definition, Vector2.zero, new Vector2(17f, 7.25f) / 32f * 1.05f, 1f, 0f, 5, false, false, 125, true, }); Assert.That(hitCount, Is.EqualTo(0)); Assert.That(enemy.CurrentHealth, Is.EqualTo(100f)); Assert.That(definition.ArtifactColor, Is.EqualTo(ArtifactColor.Blue)); Assert.That(enemy.ShieldColor, Is.EqualTo(ArtifactColor.Green)); Assert.That(enemy.IsGroggy, Is.False); Assert.That(enemy.IsDamageInvulnerable, Is.True); Assert.That(enemy.GroggyQualifyingContactCount, Is.Zero); Assert.That(enemy.ShieldHitsRemaining, Is.EqualTo(1)); Assert.That(enemy.BumpVulnerabilityRemaining, Is.EqualTo(0f)); } finally { Object.DestroyImmediate(definition); Object.DestroyImmediate(enemyObject); Object.DestroyImmediate(enemyDefinition); } } [Test] public void CycloneBodyOverlap_PreservesTouchingAndVisibleGapBehavior() { EnemyController enemy = CreateEnemyTarget( "Cyclone Geometry Target", new Vector2(1.21f, 0f)); CircleCollider2D collider = enemy.GetComponent(); collider.radius = 0.2f; Physics2D.SyncTransforms(); MethodInfo overlap = typeof(ActiveArtifactController).GetMethod( "DoesBodyColliderOverlapEllipse", BindingFlags.Static | BindingFlags.NonPublic); Assert.That( (bool)overlap.Invoke( null, new object[] { enemy, Vector2.zero, new Vector2(1f, 0.5f), }), Is.False, "A root body with a visible gap must remain outside Cyclone."); enemy.transform.position = new Vector2(1.2f, 0f); Physics2D.SyncTransforms(); Assert.That( (bool)overlap.Invoke( null, new object[] { enemy, Vector2.zero, new Vector2(1f, 0.5f), }), Is.True, "A root body touching the authored ellipse boundary must hit."); } [Test] public void CycloneFollowsAndThunderCrashRemainsFixedAtVisualRoot() { MethodInfo createVisual = typeof(ActiveArtifactController).GetMethod( "CreateVisual", BindingFlags.Instance | BindingFlags.NonPublic); MethodInfo destroyVisual = typeof(ActiveArtifactController).GetMethod( "DestroyVisual", BindingFlags.Instance | BindingFlags.NonPublic); Rigidbody2D body = owner.GetComponent(); body.position = new Vector2(2f, 3f); GameObject cyclone = (GameObject)createVisual.Invoke( controller, new object[] { "Cyclone Test", true }); GameObject thunder = (GameObject)createVisual.Invoke( controller, new object[] { "Thunder Test", false }); try { Assert.That(cyclone.transform.parent, Is.EqualTo(owner.transform)); Assert.That(thunder.transform.parent, Is.Null); Assert.That(thunder.transform.position, Is.EqualTo(new Vector3(2f, 3f, 0f))); body.position = new Vector2(4f, 3f); Assert.That(cyclone.transform.parent, Is.EqualTo(owner.transform)); Assert.That(thunder.transform.position, Is.EqualTo(new Vector3(2f, 3f, 0f))); } finally { destroyVisual.Invoke(controller, new object[] { cyclone }); destroyVisual.Invoke(controller, new object[] { thunder }); } } [Test] public void ChainLightningArc_MapsEndpointScaleAndExpiresAfterAuthoredDuration() { MethodInfo load = typeof(ActiveArtifactController).GetMethod( "TryLoadChainLightningArcFrames", BindingFlags.Instance | BindingFlags.NonPublic); MethodInfo getFrame = typeof(ActiveArtifactController).GetMethod( "GetChainLightningArcFrame", BindingFlags.Instance | BindingFlags.NonPublic); MethodInfo setProgress = typeof(ActiveArtifactController).GetMethod( "SetChainLightningArcProgress", BindingFlags.Static | BindingFlags.NonPublic); Assert.That(load.Invoke(controller, null), Is.True); Assert.That(getFrame.Invoke(controller, new object[] { 0.299f }), Is.Not.Null); Assert.That(getFrame.Invoke(controller, new object[] { 0.3001f }), Is.Null); GameObject segment = new("Arc Scale Test"); SpriteRenderer renderer = segment.AddComponent(); setProgress.Invoke(null, new object[] { renderer, new Vector2(1f, 2f), new Vector2(3f, 2f), Vector2.zero, 1f, 1.35f, }); Assert.That(renderer.transform.localPosition, Is.EqualTo(new Vector3(1f, 2f, 0f))); Assert.That(renderer.transform.localScale.x, Is.EqualTo(2f / (81f / 32f)).Within(0.0001f)); Assert.That(renderer.transform.localScale.y, Is.EqualTo(1.35f).Within(0.0001f)); Object.DestroyImmediate(segment); } [Test] public void PulseRingPalette_VisibleBoundsMatchNormalAndChargedRanges() { GameObject visual = new GameObject("Pulse Palette Test"); try { SpriteRenderer renderer = visual.AddComponent(); MethodInfo configure = typeof(ActiveArtifactController).GetMethod( "ConfigurePulseRingPaletteVisual", BindingFlags.Instance | BindingFlags.NonPublic, null, new[] { typeof(SpriteRenderer), typeof(float), typeof(bool) }, null); configure.Invoke(controller, new object[] { renderer, 1.25f, false }); Assert.That(renderer.transform.localScale.x, Is.EqualTo(1f)); Assert.That(renderer.transform.localScale.y, Is.EqualTo(1.0f * 32.5f / 33f).Within(0.0001f)); configure.Invoke(controller, new object[] { renderer, 2f, true }); Assert.That(renderer.transform.localScale.x, Is.EqualTo(1f)); Assert.That(renderer.transform.localScale.y, Is.EqualTo(1f).Within(0.0001f)); } finally { Object.DestroyImmediate(visual); } } [Test] public void PulseRingEllipse_UsesHorizontalRangeAndSpriteAspect() { MethodInfo distance = typeof(ActiveArtifactController).GetMethod( "GetPulseEllipseDistance", BindingFlags.Static | BindingFlags.NonPublic); Assert.That(distance, Is.Not.Null); float normal = (float)distance.Invoke( null, new object[] { new Vector2(1.25f, 0f), 1.25f, 0.5078125f }); float normalVertical = (float)distance.Invoke( null, new object[] { new Vector2(0f, 0.5078125f), 1.25f, 0.5078125f }); float normalOutside = (float)distance.Invoke( null, new object[] { new Vector2(0f, 0.508f), 1.25f, 0.5078125f }); Assert.That(normal, Is.EqualTo(1f).Within(0.0001f)); Assert.That(normalVertical, Is.EqualTo(1f).Within(0.0001f)); Assert.That(normalOutside, Is.GreaterThan(1f)); float chargedDiagonal = (float)distance.Invoke( null, new object[] { new Vector2(2f / Mathf.Sqrt(2f), 0.8125f / Mathf.Sqrt(2f)), 2f, 0.8125f }); Assert.That(chargedDiagonal, Is.EqualTo(1f).Within(0.0001f)); } [Test] public void PrototypeDefinitions_NormalAndChargedGaugeCostsMatch() { string[] paths = { "Assets/_Project/Constants/Artifacts/DashArtifact.asset", "Assets/_Project/Constants/Artifacts/PulseArtifact.asset", "Assets/_Project/Constants/Artifacts/PhoenixArtifact.asset", "Assets/_Project/Constants/Artifacts/CycloneArtifact.asset", "Assets/_Project/Constants/Artifacts/ThunderCrashArtifact.asset", "Assets/_Project/Constants/Artifacts/ChainLightningArtifact.asset", }; float[] expectedCosts = { 35f, 25f, 25f, 30f, 35f, 30f }; for (int i = 0; i < paths.Length; i++) { string path = paths[i]; ActiveArtifactDefinition definition = AssetDatabase.LoadAssetAtPath(path); Assert.That(definition, Is.Not.Null, path); Assert.That( definition.ChargedGaugeCost, Is.EqualTo(definition.NormalGaugeCost), definition.DisplayName); Assert.That( definition.NormalGaugeCost, Is.EqualTo(expectedCosts[i]), definition.DisplayName); } } [Test] public void ChainLightning_InitialTargetIsNearestRegardlessOfDirection() { EnemyController nearerBehind = CreateEnemyTarget( "Nearer Behind", new Vector2(-1f, 0f)); CreateEnemyTarget("Farther Ahead", new Vector2(2f, 0f)); Physics2D.SyncTransforms(); MethodInfo findInitialTarget = typeof(ActiveArtifactController) .GetMethod( "FindInitialChainTarget", BindingFlags.Instance | BindingFlags.NonPublic); EnemyController selected = (EnemyController)findInitialTarget.Invoke( controller, new object[] { Vector2.zero, 5f }); Assert.That(selected, Is.SameAs(nearerBehind)); } [Test] public void ChainLightningTargetSearch_UsesBodyOriginWhenTransformDiffers() { Rigidbody2D body = owner.GetComponent(); EnemyController bodySideTarget = null; EnemyController transformSideTarget = null; try { bodySideTarget = CreateEnemyTarget( "Body Origin Target", new Vector2(2.5f, 0f)); transformSideTarget = CreateEnemyTarget( "Transform Origin Target", new Vector2(-1.5f, 0f)); bodySideTarget.transform.SetParent(null); transformSideTarget.transform.SetParent(null); Physics2D.SyncTransforms(); owner.transform.position = new Vector2(-2f, 0f); body.position = new Vector2(2f, 0f); Assert.That( (Vector2)owner.transform.position, Is.Not.EqualTo(body.position)); MethodInfo getOrigin = typeof(ActiveArtifactController) .GetMethod( "GetCurrentArtifactOrigin", BindingFlags.Instance | BindingFlags.NonPublic); MethodInfo findInitialTarget = typeof(ActiveArtifactController) .GetMethod( "FindInitialChainTarget", BindingFlags.Instance | BindingFlags.NonPublic); Vector2 origin = (Vector2)getOrigin.Invoke(controller, null); EnemyController selected = (EnemyController)findInitialTarget.Invoke( controller, new object[] { origin, 1f }); Assert.That(origin, Is.EqualTo(body.position)); Assert.That(selected, Is.SameAs(bodySideTarget)); } finally { if (bodySideTarget != null) { Object.DestroyImmediate(bodySideTarget.gameObject); } if (transformSideTarget != null) { Object.DestroyImmediate(transformSideTarget.gameObject); } } } [Test] public void ProjectileProgress_UsesNormalizedExponentialAcceleration() { float start = ActiveArtifactController.EvaluateProjectileProgress(0f); float firstQuarter = ActiveArtifactController.EvaluateProjectileProgress(0.25f); float midpoint = ActiveArtifactController.EvaluateProjectileProgress(0.5f); float thirdQuarter = ActiveArtifactController.EvaluateProjectileProgress(0.75f); float end = ActiveArtifactController.EvaluateProjectileProgress(1f); Assert.That(start, Is.EqualTo(0f).Within(0.0001f)); Assert.That(end, Is.EqualTo(1f).Within(0.0001f)); Assert.That(midpoint, Is.EqualTo(0.2689f).Within(0.001f)); Assert.That(firstQuarter - start, Is.LessThan(midpoint - firstQuarter)); Assert.That( midpoint - firstQuarter, Is.LessThan(thirdQuarter - midpoint)); Assert.That( thirdQuarter - midpoint, Is.LessThan(end - thirdQuarter)); } private RunManager CreateRunManager(RunMode mode) { bool previousGrantCatalogForTests = ArtifactRewardController.GrantCatalogForTests; ArtifactRewardController.GrantCatalogForTests = false; try { runManagerObject = new GameObject("Artifact Charge Run Manager"); RunManager runManager = runManagerObject.AddComponent(); if (RunManager.Instance != runManager) { typeof(RunManager) .GetMethod( "Awake", BindingFlags.Instance | BindingFlags.NonPublic) ?.Invoke(runManager, null); } Assert.That(runManager.BeginRun(mode), Is.True); return runManager; } finally { ArtifactRewardController.GrantCatalogForTests = previousGrantCatalogForTests; } } private void InvokeControllerMethod(string methodName) { typeof(ActiveArtifactController) .GetMethod( methodName, BindingFlags.Instance | BindingFlags.NonPublic) ?.Invoke(controller, null); } private static void RestoreRunManagerInstance(RunManager runManager) { if (runManager == null) { runManager = null; } typeof(RunManager) .GetProperty( nameof(RunManager.Instance), BindingFlags.Static | BindingFlags.Public) ?.GetSetMethod(true) ?.Invoke(null, new object[] { runManager }); } private static ActiveArtifactDefinition CreatePulse( string id, float normalCost, float chargedCost, ActiveArtifactEffect effect = ActiveArtifactEffect.Pulse) { ActiveArtifactDefinition definition = ScriptableObject.CreateInstance(); definition.Configure( id, id, "O", effect, DamageTag.Collision, Color.white, Color.magenta, normalCost, chargedCost, 0.8f, 15f, 30f, 1.25f, 2f, 1.5f, 3f); return definition; } private static ActiveArtifactDefinition CreateThunderCrashDefinition( string id) { ActiveArtifactDefinition definition = ScriptableObject.CreateInstance(); definition.Configure( id, id, "TH", ActiveArtifactEffect.ThunderCrash, DamageTag.Collision, Color.white, Color.yellow, 0f, 0f, 0.9f, 4f, 6f, 1f, 1.5f, 0f, 0f, normalEffectWidth: 1f, chargedEffectWidth: 1.5f, normalEffectDuration: 0.3f, chargedEffectDuration: 0.55f, multiHitInterval: 0.13f, normalTargetLimit: 12, chargedTargetLimit: 20, normalInvulnerability: 0.16f, chargedInvulnerability: 0.24f, normalBumpVulnerabilityIncrease: 0.25f, chargedBumpVulnerabilityIncrease: 0.4f, normalBumpVulnerabilityDuration: 3f, chargedBumpVulnerabilityDuration: 4f); return definition; } private EnemyController CreateEnemyTarget( string name, Vector2 position) { GameObject enemy = new(name); enemy.transform.SetParent(owner.transform); enemy.transform.position = position; enemy.AddComponent(); EnemyController controller = enemy.AddComponent(); typeof(EnemyController) .GetMethod("Awake", BindingFlags.Instance | BindingFlags.NonPublic) ?.Invoke(controller, null); return controller; } private static void AssertEllipseBoundary( MethodInfo distance, Vector2 radii) { float inside = (float)distance.Invoke( null, new object[] { new Vector2(radii.x * 0.5f, 0f), radii }); float edge = (float)distance.Invoke( null, new object[] { new Vector2(radii.x, 0f), radii }); float outside = (float)distance.Invoke( null, new object[] { new Vector2(radii.x * 1.001f, 0f), radii }); Assert.That(inside, Is.LessThan(1f)); Assert.That(edge, Is.EqualTo(1f).Within(0.0001f)); Assert.That(outside, Is.GreaterThan(1f)); } private static void AssertVector2Close(Vector2 actual, Vector2 expected) { Assert.That(actual.x, Is.EqualTo(expected.x).Within(0.0001f)); Assert.That(actual.y, Is.EqualTo(expected.y).Within(0.0001f)); } private static void AssertUniformScale(Vector3 actual, float expected) { Assert.That(actual.x, Is.EqualTo(expected).Within(0.0001f)); Assert.That(actual.y, Is.EqualTo(expected).Within(0.0001f)); Assert.That(actual.z, Is.EqualTo(expected).Within(0.0001f)); } private static void DestroyDefinitions( params ActiveArtifactDefinition[] definitions) { foreach (ActiveArtifactDefinition definition in definitions) { Object.DestroyImmediate(definition); } } private static EnemyDefinition CreateProtectedEnemyDefinition() { EnemyDefinition definition = ScriptableObject.CreateInstance(); definition.Configure( EnemyKind.ArmoredSkeleton, EnemyAttackShape.Box, 10f, 1f, 1f, 0.5f, 0.2f, 0.5f, 1f, 0f, 1f, 1f, 0f, 0); definition.ConfigureRole(EnemyRole.Normal); return definition; } private static EnemyController CreateProtectedEnemy( string name, EnemyDefinition definition, out GameObject enemyObject) { enemyObject = new GameObject(name); enemyObject.AddComponent(); enemyObject.AddComponent(); enemyObject.AddComponent(); EnemyController enemy = enemyObject.AddComponent(); enemy.Configure(definition, null); EnemyAttack enemyAttack = enemyObject.GetComponent(); BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; enemyAttack.GetType().GetMethod("Awake", flags)?.Invoke( enemyAttack, null); enemy.GetType().GetMethod("Awake", flags)?.Invoke(enemy, null); return enemy; } } public sealed class ProgressionAndRunFlowTests { private static readonly string[] ModifierPaths = { "Assets/_Project/Constants/LevelUp/CollisionDamage.asset", "Assets/_Project/Constants/LevelUp/MoveSpeed.asset", "Assets/_Project/Constants/LevelUp/MaxHealth.asset", "Assets/_Project/Constants/LevelUp/ArtifactGaugeGain.asset", }; [Test] public void ModifierCatalog_IsDataDrivenAndUsesUniqueSourceIds() { HashSet ids = new(); foreach (string path in ModifierPaths) { ModifierDefinition definition = AssetDatabase.LoadAssetAtPath(path); Assert.That(definition, Is.Not.Null, path); Assert.That(ids.Add(definition.ModifierId), Is.True); Assert.That(definition.DisplayName, Is.Not.Empty); } } [Test] public void ModifierDefinition_StopsAtConfiguredMaximumStacks() { GameObject owner = new("Modifier Test"); ModifierDefinition definition = ScriptableObject.CreateInstance(); try { PlayerStats stats = owner.AddComponent(); definition.Configure( "test.move-speed", "Move speed", CharacterStat.MoveSpeed, ModifierOperation.Increased, 0.1f, 2); Assert.That(definition.TryApply(stats, null), Is.True); Assert.That(definition.TryApply(stats, null), Is.True); Assert.That(definition.TryApply(stats, null), Is.False); Assert.That( stats.GetStackCount("test.move-speed", CharacterStat.MoveSpeed), Is.EqualTo(2)); } finally { Object.DestroyImmediate(definition); Object.DestroyImmediate(owner); } } [Test] public void FormalPlayerPrefab_UsesSixItemCatalogAndThreeColorOwnershipCap() { GameObject prefab = AssetDatabase.LoadAssetAtPath( "Assets/_Project/Prefabs/Player/Player.prefab"); ActiveArtifactController artifacts = prefab.GetComponent(); Assert.That(artifacts, Is.Not.Null); Assert.That(artifacts.CatalogCount, Is.EqualTo(6)); Assert.That(artifacts.MaxOwnedArtifacts, Is.EqualTo(3)); Assert.That(artifacts.MaxArtifactsPerColor, Is.EqualTo(1)); Assert.That(artifacts.OwnedArtifactCount, Is.Zero); Assert.That(GameplayConstants.Current.Artifacts.StartingGauge, Is.Zero); Assert.That( GameplayConstants.Current.Artifacts.NormalBumpGaugeGain, Is.EqualTo(4f)); Assert.That( GameplayConstants.Current.Artifacts.MovementGaugeGainPerSecond, Is.EqualTo(2f)); } [Test] public void RunTimingAndEnemyTargets_UseFormalProductionValues() { Assert.That( RunManager.GetEventTime(RunTimedEvent.Elite, false), Is.EqualTo(180f)); Assert.That( RunManager.GetEventTime(RunTimedEvent.MidBoss, false), Is.EqualTo(600f)); Assert.That( RunManager.GetEventTime(RunTimedEvent.FinalBoss, false), Is.EqualTo(1200f)); Assert.That( RunManager.GetEventTime(RunTimedEvent.Elite, true), Is.EqualTo(36f)); Assert.That( RunManager.GetEventTime(RunTimedEvent.MidBoss, true), Is.EqualTo(120f)); Assert.That( RunManager.GetEventTime(RunTimedEvent.FinalBoss, true), Is.EqualTo(240f)); Assert.That( RunManager.GetProgressionTime(60f, true), Is.EqualTo(300f)); Assert.That( RunManager.GetProgressionTime(60f, false), Is.EqualTo(60f)); Assert.That(SpawnDirector.TargetAliveCountAt(0f), Is.EqualTo(6)); Assert.That(SpawnDirector.TargetAliveCountAt(60f), Is.EqualTo(10)); Assert.That(SpawnDirector.TargetAliveCountAt(180f), Is.EqualTo(16)); Assert.That(SpawnDirector.TargetAliveCountAt(300f), Is.EqualTo(24)); Assert.That(SpawnDirector.TargetAliveCountAt(600f), Is.EqualTo(32)); Assert.That(SpawnDirector.TargetCrowdAliveCountAt(0f), Is.EqualTo(3)); Assert.That(SpawnDirector.TargetCrowdAliveCountAt(60f), Is.EqualTo(6)); Assert.That(SpawnDirector.TargetCrowdAliveCountAt(180f), Is.EqualTo(12)); Assert.That(SpawnDirector.TargetCrowdAliveCountAt(300f), Is.EqualTo(19)); Assert.That(SpawnDirector.TargetCrowdAliveCountAt(600f), Is.EqualTo(27)); Assert.That(SpawnDirector.TargetNormalAliveCountAt(0f), Is.EqualTo(3)); Assert.That(SpawnDirector.TargetNormalAliveCountAt(60f), Is.EqualTo(4)); Assert.That(SpawnDirector.TargetNormalAliveCountAt(180f), Is.EqualTo(4)); Assert.That(SpawnDirector.TargetNormalAliveCountAt(300f), Is.EqualTo(5)); Assert.That(SpawnDirector.TargetNormalAliveCountAt(600f), Is.EqualTo(5)); } [Test] public void RunEventEnemies_UseDistinctCombatSequencesAndArtifactRoles() { GameObject owner = new("Run Event Tuning Test"); try { SpawnDirector director = owner.AddComponent(); RunEventEnemyTuning elite = director.GetEventEnemyTuning(RunTimedEvent.Elite); RunEventEnemyTuning midBoss = director.GetEventEnemyTuning(RunTimedEvent.MidBoss); RunEventEnemyTuning finalBoss = director.GetEventEnemyTuning(RunTimedEvent.FinalBoss); Assert.That(elite.PrefabKind, Is.EqualTo(EnemyKind.ArmoredSkeleton)); Assert.That(elite.AttacksPerSequence, Is.EqualTo(2)); Assert.That(elite.CanBeLaunched, Is.True); EnemyKind[] expectedKinds = { EnemyKind.ArmoredSkeleton, EnemyKind.Werewolf, EnemyKind.Werebear, EnemyKind.ArmoredSkeleton, EnemyKind.Werewolf, EnemyKind.Werebear, }; int[] expectedSequences = { 2, 2, 3, 2, 2, 3 }; for (int ordinal = 0; ordinal < expectedKinds.Length; ordinal++) { RunEventEnemyTuning scheduledElite = director.GetEventEnemyTuning(RunTimedEvent.Elite, ordinal); Assert.That(scheduledElite.PrefabKind, Is.EqualTo(expectedKinds[ordinal])); Assert.That(scheduledElite.AttacksPerSequence, Is.EqualTo(expectedSequences[ordinal])); } Assert.That( midBoss.PrefabKind == EnemyKind.GreatswordSkeleton || midBoss.PrefabKind == EnemyKind.NecroGolem, Is.True, "The mid-boss tuning must select one of the two requested kinds."); Assert.That(midBoss.AttacksPerSequence, Is.EqualTo(3)); Assert.That(midBoss.CanBeLaunched, Is.False); Assert.That( finalBoss.PrefabKind, Is.EqualTo(EnemyKind.Necromancer)); Assert.That(finalBoss.AttacksPerSequence, Is.GreaterThanOrEqualTo(1)); Assert.That(finalBoss.CanBeLaunched, Is.False); } finally { Object.DestroyImmediate(owner); } } [Test] public void PrototypeEnemies_UseRequestedCrowdAndNormalCatalog() { string[] crowdNames = { "Bat", "Slime" }; foreach (string crowdName in crowdNames) { EnemyDefinition definition = AssetDatabase.LoadAssetAtPath( $"Assets/_Project/Constants/Enemies/{crowdName}.asset"); GameObject prefab = AssetDatabase.LoadAssetAtPath( $"Assets/_Project/Prefabs/Enemies/{crowdName}.prefab"); Assert.That(definition, Is.Not.Null, crowdName); Assert.That(prefab, Is.Not.Null, crowdName); Assert.That(definition.Role, Is.EqualTo(EnemyRole.Crowd), crowdName); Assert.That(definition.MaxHealth, Is.GreaterThan(0f), crowdName); Assert.That(prefab.transform.localScale.x, Is.EqualTo(1f), crowdName); Assert.That(prefab.transform.localScale.y, Is.EqualTo(1f), crowdName); Assert.That(prefab.GetComponent().isTrigger, Is.True, crowdName); } string[] normalNames = { "Skeleton", "Necrofire", "SkeletonArcher" }; foreach (string normalName in normalNames) { EnemyDefinition definition = AssetDatabase.LoadAssetAtPath( $"Assets/_Project/Constants/Enemies/{normalName}.asset"); GameObject prefab = AssetDatabase.LoadAssetAtPath( $"Assets/_Project/Prefabs/Enemies/{normalName}.prefab"); Assert.That(definition, Is.Not.Null, normalName); Assert.That(definition.Role, Is.EqualTo(EnemyRole.Normal), normalName); Assert.That(prefab, Is.Not.Null, normalName); Assert.That(prefab.transform.localScale.x, Is.EqualTo(1.25f).Within(0.0001f), normalName); Assert.That(prefab.transform.localScale.y, Is.EqualTo(1.25f).Within(0.0001f), normalName); Assert.That(prefab.GetComponent().isTrigger, Is.False, normalName); } Assert.That( AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Enemies/Lancer.asset"), Is.Not.Null, "The legacy Lancer definition remains available for regressions."); } [Test] public void CrowdAttackTokens_AreIndependentAndRateLimited() { GameObject managerObject = new("Attack Token Test"); GameObject firstCrowdObject = null; GameObject secondCrowdObject = null; GameObject normalObject = null; EnemyDefinition crowdDefinition = null; EnemyDefinition normalDefinition = null; try { AttackTokenManager manager = managerObject.AddComponent(); crowdDefinition = CreateEnemyDefinition(EnemyRole.Crowd); normalDefinition = CreateEnemyDefinition(EnemyRole.Normal); EnemyController firstCrowd = CreateEnemy( "First Crowd", crowdDefinition, out firstCrowdObject); EnemyController secondCrowd = CreateEnemy( "Second Crowd", crowdDefinition, out secondCrowdObject); EnemyController normal = CreateEnemy( "Normal", normalDefinition, out normalObject); Assert.That(manager.TryAcquire(firstCrowd), Is.True); Assert.That(manager.TryAcquire(normal), Is.True); Assert.That(manager.TryAcquire(secondCrowd), Is.False); Assert.That(manager.ActiveCrowdAttackers, Is.EqualTo(1)); manager.Release(firstCrowd); Assert.That(manager.TryAcquire(secondCrowd), Is.False); Assert.That(manager.ActiveCrowdAttackers, Is.Zero); Assert.That( AttackTokenManager.GetCrowdAttackLimit(179f, 1, 2, 180f), Is.EqualTo(1)); Assert.That( AttackTokenManager.GetCrowdAttackLimit(180f, 1, 2, 180f), Is.EqualTo(2)); } finally { Object.DestroyImmediate(firstCrowdObject); Object.DestroyImmediate(secondCrowdObject); Object.DestroyImmediate(normalObject); Object.DestroyImmediate(crowdDefinition); Object.DestroyImmediate(normalDefinition); Object.DestroyImmediate(managerObject); } } private static EnemyDefinition CreateEnemyDefinition(EnemyRole role) { EnemyDefinition definition = ScriptableObject.CreateInstance(); definition.Configure( EnemyKind.Skeleton, EnemyAttackShape.Box, 10f, 1f, 1f, 0.5f, 0.2f, 0.5f, 1f, 0f, 1f, 1f, 0f, 0); definition.ConfigureRole(role); return definition; } private static EnemyController CreateEnemy( string name, EnemyDefinition definition, out GameObject enemyObject) { enemyObject = new GameObject(name); enemyObject.SetActive(false); enemyObject.AddComponent(); enemyObject.AddComponent(); enemyObject.AddComponent(); EnemyController enemy = enemyObject.AddComponent(); enemy.Configure(definition, null); enemyObject.SetActive(true); return enemy; } } }