using System.Linq; using BumpCombat.Constants; using BumpCombat.Core; using BumpCombat.Defense; using BumpCombat.Defense.Comparison; using BumpCombat.Player; using BumpCombat.Progression; using NUnit.Framework; using UnityEngine; namespace BumpCombat.PlayModeTests { public sealed class Defense25DRewardChoicePlayModeTests { [Test] public void LevelUp_OffersMixedDistinctChoicesAndIgnoresDuplicateToken() { GameObject playerObject = new("Reward Choice Player"); ModifierDefinition playerModifier = CreatePlayerModifier("move-speed"); Defense25DRewardConstants constants = CreateConstants(includeEpic: true); try { PlayerStats player = playerObject.AddComponent(); Defense25DTowerModifiers towerModifiers = new(constants); Defense25DRewardChoices choices = new( constants, new[] { playerModifier }, player, towerModifiers, _ => true); Assert.That(choices.QueueReward( Defense25DRewardSource.LevelUp, "level:2"), Is.True); Assert.That(choices.QueueReward( Defense25DRewardSource.LevelUp, "level:2"), Is.False); Assert.That(choices.CurrentChoices, Has.Count.EqualTo(3)); Assert.That(choices.CurrentChoices.Any(choice => choice.TargetKind == Defense25DRewardTargetKind.PlayerModifier), Is.True); Assert.That(choices.CurrentChoices.Any(choice => choice.TargetKind == Defense25DRewardTargetKind.TowerModifier), Is.True); Assert.That(choices.CurrentChoices.Select(choice => (choice.TargetKind, choice.DefinitionId, choice.Rarity)) .Distinct().ToList(), Has.Count.EqualTo(choices.CurrentChoices.Count)); } finally { Object.DestroyImmediate(playerModifier); Object.DestroyImmediate(constants); Object.DestroyImmediate(playerObject); } } [Test] public void PlayerChoice_ShowsKoreanEffectAndRareValueThatMatchesAppliedModifier() { GameObject playerObject = new("Reward Display Player"); ModifierDefinition playerModifier = CreatePlayerModifier("move-speed"); Defense25DRewardConstants constants = CreateConstants(includeEpic: true); constants.ChoiceCount = 1; constants.LevelUpRarityWeights = new Defense25DRewardRarityWeights { Common = 0, Rare = 100, Epic = 0, }; constants.TowerModifierCatalog = System.Array.Empty(); try { PlayerStats player = playerObject.AddComponent(); Defense25DRewardChoices choices = new( constants, new[] { playerModifier }, player, new Defense25DTowerModifiers(constants), _ => false); Assert.That(choices.QueueReward( Defense25DRewardSource.LevelUp, "level:display"), Is.True); Defense25DRewardChoice choice = choices.CurrentChoices.Single(); Assert.That(choice.Rarity, Is.EqualTo(Defense25DRewardRarity.Rare)); Assert.That(choice.DisplayData.Title, Is.EqualTo("이동 속도 강화")); Assert.That(choice.DisplayData.Description, Is.EqualTo("이동 속도 +30%\n선택하면 즉시 적용됩니다.")); Assert.That(choices.TryChooseReward(0, "level:display"), Is.True); Assert.That(player.Evaluate(CharacterStat.MoveSpeed, 100f), Is.EqualTo(130f).Within(0.001f)); } finally { Object.DestroyImmediate(playerModifier); Object.DestroyImmediate(constants); Object.DestroyImmediate(playerObject); } } [Test] public void RefreshChoices_PreservesSnapshotAndReplacesOnlyAnIneligibleEntry() { GameObject playerObject = new("Refresh Snapshot Player"); ModifierDefinition[] definitions = Enumerable.Range(0, 4) .Select(index => CreatePlayerModifier($"snapshot-{index}")) .ToArray(); Defense25DRewardConstants constants = CreateConstants(includeEpic: true); constants.TowerModifierCatalog = System.Array.Empty(); constants.LevelUpRarityWeights = new Defense25DRewardRarityWeights { Common = 100, Rare = 0, Epic = 0, }; try { PlayerStats player = playerObject.AddComponent(); Defense25DRewardChoices choices = new( constants, definitions, player, new Defense25DTowerModifiers(constants), _ => false); choices.QueueReward(Defense25DRewardSource.LevelUp, "level:snapshot"); Defense25DRewardChoice[] snapshot = choices.CurrentChoices.ToArray(); Assert.That(snapshot, Has.Length.EqualTo(3)); constants.LevelUpRarityWeights = new Defense25DRewardRarityWeights { Common = 0, Rare = 0, Epic = 100, }; choices.RefreshChoices(); for (int index = 0; index < snapshot.Length; index++) { Assert.That(choices.CurrentChoices[index], Is.SameAs(snapshot[index]), "A UI rebind retains each eligible token candidate and its order."); } ModifierDefinition invalidated = definitions.Single(definition => definition.ModifierId == snapshot[0].DefinitionId); Assert.That(invalidated.TryApplyToModel(player, null), Is.True); Assert.That(invalidated.TryApplyToModel(player, null), Is.True); choices.RefreshChoices(); Assert.That(choices.CurrentChoices, Has.Count.EqualTo(3)); Assert.That(choices.CurrentChoices[0], Is.Not.SameAs(snapshot[0])); Assert.That(choices.CurrentChoices[0].DefinitionId, Is.Not.EqualTo(snapshot[0].DefinitionId)); Assert.That(choices.CurrentChoices[1], Is.SameAs(snapshot[1])); Assert.That(choices.CurrentChoices[2], Is.SameAs(snapshot[2])); } finally { foreach (ModifierDefinition definition in definitions) Object.DestroyImmediate(definition); Object.DestroyImmediate(constants); Object.DestroyImmediate(playerObject); } } [Test] public void EmptyRarityFallbacksOnlyMoveDownward() { Defense25DRewardConstants constants = CreateConstants(includeEpic: true); constants.TowerModifierCatalog = new[] { TowerModifier("only-rare", DefenseTowerKind.Direct, Defense25DTowerModifierEffect.AttackDamageIncreased, Defense25DRewardRarity.Rare, 0.2f, 5), }; try { constants.LevelUpRarityWeights = new Defense25DRewardRarityWeights { Common = 100, Rare = 0, Epic = 0, }; Defense25DRewardChoices commonWithoutCommon = NewTowerOnlyChoices(constants); commonWithoutCommon.QueueReward( Defense25DRewardSource.LevelUp, "level:common-no-upgrade"); Assert.That(commonWithoutCommon.CurrentChoices, Is.Empty, "A Common request does not upgrade to a populated Rare tier."); constants.TowerModifierCatalog = new[] { TowerModifier("only-common", DefenseTowerKind.Direct, Defense25DTowerModifierEffect.AttackDamageIncreased, Defense25DRewardRarity.Common, 0.1f, 5), }; constants.LevelUpRarityWeights = new Defense25DRewardRarityWeights { Common = 0, Rare = 100, Epic = 0, }; Defense25DRewardChoices rareToCommon = NewTowerOnlyChoices(constants); rareToCommon.QueueReward( Defense25DRewardSource.LevelUp, "level:rare-to-common"); Assert.That(rareToCommon.CurrentChoices.Single().Rarity, Is.EqualTo(Defense25DRewardRarity.Common)); constants.TowerModifierCatalog = new[] { TowerModifier("only-rare", DefenseTowerKind.Direct, Defense25DTowerModifierEffect.AttackDamageIncreased, Defense25DRewardRarity.Rare, 0.2f, 5), }; constants.LevelUpRarityWeights = new Defense25DRewardRarityWeights { Common = 0, Rare = 0, Epic = 100, }; Defense25DRewardChoices epicToRare = NewTowerOnlyChoices(constants); epicToRare.QueueReward( Defense25DRewardSource.LevelUp, "level:epic-to-rare"); Assert.That(epicToRare.CurrentChoices.Single().Rarity, Is.EqualTo(Defense25DRewardRarity.Rare)); } finally { Object.DestroyImmediate(constants); } } [Test] public void PendingLevelUpPreemptsUnopenedRewardsButNotAnOpenedReward() { GameObject playerObject = new("Reward Priority Player"); ModifierDefinition playerModifier = CreatePlayerModifier("priority"); Defense25DRewardConstants constants = CreateConstants(includeEpic: true); constants.TowerModifierCatalog = System.Array.Empty(); constants.LevelUpRarityWeights = new Defense25DRewardRarityWeights { Common = 100, Rare = 0, Epic = 0, }; constants.GlobalEliteRarityWeights = constants.LevelUpRarityWeights; try { PlayerStats player = playerObject.AddComponent(); Defense25DRewardChoices choices = new( constants, new[] { playerModifier }, player, new Defense25DTowerModifiers(constants), _ => false); choices.QueueReward(Defense25DRewardSource.GlobalElite, "elite:pending"); Defense25DRewardChoice unopenedEliteChoice = choices.CurrentChoices[0]; choices.QueueReward(Defense25DRewardSource.LevelUp, "level:priority"); Assert.That(choices.CurrentRewardToken, Is.EqualTo("level:priority")); Assert.That(choices.CurrentRewardIsOpened, Is.False); Assert.That(choices.GetPendingRewardCount( Defense25DRewardSource.GlobalElite), Is.EqualTo(1)); Assert.That(choices.TryChooseReward(0, "level:priority"), Is.True); Assert.That(choices.CurrentRewardToken, Is.EqualTo("elite:pending")); Assert.That(choices.CurrentChoices[0], Is.SameAs(unopenedEliteChoice), "The preempted unopened reward retains its token snapshot."); choices.Reset(); choices.QueueReward(Defense25DRewardSource.GlobalElite, "elite:opened"); Assert.That(choices.MarkCurrentRewardOpened(), Is.True); choices.QueueReward(Defense25DRewardSource.LevelUp, "level:while-open"); Assert.That(choices.CurrentRewardToken, Is.EqualTo("elite:opened")); Assert.That(choices.CurrentRewardIsOpened, Is.True); Assert.That(choices.TryChooseReward(0, "elite:opened"), Is.True); Assert.That(choices.CurrentRewardToken, Is.EqualTo("level:while-open")); Assert.That(choices.CurrentRewardIsOpened, Is.False); } finally { Object.DestroyImmediate(playerModifier); Object.DestroyImmediate(constants); Object.DestroyImmediate(playerObject); } } [Test] public void MixedTargetGuaranteeIsResolvedBeforeEachTargetsRarityRoll() { GameObject playerObject = new("Target First Player"); ModifierDefinition playerModifier = CreatePlayerModifier("target-first"); Defense25DRewardConstants constants = CreateConstants(includeEpic: true); constants.TowerModifierCatalog = new[] { TowerModifier("tower.epic.only", DefenseTowerKind.Direct, Defense25DTowerModifierEffect.AttackDamageIncreased, Defense25DRewardRarity.Epic, 0.35f, 1), }; constants.LevelUpRarityWeights = new Defense25DRewardRarityWeights { Common = 80, Rare = 0, Epic = 20, }; try { PlayerStats player = playerObject.AddComponent(); Defense25DRewardChoices choices = new( constants, new[] { playerModifier }, player, new Defense25DTowerModifiers(constants), _ => true); for (int index = 0; index < 20; index++) { choices.Reset(); choices.QueueReward( Defense25DRewardSource.LevelUp, $"level:target-first-{index}"); Assert.That(choices.CurrentChoices.Select(choice => choice.TargetKind) .Distinct().ToList(), Is.EquivalentTo(new[] { Defense25DRewardTargetKind.PlayerModifier, Defense25DRewardTargetKind.TowerModifier, }), "Both eligible target groups are selected before rarity is rolled."); } } finally { Object.DestroyImmediate(playerModifier); Object.DestroyImmediate(constants); Object.DestroyImmediate(playerObject); } } [Test] public void MissionRelicRewardUsesTokenCandidatesAndCanResolveOnlyOnce() { Defense25DRewardConstants constants = CreateConstants(includeEpic: true); constants.TowerModifierCatalog = System.Array.Empty(); Defense25DRewardChoice[] candidates = { Defense25DRewardChoice.CreatePersonalMissionRelic( "relic.first", "Relic", new Defense25DRewardDisplayData("First", "First relic")), Defense25DRewardChoice.CreatePersonalMissionRelic( "relic.second", "Relic", new Defense25DRewardDisplayData("Second", "Second relic")), }; int resolved = 0; try { Defense25DRewardChoices choices = new( constants, System.Array.Empty(), null, new Defense25DTowerModifiers(constants), _ => false, getMissionRelicCandidates: _ => candidates, chooseMissionRelic: choice => { resolved++; return choice.DefinitionId == "relic.second"; }); Assert.That(choices.QueueReward( Defense25DRewardSource.PersonalMissionRelic, "mission:15:relic"), Is.True); Assert.That(choices.QueueReward( Defense25DRewardSource.PersonalMissionRelic, "mission:15:relic"), Is.False); Assert.That(choices.CurrentChoices, Has.Count.EqualTo(2)); Assert.That(choices.TryChooseReward(1, "mission:15:relic"), Is.True); Assert.That(resolved, Is.EqualTo(1)); Assert.That(choices.TryChooseReward(1, "mission:15:relic"), Is.False); Assert.That(choices.QueueReward( Defense25DRewardSource.PersonalMissionRelic, "mission:15:relic"), Is.False); } finally { Object.DestroyImmediate(constants); } } [Test] public void SameSeedAndTokenProduceSameCandidateSnapshot() { GameObject firstPlayerObject = new("Seeded Reward Player A"); GameObject secondPlayerObject = new("Seeded Reward Player B"); ModifierDefinition playerModifier = CreatePlayerModifier("seeded"); Defense25DRewardConstants constants = CreateConstants(includeEpic: true); try { PlayerStats firstPlayer = firstPlayerObject.AddComponent(); PlayerStats secondPlayer = secondPlayerObject.AddComponent(); Defense25DRewardChoices first = new( constants, new[] { playerModifier }, firstPlayer, new Defense25DTowerModifiers(constants), _ => true); Defense25DRewardChoices replay = new( constants, new[] { playerModifier }, secondPlayer, new Defense25DTowerModifiers(constants), _ => true); first.QueueReward(Defense25DRewardSource.LevelUp, "level:seeded-replay"); replay.QueueReward(Defense25DRewardSource.LevelUp, "level:seeded-replay"); Assert.That(first.CurrentChoices.Select(choice => (choice.TargetKind, choice.DefinitionId, choice.Rarity)).ToArray(), Is.EqualTo(replay.CurrentChoices.Select(choice => (choice.TargetKind, choice.DefinitionId, choice.Rarity)).ToArray())); } finally { Object.DestroyImmediate(playerModifier); Object.DestroyImmediate(constants); Object.DestroyImmediate(firstPlayerObject); Object.DestroyImmediate(secondPlayerObject); } } [Test] public void GlobalMidBoss_OffersEpicAndFallsBackToCurrencyWhenNoEpicIsEligible() { GameObject playerObject = new("MidBoss Reward Player"); ModifierDefinition playerModifier = CreatePlayerModifier("collision-damage"); Defense25DRewardConstants constants = CreateConstants(includeEpic: true); int grantedCurrency = 0; int grantedAmount = 0; try { PlayerStats player = playerObject.AddComponent(); Defense25DRewardChoices choices = new( constants, new[] { playerModifier }, player, new Defense25DTowerModifiers(constants), _ => true); choices.QueueReward( Defense25DRewardSource.GlobalMidBoss, "midboss:240"); Assert.That(choices.CurrentChoices.Any(choice => choice.Rarity == Defense25DRewardRarity.Epic), Is.True); constants.TowerModifierCatalog = constants.TowerModifierCatalog .Where(definition => definition.Rarity != Defense25DRewardRarity.Epic) .ToArray(); player.AddModifier(new StatModifier( playerModifier.ModifierId, playerModifier.Stat, playerModifier.Operation, playerModifier.Value, playerModifier.MaximumStacks)); player.AddModifier(new StatModifier( playerModifier.ModifierId, playerModifier.Stat, playerModifier.Operation, playerModifier.Value, playerModifier.MaximumStacks)); Defense25DRewardChoices noEpicChoices = new( constants, new[] { playerModifier }, player, new Defense25DTowerModifiers(constants), _ => true, grantCurrency: amount => { grantedCurrency++; grantedAmount += amount; }); noEpicChoices.QueueReward( Defense25DRewardSource.GlobalMidBoss, "midboss:no-epic"); Assert.That(noEpicChoices.CurrentChoices.Any(choice => choice.TargetKind == Defense25DRewardTargetKind.Currency), Is.True); int currencyChoiceIndex = noEpicChoices.CurrentChoices .Select((choice, index) => (choice, index)) .Single(pair => pair.choice.TargetKind == Defense25DRewardTargetKind.Currency).index; Assert.That(noEpicChoices.TryChooseReward( currencyChoiceIndex, "midboss:no-epic"), Is.True); Assert.That(grantedCurrency, Is.EqualTo(1)); Assert.That(grantedAmount, Is.EqualTo(constants.EmptyEpicFallbackCurrency)); Assert.That(noEpicChoices.TryChooseReward( currencyChoiceIndex, "midboss:no-epic"), Is.False); Assert.That(grantedCurrency, Is.EqualTo(1)); } finally { Object.DestroyImmediate(playerModifier); Object.DestroyImmediate(constants); Object.DestroyImmediate(playerObject); } } [TestCase(Defense25DRewardSource.LevelUp)] [TestCase(Defense25DRewardSource.GlobalElite)] public void EmptyModifierPoolOffersResolvableCurrencyFallback( Defense25DRewardSource source) { Defense25DRewardConstants constants = CreateConstants(includeEpic: true); constants.TowerModifierCatalog = System.Array.Empty(); int grantCount = 0; int grantedAmount = 0; try { Defense25DRewardChoices choices = new( constants, System.Array.Empty(), null, new Defense25DTowerModifiers(constants), _ => false, grantCurrency: amount => { grantCount++; grantedAmount = amount; }); Assert.That(choices.QueueReward(source, "empty:" + source), Is.True); Assert.That(choices.CurrentChoices, Has.Count.EqualTo(1)); Assert.That(choices.CurrentChoices[0].TargetKind, Is.EqualTo(Defense25DRewardTargetKind.Currency)); Assert.That(choices.CurrentChoices[0].DisplayData.Title, Is.EqualTo("보상 대체")); Assert.That(choices.CurrentChoices[0].DisplayData.Description, Is.EqualTo("적격한 보상이 없어 재화 10개를 받습니다.\n선택하면 즉시 적용됩니다.")); Assert.That(choices.TryChooseReward(0, "empty:" + source), Is.True); Assert.That(grantCount, Is.EqualTo(1)); Assert.That(grantedAmount, Is.EqualTo(constants.EmptyEpicFallbackCurrency)); Assert.That(choices.HasPendingReward, Is.False); } finally { Object.DestroyImmediate(constants); } } [Test] public void EmptyRewardWithoutCurrencyCallbackAutomaticallyResolvesToken() { Defense25DRewardConstants constants = CreateConstants(includeEpic: true); constants.TowerModifierCatalog = System.Array.Empty(); try { Defense25DRewardChoices choices = new( constants, System.Array.Empty(), null, new Defense25DTowerModifiers(constants), _ => false); int autoResolvedCount = 0; string autoResolvedToken = null; choices.AutomaticallyResolved += (source, token) => { Assert.That(source, Is.EqualTo(Defense25DRewardSource.LevelUp)); autoResolvedCount++; autoResolvedToken = token; }; Assert.That(choices.QueueReward( Defense25DRewardSource.LevelUp, "empty:no-currency"), Is.True); Assert.That(choices.HasPendingReward, Is.False); Assert.That(choices.PendingRewardCount, Is.Zero); Assert.That(autoResolvedCount, Is.EqualTo(1)); Assert.That(autoResolvedToken, Is.EqualTo("empty:no-currency")); } finally { Object.DestroyImmediate(constants); } } [Test] public void FullyInvalidatedOpenSnapshotFallsBackToCurrency() { GameObject playerObject = new("Invalidated Reward Player"); ModifierDefinition playerModifier = CreatePlayerModifier("invalidated"); Defense25DRewardConstants constants = CreateConstants(includeEpic: true); constants.TowerModifierCatalog = System.Array.Empty(); constants.LevelUpRarityWeights = new Defense25DRewardRarityWeights { Common = 100, Rare = 0, Epic = 0, }; int grantedCurrency = 0; try { PlayerStats player = playerObject.AddComponent(); Defense25DRewardChoices choices = new( constants, new[] { playerModifier }, player, new Defense25DTowerModifiers(constants), _ => false, grantCurrency: amount => grantedCurrency += amount); choices.QueueReward(Defense25DRewardSource.LevelUp, "stale:all-invalid"); Assert.That(choices.CurrentChoices, Has.Count.EqualTo(1)); Assert.That(choices.MarkCurrentRewardOpened(), Is.True); for (int index = 0; index < playerModifier.MaximumStacks; index++) { Assert.That(playerModifier.TryApplyToModel(player, null), Is.True); } choices.RefreshChoices(); Assert.That(choices.CurrentRewardToken, Is.EqualTo("stale:all-invalid")); Assert.That(choices.CurrentRewardIsOpened, Is.True); Assert.That(choices.CurrentChoices, Has.Count.EqualTo(1)); Assert.That(choices.CurrentChoices[0].TargetKind, Is.EqualTo(Defense25DRewardTargetKind.Currency)); Assert.That(choices.TryChooseReward(0, "stale:all-invalid"), Is.True); Assert.That(grantedCurrency, Is.EqualTo(constants.EmptyEpicFallbackCurrency)); Assert.That(choices.HasPendingReward, Is.False); } finally { Object.DestroyImmediate(playerModifier); Object.DestroyImmediate(constants); Object.DestroyImmediate(playerObject); } } [Test] public void ChoosingPlayerModifier_ChangesOnlyPlayerModelAndConsumesRewardOnce() { GameObject playerObject = new("Apply Reward Player"); ModifierDefinition playerModifier = CreatePlayerModifier("collision-damage"); Defense25DRewardConstants constants = CreateConstants(includeEpic: true); try { PlayerStats player = playerObject.AddComponent(); Defense25DTowerModifiers towerModifiers = new(constants); Defense25DRewardChoices choices = new( constants, new[] { playerModifier }, player, towerModifiers, _ => true); choices.QueueReward( Defense25DRewardSource.LevelUp, "level:apply-once"); int playerChoiceIndex = choices.CurrentChoices.ToList() .FindIndex(choice => choice.TargetKind == Defense25DRewardTargetKind.PlayerModifier); Assert.That(playerChoiceIndex, Is.GreaterThanOrEqualTo(0)); Assert.That(choices.TryChooseReward(playerChoiceIndex), Is.True); Assert.That(player.Evaluate(CharacterStat.CollisionDamage, 10f), Is.GreaterThan(10f)); Assert.That(towerModifiers.AppliedChoiceCount, Is.Zero); Assert.That(choices.HasPendingReward, Is.False); Assert.That(choices.TryChooseReward(playerChoiceIndex), Is.False); } finally { Object.DestroyImmediate(playerModifier); Object.DestroyImmediate(constants); Object.DestroyImmediate(playerObject); } } [Test] public void PlayerRarityVariantsShareStacksAndHealOnlyAfterSuccessfulApply() { GameObject playerObject = new("Rarity Variant Player"); ModifierDefinition playerModifier = CreatePlayerModifier("move-speed"); playerModifier.Configure( "test.move-speed", "Move speed", CharacterStat.MoveSpeed, ModifierOperation.Increased, 0.2f, 2, 6f); Defense25DRewardConstants constants = CreateConstants(includeEpic: true); int healCalls = 0; float totalHeal = 0f; try { constants.LevelUpRarityWeights = new Defense25DRewardRarityWeights { Common = 0, Rare = 100, Epic = 0, }; PlayerStats player = playerObject.AddComponent(); Defense25DRewardChoices choices = new( constants, new[] { playerModifier }, player, new Defense25DTowerModifiers(constants), _ => false, amount => { healCalls++; totalHeal += amount; }); Assert.That(choices.QueueReward( Defense25DRewardSource.LevelUp, "level:rare"), Is.True); Assert.That(choices.CurrentChoices, Has.Count.EqualTo(1)); Assert.That(choices.CurrentChoices[0].Rarity, Is.EqualTo(Defense25DRewardRarity.Rare)); Assert.That(choices.TryChooseReward(0), Is.True); Assert.That(player.Evaluate(CharacterStat.MoveSpeed, 10f), Is.EqualTo(13f).Within(0.001f)); Assert.That(healCalls, Is.EqualTo(1)); Assert.That(totalHeal, Is.EqualTo(9f).Within(0.001f)); constants.LevelUpRarityWeights = new Defense25DRewardRarityWeights { Common = 0, Rare = 0, Epic = 100, }; Assert.That(playerModifier.CanApplyToModel(player), Is.True, "A rare stack leaves one shared stack available to an Epic variant."); Assert.That(choices.QueueReward( Defense25DRewardSource.LevelUp, "level:epic"), Is.True); Assert.That(choices.CurrentChoices[0].Rarity, Is.EqualTo(Defense25DRewardRarity.Epic)); Assert.That(choices.TryChooseReward(0), Is.True); Assert.That(player.Evaluate(CharacterStat.MoveSpeed, 10f), Is.EqualTo(17f).Within(0.001f)); Assert.That(healCalls, Is.EqualTo(2)); Assert.That(totalHeal, Is.EqualTo(21f).Within(0.001f)); Assert.That(playerModifier.TryApplyToModel( player, amount => { healCalls++; totalHeal += amount; }, 1.5f), Is.False, "The shared cap rejects a later rarity variant."); Assert.That(healCalls, Is.EqualTo(2)); Assert.That(totalHeal, Is.EqualTo(21f).Within(0.001f)); } finally { Object.DestroyImmediate(playerModifier); Object.DestroyImmediate(constants); Object.DestroyImmediate(playerObject); } } private static ModifierDefinition CreatePlayerModifier(string id) { ModifierDefinition definition = ScriptableObject.CreateInstance(); definition.Configure( $"test.{id}", id, id == "move-speed" ? CharacterStat.MoveSpeed : CharacterStat.CollisionDamage, ModifierOperation.Increased, 0.2f, 2); return definition; } private static Defense25DRewardChoices NewTowerOnlyChoices( Defense25DRewardConstants constants) { return new Defense25DRewardChoices( constants, System.Array.Empty(), null, new Defense25DTowerModifiers(constants), _ => true); } private static Defense25DRewardConstants CreateConstants(bool includeEpic) { Defense25DRewardConstants constants = ScriptableObject.CreateInstance(); constants.RunSeed = 137; constants.ChoiceCount = 3; constants.LevelUpRarityWeights = new Defense25DRewardRarityWeights { Common = 75, Rare = 20, Epic = 5, }; constants.GlobalEliteRarityWeights = new Defense25DRewardRarityWeights { Common = 60, Rare = 30, Epic = 10, }; constants.EmptyEpicFallbackCurrency = 10; constants.TowerModifierCatalog = new[] { TowerModifier("fire.attack.common", DefenseTowerKind.Direct, Defense25DTowerModifierEffect.AttackDamageIncreased, Defense25DRewardRarity.Common, 0.1f, 5), TowerModifier("fire.attack.rare", DefenseTowerKind.Direct, Defense25DTowerModifierEffect.AttackDamageIncreased, Defense25DRewardRarity.Rare, 0.2f, 5), TowerModifier("fire.attack.epic", DefenseTowerKind.Direct, Defense25DTowerModifierEffect.AttackDamageIncreased, Defense25DRewardRarity.Epic, 0.35f, 5), TowerModifier("lightning.chain.rare", DefenseTowerKind.Lightning, Defense25DTowerModifierEffect.ChainCount, Defense25DRewardRarity.Rare, 1f, 3), TowerModifier("cold.projectile.epic", DefenseTowerKind.Cold, Defense25DTowerModifierEffect.AdditionalProjectile, Defense25DRewardRarity.Epic, 1f, 2), }.Where(definition => includeEpic || definition.Rarity != Defense25DRewardRarity.Epic).ToArray(); return constants; } private static Defense25DTowerModifierDefinition TowerModifier( string id, DefenseTowerKind family, Defense25DTowerModifierEffect effect, Defense25DRewardRarity rarity, float value, int maximumStacks) { return new Defense25DTowerModifierDefinition { DefinitionId = id, DisplayName = id, Description = id, Family = family, Effect = effect, Rarity = rarity, Value = value, MaximumStacks = maximumStacks, }; } } }