using System.Collections; using System.IO; using System.Reflection; using BumpCombat.Combat; using BumpCombat.Core; using BumpCombat.Enemies; using BumpCombat.Player; using BumpCombat.Progression; using BumpCombat.Spawning; using NUnit.Framework; using UnityEngine; using UnityEngine.SceneManagement; using UnityEngine.TestTools; namespace BumpCombat.PlayModeTests { public sealed class RunMissionTrackerPlayModeTests { private const BindingFlags NonPublicInstance = BindingFlags.Instance | BindingFlags.NonPublic; private bool previousGrantCatalogForTests; private bool previousProductionModeForTests; private float previousTimeScale; private string isolatedProfilePath; [SetUp] public void SetUp() { previousGrantCatalogForTests = ArtifactRewardController.GrantCatalogForTests; previousProductionModeForTests = RunManager.ForceProductionModeForTests; previousTimeScale = Time.timeScale; ArtifactRewardController.GrantCatalogForTests = true; RunManager.ForceProductionModeForTests = false; Time.timeScale = 1f; isolatedProfilePath = Path.Combine( Path.GetTempPath(), "BumpCombatMissionTests", System.Guid.NewGuid().ToString("N") + ".json"); } [TearDown] public void TearDown() { ArtifactRewardController.GrantCatalogForTests = previousGrantCatalogForTests; RunManager.ForceProductionModeForTests = previousProductionModeForTests; Time.timeScale = previousTimeScale; if (!string.IsNullOrEmpty(isolatedProfilePath)) { if (File.Exists(isolatedProfilePath)) { File.Delete(isolatedProfilePath); } string temporaryPath = isolatedProfilePath + ".tmp"; if (File.Exists(temporaryPath)) { File.Delete(temporaryPath); } string directory = Path.GetDirectoryName(isolatedProfilePath); if (Directory.Exists(directory) && Directory.GetFiles(directory).Length == 0) { Directory.Delete(directory); } } } [UnityTest] public IEnumerator ConfirmedCombatEvents_CountOnceBySpawnAndActivation() { yield return LoadCombatScene(); RunMissionTracker tracker = FindAny(); RunManager runManager = RunManager.Instance; ExperienceSystem experience = FindAny(); PlayerHealth health = FindAny(); SpawnDirector director = FindAny(); Assert.That(tracker, Is.Not.Null); Assert.That(runManager, Is.Not.Null); Assert.That(experience, Is.Not.Null); Assert.That(health, Is.Not.Null); Assert.That(director, Is.Not.Null); tracker.UseProfileStoreForTests(new UserProfileStore(isolatedProfilePath)); director.enabled = false; foreach (EnemyController existing in Object.FindObjectsByType( FindObjectsSortMode.None)) { existing.gameObject.SetActive(false); } ActiveArtifactController artifacts = health.GetComponent(); Assert.That(artifacts, Is.Not.Null); for (int i = 0; i < artifacts.OwnedArtifactCount && artifacts.CurrentArtifact?.Effect != ActiveArtifactEffect.Pulse; i++) { artifacts.SelectNext(); } Assert.That(artifacts.CurrentArtifact?.Effect, Is.EqualTo(ActiveArtifactEffect.Pulse), "The development catalog should expose the real Pulse activation for integration coverage."); EnemyController artifactTarget = SpawnEnemy( director, health.transform.position + Vector3.right * 0.75f, disableImmediately: false); yield return null; artifactTarget.enabled = false; Physics2D.SyncTransforms(); Assert.That(artifacts.TryUseCurrent(charged: false), Is.True, "The real artifact activation should execute with available gauge."); float artifactDeadline = Time.realtimeSinceStartup + 1f; while (tracker.GetProgress(3).Progress == 0 && Time.realtimeSinceStartup < artifactDeadline) { yield return null; } Assert.That(tracker.GetProgress(3).Progress, Is.EqualTo(1), "An actual successful Pulse contact must reach the tracker event hook."); yield return new WaitForSeconds(0.2f); EnemyController wrongColorShield = SpawnEnemy( director, health.transform.position + Vector3.right * 0.8f, EnemyKind.ArmoredSkeleton, disableImmediately: false); yield return null; wrongColorShield.enabled = false; ConfigureShieldForArtifactTest(wrongColorShield, ArtifactColor.Blue); Assert.That(wrongColorShield.ShieldColor, Is.EqualTo(ArtifactColor.Blue)); Assert.That(wrongColorShield.ShieldHitsRemaining, Is.EqualTo(2)); Assert.That(tracker.GetProgress(8).Progress, Is.Zero); Physics2D.SyncTransforms(); Assert.That(artifacts.TryUseCurrent(charged: false), Is.True); yield return new WaitForSeconds(0.2f); Assert.That(tracker.GetProgress(3).Progress, Is.EqualTo(1), "A wrong-color shield contact is not an effective artifact hit."); Assert.That(tracker.GetProgress(8).Progress, Is.Zero, "A wrong-color shield contact does not decrement its required hits."); Assert.That(wrongColorShield.ShieldHitsRemaining, Is.EqualTo(2), "Wrong-color contacts leave the shield's required-hit counter unchanged."); EnemyController matchingColorShield = SpawnEnemy( director, health.transform.position + Vector3.right * 0.8f, EnemyKind.ArmoredSkeleton, disableImmediately: false); yield return null; matchingColorShield.enabled = false; ConfigureShieldForArtifactTest(matchingColorShield, ArtifactColor.Red); Physics2D.SyncTransforms(); Assert.That(artifacts.TryUseCurrent(charged: false), Is.True); yield return new WaitForSeconds(0.2f); Assert.That(tracker.GetProgress(3).Progress, Is.EqualTo(2), "A matching shield pip is an effective artifact activation."); Assert.That(tracker.GetProgress(8).Progress, Is.EqualTo(1), "Only its actual matching-color decrement advances the shield mission."); EnemyController first = SpawnEnemy(director, Vector2.right * 3f); EnemyController second = SpawnEnemy(director, Vector2.left * 3f); CombatEvents.RaiseValidHit(CreateBumpHit(experience.gameObject, first.gameObject, HitSide.Back, 0f)); Assert.That(tracker.GetProgress(2).Progress, Is.Zero, "A contact that dealt no damage must not count as a successful bump."); CombatHitResult rearHit = CreateBumpHit( experience.gameObject, first.gameObject, HitSide.Back, 1f); CombatEvents.RaiseValidHit(rearHit); CombatEvents.RaiseValidHit(rearHit); CombatEvents.RaiseValidHit(CreateBumpHit( experience.gameObject, second.gameObject, HitSide.Back, 1f)); Assert.That(tracker.GetProgress(2).Progress, Is.EqualTo(3)); Assert.That(tracker.GetProgress(4).Progress, Is.EqualTo(2), "Rear progress is once per enemy lifetime, while bump progress records each real hit."); EnemyController lethal = SpawnEnemy( director, health.transform.position + Vector3.left * 4f, disableImmediately: false); yield return null; Assert.That(lethal.CurrentHealth, Is.GreaterThan(0f)); SetPrivateField(lethal, "experienceOrbPrefab", null); Assert.That(lethal.TryTakeDamage( lethal.CurrentHealth + 1f, Vector2.right, 0.1f), Is.True, "A real lethal EnemyController hit should publish its death event."); Assert.That(tracker.GetProgress(0).Progress, Is.EqualTo(1)); CombatEvents.RaiseEnemyDied(lethal.gameObject); Assert.That(tracker.GetProgress(0).Progress, Is.EqualTo(1), "A duplicate callback for that same spawn lifetime must not count twice."); experience.AddExperience(1); Assert.That(tracker.GetProgress(1).Progress, Is.Zero, "Free mission XP must not masquerade as an orb pickup."); GameObject orbObject = new("Mission test experience orb"); orbObject.transform.position = experience.transform.position; ExperienceOrb orb = orbObject.AddComponent(); Rigidbody2D orbBody = orbObject.GetComponent(); orbBody.gravityScale = 0f; CircleCollider2D orbCollider = orbObject.GetComponent(); orbCollider.isTrigger = true; orb.Initialize(1); yield return null; yield return new WaitForFixedUpdate(); Assert.That(tracker.GetProgress(1).Progress, Is.EqualTo(1), "A nearby orb should report one actual pickup through its normal physics path."); health.GrantInvulnerability(2f); health.TryTakeDamage(0f, Vector2.zero, 0f); health.TryTakeDamage(5f, Vector2.right, 0.1f); Assert.That(tracker.GetProgress(7).Progress, Is.Zero, "Zero damage and ordinary invulnerability do not count as a guard block."); Assert.That(health.TryActivateGuard(), Is.True); Assert.That(health.TryTakeDamage(5f, Vector2.right, 0.1f), Is.False); Assert.That(health.TryTakeDamage(5f, Vector2.right, 0.1f), Is.False); Assert.That(tracker.GetProgress(7).Progress, Is.EqualTo(1), "Multiple blocked hits from one guard activation count once."); CombatEvents.RaiseArtifactEffectiveHit( first.gameObject, 2001, ActiveArtifactEffect.Pulse, isCharged: true, matchedShieldRequirement: false); CombatEvents.RaiseArtifactEffectiveHit( first.gameObject, 2001, ActiveArtifactEffect.Pulse, isCharged: true, matchedShieldRequirement: false); Assert.That(tracker.GetProgress(3).Progress, Is.EqualTo(3)); Assert.That(tracker.GetProgress(9).Progress, Is.EqualTo(1), "One charged activation counts once even if it affects a target repeatedly."); Assert.That(tracker.GetProgress(8).Progress, Is.EqualTo(1), "Only the earlier actual matching-color shield decrement has counted."); EnemyController[] crowdTargets = new EnemyController[5]; for (int i = 0; i < crowdTargets.Length; i++) { crowdTargets[i] = i == 0 ? first : SpawnEnemy(director, new Vector2(i + 5f, 0f)); CombatEvents.RaiseArtifactEffectiveHit( crowdTargets[i].gameObject, 3001, ActiveArtifactEffect.Cyclone, isCharged: false, matchedShieldRequirement: i == 0); } Assert.That(tracker.GetProgress(5).Progress, Is.EqualTo(5)); Assert.That(tracker.GetProgress(5).Completed, Is.True); Assert.That(tracker.GetProgress(8).Progress, Is.EqualTo(2), "Only a counted matching-color shield decrement advances the shield mission."); Assert.That(tracker.GetLifetimeMissionCompletions(), Is.Zero, "A development/test run must not write permanent mission records."); LogAssert.NoUnexpectedReceived(); } [UnityTest] public IEnumerator RampageWindowIncludesExactThreeSecondBoundary_AndGameOverStopsEvents() { yield return LoadCombatScene(); RunMissionTracker tracker = FindAny(); RunManager runManager = RunManager.Instance; ExperienceSystem experience = FindAny(); SpawnDirector director = FindAny(); Assert.That(tracker, Is.Not.Null); Assert.That(runManager, Is.Not.Null); Assert.That(experience, Is.Not.Null); Assert.That(director, Is.Not.Null); tracker.UseProfileStoreForTests(new UserProfileStore(isolatedProfilePath)); director.enabled = false; foreach (EnemyController existing in Object.FindObjectsByType( FindObjectsSortMode.None)) { existing.gameObject.SetActive(false); } SetAutoProperty(runManager, "ElapsedTime", 0f); EnemyController first = SpawnEnemy(director, new Vector2(2f, 0f)); CombatEvents.RaiseEnemyDied(first.gameObject); CombatEvents.RaiseEnemyDied(first.gameObject); Assert.That(tracker.GetProgress(10).Progress, Is.EqualTo(1), "A duplicate death callback from one spawn lifetime counts once."); EnemyController summoned = SpawnEnemy(director, new Vector2(3f, 0f)); summoned.ConfigureSummonedEnemy( null, RunEventEnemyTuning.Create( EnemyKind.Skeleton, 1f, 1, 1f, Color.white, 1f, 1f, 1f, 1f, 1f, 1f, 1f, 1f, 1f, 1, 1f, 1f, 1, 1f, true), phaseProtected: false); CombatEvents.RaiseEnemyDied(summoned.gameObject); Assert.That(tracker.GetProgress(10).Progress, Is.EqualTo(1), "Summons do not count toward the non-summoned kill streak."); Assert.That(tracker.GetProgress(0).Progress, Is.EqualTo(1), "Summons also do not count toward the first-sweep mission."); for (int i = 0; i < 8; i++) { EnemyController enemy = SpawnEnemy(director, new Vector2(i + 4f, 0f)); CombatEvents.RaiseEnemyDied(enemy.gameObject); } Assert.That(tracker.GetProgress(10).Progress, Is.EqualTo(9)); SetAutoProperty(runManager, "ElapsedTime", 4f); EnemyController tenth = SpawnEnemy(director, Vector2.left * 4f); CombatEvents.RaiseEnemyDied(tenth.gameObject); Assert.That(tracker.GetProgress(10).Progress, Is.EqualTo(1), "Kills older than three combat seconds leave the sliding window."); for (int i = 0; i < 8; i++) { SetAutoProperty(runManager, "ElapsedTime", 4.1f + i * 0.1f); EnemyController enemy = SpawnEnemy(director, new Vector2(i + 6f, 0f)); CombatEvents.RaiseEnemyDied(enemy.gameObject); } Assert.That(tracker.GetProgress(10).Progress, Is.EqualTo(9)); SetAutoProperty(runManager, "ElapsedTime", 7f); EnemyController final = SpawnEnemy(director, Vector2.left * 6f); CombatEvents.RaiseEnemyDied(final.gameObject); Assert.That(tracker.GetProgress(10).Progress, Is.EqualTo(10)); Assert.That(tracker.GetProgress(10).Completed, Is.True, "A kill exactly three combat seconds after the oldest valid kill completes the inclusive window."); runManager.EndRun(); int bumpBeforeTerminalEvents = tracker.GetProgress(2).Progress; int orbBeforeTerminalEvents = tracker.GetProgress(1).Progress; CombatEvents.RaiseValidHit(CreateBumpHit( experience.gameObject, tenth.gameObject, HitSide.Front, 1f)); CombatEvents.RaiseArtifactEffectiveHit( tenth.gameObject, 4001, ActiveArtifactEffect.Pulse, isCharged: false, matchedShieldRequirement: false); experience.CollectExperienceOrb(1); Assert.That(tracker.GetProgress(2).Progress, Is.EqualTo(bumpBeforeTerminalEvents)); Assert.That(tracker.GetProgress(1).Progress, Is.EqualTo(orbBeforeTerminalEvents)); Assert.That(tracker.GetProgress(3).Progress, Is.Zero); Assert.That(tracker.GetProgress(10).Progress, Is.EqualTo(10), "Completed rampage progress must not decay after its success or after game over."); LogAssert.NoUnexpectedReceived(); } [UnityTest] public IEnumerator ProductionRun_PersistsMissionCompletionAndHonorsUnlockGates() { ArtifactRewardController.GrantCatalogForTests = false; yield return LoadCombatScene(); RunMissionTracker tracker = FindAny(); RunManager runManager = RunManager.Instance; ExperienceSystem experience = FindAny(); Assert.That(tracker, Is.Not.Null); Assert.That(runManager, Is.Not.Null); Assert.That(experience, Is.Not.Null); Assert.That(runManager.IsTitleScreen, Is.True); tracker.UseProfileStoreForTests(new UserProfileStore(isolatedProfilePath)); Assert.That(runManager.BeginRun(RunMode.Production), Is.True); Assert.That(tracker.GetProgress(7).Locked, Is.True, "Guard missions stay locked until the run unlocks guard."); Assert.That(tracker.GetProgress(9).Locked, Is.True, "Charged missions stay locked until the run unlocks charge."); for (int i = 0; i < 50; i++) { experience.CollectExperienceOrb(0); } Assert.That(tracker.GetProgress(1).Completed, Is.True); Assert.That(tracker.GetLifetimeMissionCompletions(), Is.EqualTo(1)); Assert.That(File.Exists(isolatedProfilePath), Is.True); string savedProfile = File.ReadAllText(isolatedProfilePath); Assert.That(savedProfile, Does.Contain("mission.growth_footing")); runManager.UnlockGuard(); runManager.UnlockArtifactCharge(); Assert.That(tracker.GetProgress(7).Locked, Is.False); Assert.That(tracker.GetProgress(9).Locked, Is.False); Assert.That(tracker.ProfileStore.Profile.achievements, Is.Empty, "No uncommissioned achievements are synthesized into the profile."); Assert.That(tracker.ProfileStore.Profile.unlockedSkinIds, Is.Empty); LogAssert.NoUnexpectedReceived(); } private static IEnumerator LoadCombatScene() { AsyncOperation load = SceneManager.LoadSceneAsync("CombatPrototype"); while (!load.isDone) { yield return null; } yield return null; yield return null; } private static T FindAny() where T : Object { return Object.FindAnyObjectByType(); } private static EnemyController SpawnEnemy( SpawnDirector director, Vector2 position, EnemyKind kind = EnemyKind.Skeleton, bool disableImmediately = true) { EnemyController prefab = FindCatalogPrefab(director, kind); Assert.That(prefab, Is.Not.Null); EnemyController enemy = Object.Instantiate( prefab, position, Quaternion.identity); if (disableImmediately) { enemy.enabled = false; } return enemy; } private static EnemyController FindCatalogPrefab( SpawnDirector director, EnemyKind kind) { string[] fieldNames = { "enemyPrefabs", "elitePrefabs", "midBossPrefabs", "finalBossPrefabs", }; foreach (string fieldName in fieldNames) { FieldInfo field = typeof(SpawnDirector).GetField( fieldName, NonPublicInstance); EnemyController[] prefabs = field?.GetValue(director) as EnemyController[]; if (prefabs == null) { continue; } foreach (EnemyController prefab in prefabs) { if (prefab != null && prefab.Definition != null && prefab.Definition.Kind == kind) { return prefab; } } } return null; } private static CombatHitResult CreateBumpHit( GameObject attacker, GameObject target, HitSide side, float damage) { return new CombatHitResult( attacker, target, false, side, damage, Vector2.right, 1f, target.transform.position); } private static void ConfigureShieldForArtifactTest( EnemyController enemy, ArtifactColor shieldColor) { SetPrivateField(enemy, "groggyTierConfigured", true); SetPrivateField(enemy, "groggyTier", RunTimedEvent.Elite); SetPrivateField(enemy, "requiredGroggyDistinctArtifacts", 1); SetPrivateField(enemy, "requiredGroggyContactsPerArtifact", 2); SetPrivateField(enemy, "groggyContactCount", 0); SetPrivateField(enemy, "isGroggy", false); SetPrivateField(enemy, "shieldColor", shieldColor); } private static void SetPrivateField(object target, string fieldName, T value) { target.GetType() .GetField(fieldName, NonPublicInstance) ?.SetValue(target, value); } private static void SetAutoProperty(object target, string propertyName, T value) { target.GetType() .GetField( $"<{propertyName}>k__BackingField", NonPublicInstance) ?.SetValue(target, value); } } }