Files
tiny_tackle_heroes/Assets/_Project/Tests/PlayMode/RunMissionTrackerPlayModeTests.cs
T

538 lines
23 KiB
C#

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<RunMissionTracker>();
RunManager runManager = RunManager.Instance;
ExperienceSystem experience = FindAny<ExperienceSystem>();
PlayerHealth health = FindAny<PlayerHealth>();
SpawnDirector director = FindAny<SpawnDirector>();
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<EnemyController>(
FindObjectsSortMode.None))
{
existing.gameObject.SetActive(false);
}
ActiveArtifactController artifacts = health.GetComponent<ActiveArtifactController>();
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<ExperienceOrb>(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<ExperienceOrb>();
Rigidbody2D orbBody = orbObject.GetComponent<Rigidbody2D>();
orbBody.gravityScale = 0f;
CircleCollider2D orbCollider = orbObject.GetComponent<CircleCollider2D>();
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<RunMissionTracker>();
RunManager runManager = RunManager.Instance;
ExperienceSystem experience = FindAny<ExperienceSystem>();
SpawnDirector director = FindAny<SpawnDirector>();
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<EnemyController>(
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<RunMissionTracker>();
RunManager runManager = RunManager.Instance;
ExperienceSystem experience = FindAny<ExperienceSystem>();
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<T>() where T : Object
{
return Object.FindAnyObjectByType<T>();
}
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<T>(object target, string fieldName, T value)
{
target.GetType()
.GetField(fieldName, NonPublicInstance)
?.SetValue(target, value);
}
private static void SetAutoProperty<T>(object target, string propertyName, T value)
{
target.GetType()
.GetField(
$"<{propertyName}>k__BackingField",
NonPublicInstance)
?.SetValue(target, value);
}
}
}