From eafd07a54b0d85eccfc2d5c3c9775461c61cfaf2 Mon Sep 17 00:00:00 2001 From: Jonathan E Date: Wed, 17 Sep 2025 21:20:22 +0200 Subject: [PATCH 1/3] Refactor combat resolver attack pipeline --- Roguelike.Core/Game/Combats/CombatResolver.cs | 258 +++++++++++------- 1 file changed, 157 insertions(+), 101 deletions(-) diff --git a/Roguelike.Core/Game/Combats/CombatResolver.cs b/Roguelike.Core/Game/Combats/CombatResolver.cs index 34555fa..71aabb8 100644 --- a/Roguelike.Core/Game/Combats/CombatResolver.cs +++ b/Roguelike.Core/Game/Combats/CombatResolver.cs @@ -12,138 +12,194 @@ public sealed class CombatResolver public AttackOutcome ExecuteAttack(Character attacker, Character defender, int round) { - // TrollMushroom item logic var trollMushroom = attacker.Inventory.FirstOrDefault(i => i.Id == ItemId.TrollMushroom); - if (trollMushroom != null && round % 2 == 1) + if (IsUnderTrollMushroomEffect(trollMushroom, round)) { - // Cannot attack on odd rounds return AttackOutcome.UnderTrollMushroomEffect(); } - // 1) Compute defender dodge chance (with boots if any) - if (TryDodge(attacker, defender, out double dodgeChance) - && _random.NextDouble() < dodgeChance) + if (TryResolveDodge(attacker, defender, out var dodgeOutcome)) { - // FeathersOfHope logic - int restoredLife = 0; - var feathersOfHope = defender.Inventory.FirstOrDefault(i => i.Id == ItemId.FeathersOfHope); - if (feathersOfHope != null) - { - var initialLifePoint = defender.LifePoint; - defender.LifePoint = Math.Min(defender.MaxLifePoint, defender.LifePoint + feathersOfHope.Value); - restoredLife = defender.LifePoint - initialLifePoint; - } - return AttackOutcome.HasDodged(restoredLife); + return dodgeOutcome; } - // 2) Compute base damage (after armor). Min damage is 1. + int damage = ComputeBaseDamage(attacker, defender, trollMushroom); + + var critResult = ApplyCriticalEffects(attacker, defender, damage); + damage = critResult.Damage; + bool isCrit = critResult.IsCritical; + int armorShred = critResult.ArmorShred; + int lifeStolen = critResult.LifeStolen; + + armorShred += ApplyOldGiantWoodenClub(attacker, defender); + + bool savedByTalisman = ApplyDamageAndTalisman(defender, damage); + + lifeStolen += ApplyLifeSteal(attacker, damage); + + int thornsDamage = ApplyThorns(attacker, defender, damage); + + return new AttackOutcome( + Dodged: false, + Damage: damage, + Crit: isCrit, + ArmorShredded: armorShred, + LifeStolen: lifeStolen, + ThornsReflected: thornsDamage, + DefenderSavedByTalisman: savedByTalisman + ); + } + + private bool IsUnderTrollMushroomEffect(Item trollMushroom, int round) + { + if (trollMushroom == null) + { + return false; + } + + // Cannot attack on odd rounds + return round % 2 == 1; + } + + private bool TryResolveDodge(Character attacker, Character defender, out AttackOutcome outcome) + { + outcome = default; + + if (!TryDodge(attacker, defender, out double dodgeChance) + || _random.NextDouble() >= dodgeChance) + { + return false; + } + + int restoredLife = 0; + var feathersOfHope = defender.Inventory.FirstOrDefault(i => i.Id == ItemId.FeathersOfHope); + if (feathersOfHope != null) + { + var initialLifePoint = defender.LifePoint; + defender.LifePoint = Math.Min(defender.MaxLifePoint, defender.LifePoint + feathersOfHope.Value); + restoredLife = defender.LifePoint - initialLifePoint; + } + + outcome = AttackOutcome.HasDodged(restoredLife); + return true; + } + + private static int ComputeBaseDamage(Character attacker, Character defender, Item trollMushroom) + { int baseDamage = Math.Max(0, attacker.Strength - defender.Armor); - int minDamage = 1; - decimal multiplier = 1m; - if (trollMushroom != null) + + if (trollMushroom == null) + { + return Math.Max(1, baseDamage); + } + + decimal multiplier = trollMushroom.Value / 100m; + int scaledDamage = (int)Math.Ceiling(baseDamage * multiplier); + return Math.Max(2, scaledDamage); + } + + private (int Damage, bool IsCritical, int ArmorShred, int LifeStolen) ApplyCriticalEffects( + Character attacker, + Character defender, + int damage) + { + if (attacker.Strength <= defender.Strength) { - multiplier *= trollMushroom.Value / 100m; - minDamage = 2; + return (damage, false, 0, 0); } - int damage = Math.Max(minDamage, (int)Math.Ceiling(baseDamage * multiplier)); + var royalGantelet = attacker.Inventory.FirstOrDefault(i => i.Id == ItemId.RoyalGuardGauntlet); + var royalShield = defender.Inventory.FirstOrDefault(i => i.Id == ItemId.RoyalGuardShield); + decimal criticalChanceBonus = royalGantelet?.Value / 100m ?? 0; + criticalChanceBonus -= royalShield?.Value / 100m ?? 0; + + double critChance = 0.15 + (double)criticalChanceBonus; // 15% crit chance by default + if (_random.NextDouble() > critChance) + { + return (damage, false, 0, 0); + } + + var berserkerNecklace = attacker.Inventory.FirstOrDefault(i => i.Id == ItemId.BerserkerNecklace); + var paladinNecklace = defender.Inventory.FirstOrDefault(i => i.Id == ItemId.PaladinNecklace); + decimal criticalDamageBonus = 1.5m; // +50% damage by default + criticalDamageBonus += berserkerNecklace?.Value / 100m ?? 0; + criticalDamageBonus -= paladinNecklace?.Value / 100m ?? 0; + + int criticalDamage = (int)Math.Ceiling(damage * criticalDamageBonus); + int armorShred = Math.Max(1, (int)Math.Round(defender.Armor * 0.05, MidpointRounding.AwayFromZero)); // -5% armor + defender.Armor = Math.Max(0, defender.Armor - armorShred); - // 3) Roll crit + armor break (only if attacker’s Strength > defender’s Strength) - bool isCrit = false; - int armorShred = 0; int lifeStolen = 0; - if (attacker.Strength > defender.Strength) - { - // RoyalGuardGauntlet and RoyalGuardShield logic - var royalGantelet = attacker.Inventory.FirstOrDefault(i => i.Id == ItemId.RoyalGuardGauntlet); - var royalShield = defender.Inventory.FirstOrDefault(i => i.Id == ItemId.RoyalGuardShield); - decimal criticalChanceBonus = royalGantelet?.Value / 100m ?? 0; - criticalChanceBonus -= royalShield?.Value / 100m ?? 0; - - if (_random.NextDouble() <= 0.15 + (double)criticalChanceBonus) // 15% crit chance by default - { - // BerserkerNecklace and PaladinNecklace logic - var berserkerNecklace = attacker.Inventory.FirstOrDefault(i => i.Id == ItemId.BerserkerNecklace); - var paladinNecklace = defender.Inventory.FirstOrDefault(i => i.Id == ItemId.PaladinNecklace); - decimal criticalDamageBonus = 1.5m; // +50% damage by default - criticalDamageBonus += berserkerNecklace?.Value / 100m ?? 0; - criticalDamageBonus -= paladinNecklace?.Value / 100m ?? 0; - - isCrit = true; - damage = (int)Math.Ceiling(damage * criticalDamageBonus); - armorShred = Math.Max(1, (int)Math.Round(defender.Armor * 0.05, MidpointRounding.AwayFromZero)); // -5% armor - defender.Armor = Math.Max(0, defender.Armor - armorShred); - - // SealOfLivingFlesh item logic - var sealOfLivingFlesh = attacker.Inventory.FirstOrDefault(i => i.Id == ItemId.SealOfLivingFlesh); - if (sealOfLivingFlesh != null) - { - var initialLifePoint = attacker.LifePoint; - attacker.LifePoint = Math.Min(attacker.MaxLifePoint, attacker.LifePoint + sealOfLivingFlesh.Value); - lifeStolen += attacker.LifePoint - initialLifePoint; - } - } - } - - // OldGiantWoodenClub item logic + var sealOfLivingFlesh = attacker.Inventory.FirstOrDefault(i => i.Id == ItemId.SealOfLivingFlesh); + if (sealOfLivingFlesh != null) + { + var initialLifePoint = attacker.LifePoint; + attacker.LifePoint = Math.Min(attacker.MaxLifePoint, attacker.LifePoint + sealOfLivingFlesh.Value); + lifeStolen += attacker.LifePoint - initialLifePoint; + } + + return (criticalDamage, true, armorShred, lifeStolen); + } + + private int ApplyOldGiantWoodenClub(Character attacker, Character defender) + { var oldGiantWoodenClub = attacker.Inventory.FirstOrDefault(i => i.Id == ItemId.OldGiantWoodenClub); - if (oldGiantWoodenClub != null) + if (oldGiantWoodenClub == null || attacker.Strength >= defender.Armor) { - // Breaks armor when less strength than opponent's armor - if (attacker.Strength < defender.Armor) - { - var defenderArmorBeforeBreak = defender.Armor; - defender.Armor = Math.Max(attacker.Strength, defender.Armor - oldGiantWoodenClub.Value); - armorShred += defenderArmorBeforeBreak - defender.Armor; - } + return 0; } - // 4) Apply damage to defender (with talisman safety if equiped) + var defenderArmorBeforeBreak = defender.Armor; + defender.Armor = Math.Max(attacker.Strength, defender.Armor - oldGiantWoodenClub.Value); + return defenderArmorBeforeBreak - defender.Armor; + } + + private bool ApplyDamageAndTalisman(Character defender, int damage) + { defender.LifePoint = Math.Max(0, defender.LifePoint - damage); - // Talisman logic - bool savedByTalisman = false; - if (defender.LifePoint <= 0 - && defender.Inventory.Any(i => i.Id == ItemId.TalismanOfTheLastBreath) - && !_talismanUsed.ContainsKey(defender.Name)) + if (defender.LifePoint > 0 + || !defender.Inventory.Any(i => i.Id == ItemId.TalismanOfTheLastBreath) + || _talismanUsed.ContainsKey(defender.Name)) { - var talisman = defender.Inventory.First(i => i.Id == ItemId.TalismanOfTheLastBreath); - defender.LifePoint = Math.Min(defender.MaxLifePoint, talisman.Value); - savedByTalisman = true; - _talismanUsed.Add(defender.Name, savedByTalisman); + return false; } - // 5) Apply on-hit lifesteal for attacker (dagger) + var talisman = defender.Inventory.First(i => i.Id == ItemId.TalismanOfTheLastBreath); + defender.LifePoint = Math.Min(defender.MaxLifePoint, talisman.Value); + _talismanUsed.Add(defender.Name, true); + return true; + } + + private int ApplyLifeSteal(Character attacker, int damage) + { var dagger = attacker.Inventory.FirstOrDefault(i => i.Id == ItemId.DaggerLifeSteal); - if (dagger != null && damage > 0 && _random.NextDouble() <= 0.6 ) + if (dagger == null || damage <= 0 || _random.NextDouble() > 0.6) { - int steal = Math.Min(damage, dagger.Value); - int before = attacker.LifePoint; - attacker.LifePoint = Math.Min(attacker.MaxLifePoint, attacker.LifePoint + steal); - lifeStolen += attacker.LifePoint - before; + return 0; } - // 6) Apply thorns on attacker if defender has breastplate and got hit - int thornsDamage = 0; + int steal = Math.Min(damage, dagger.Value); + int before = attacker.LifePoint; + attacker.LifePoint = Math.Min(attacker.MaxLifePoint, attacker.LifePoint + steal); + return attacker.LifePoint - before; + } + + private int ApplyThorns(Character attacker, Character defender, int damage) + { var breastplate = defender.Inventory.FirstOrDefault(i => i.Id == ItemId.ThornBreastplate); - if (breastplate != null && damage > 0 && _random.NextDouble() <= 0.6) + if (breastplate == null || damage <= 0 || _random.NextDouble() > 0.6) { - thornsDamage = Math.Max(0, breastplate.Value); - if (thornsDamage > 0) - { - attacker.LifePoint = Math.Max(0, attacker.LifePoint - thornsDamage); - } + return 0; } - return new AttackOutcome( - Dodged: false, - Damage: damage, - Crit: isCrit, - ArmorShredded: armorShred, - LifeStolen: lifeStolen, - ThornsReflected: thornsDamage, - DefenderSavedByTalisman: savedByTalisman - ); + int thornsDamage = Math.Max(0, breastplate.Value); + if (thornsDamage > 0) + { + attacker.LifePoint = Math.Max(0, attacker.LifePoint - thornsDamage); + } + + return thornsDamage; } /// From ece902a6966d8c514baae4c369de2775ea5cc227 Mon Sep 17 00:00:00 2001 From: Jonathan E Date: Mon, 22 Sep 2025 20:57:02 +0200 Subject: [PATCH 2/3] Add CombatResolver unit tests --- .../Game/Combats/CombatResolverTests.cs | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 Roguelike.Core.Tests/Game/Combats/CombatResolverTests.cs diff --git a/Roguelike.Core.Tests/Game/Combats/CombatResolverTests.cs b/Roguelike.Core.Tests/Game/Combats/CombatResolverTests.cs new file mode 100644 index 0000000..bc0d7eb --- /dev/null +++ b/Roguelike.Core.Tests/Game/Combats/CombatResolverTests.cs @@ -0,0 +1,171 @@ +using System.Collections.Generic; +using System.Reflection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Roguelike.Core.Game.Characters; +using Roguelike.Core.Game.Collectables.Items; +using Roguelike.Core.Game.Combat; + +namespace Roguelike.Core.Tests.Game.Combats; + +[TestClass] +public class CombatResolverTests +{ + [TestMethod] + public void ExecuteAttack_ReturnsTrollMushroomEffect_OnOddRound() + { + var resolver = CreateResolver(1.0); + var attacker = CreateCharacter("Attacker", life: 20, maxLife: 20, strength: 12, armor: 2, speed: 6); + attacker.Inventory.Add(new Item { Id = ItemId.TrollMushroom, Value = 150 }); + var defender = CreateCharacter("Defender", life: 18, maxLife: 18, strength: 8, armor: 4, speed: 4); + + var outcome = resolver.ExecuteAttack(attacker, defender, round: 1); + + Assert.IsTrue(outcome.TrollMushroomEffect); + Assert.AreEqual(0, outcome.Damage); + Assert.AreEqual(18, defender.LifePoint); + } + + [TestMethod] + public void ExecuteAttack_DealsBaseDamageWithoutCritOrSpecials() + { + var resolver = CreateResolver(1.0); + var attacker = CreateCharacter("Attacker", life: 22, maxLife: 22, strength: 10, armor: 2, speed: 5); + var defender = CreateCharacter("Defender", life: 30, maxLife: 30, strength: 12, armor: 3, speed: 4); + + var outcome = resolver.ExecuteAttack(attacker, defender, round: 2); + + Assert.IsFalse(outcome.Crit); + Assert.AreEqual(7, outcome.Damage); + Assert.AreEqual(23, defender.LifePoint); + Assert.AreEqual(0, outcome.ArmorShredded); + Assert.AreEqual(0, outcome.LifeStolen); + Assert.AreEqual(0, outcome.ThornsReflected); + Assert.IsFalse(outcome.DefenderSavedByTalisman); + } + + [TestMethod] + public void ExecuteAttack_TalismanTriggersOnlyOnceWhenDefenderWouldDie() + { + var resolver = CreateResolver(1.0, 1.0); + var attacker = CreateCharacter("Attacker", life: 25, maxLife: 25, strength: 40, armor: 2, speed: 5); + var defender = CreateCharacter("Defender", life: 20, maxLife: 40, strength: 12, armor: 5, speed: 4); + defender.Inventory.Add(new Item { Id = ItemId.TalismanOfTheLastBreath, Value = 15 }); + + var firstOutcome = resolver.ExecuteAttack(attacker, defender, round: 2); + var secondOutcome = resolver.ExecuteAttack(attacker, defender, round: 2); + + Assert.IsTrue(firstOutcome.DefenderSavedByTalisman); + Assert.AreEqual(15, defender.LifePoint); + Assert.IsFalse(secondOutcome.DefenderSavedByTalisman); + Assert.AreEqual(0, defender.LifePoint); + } + + [TestMethod] + public void ExecuteAttack_AppliesOldGiantWoodenClubArmorShred() + { + var resolver = CreateResolver(1.0); + var attacker = CreateCharacter("Attacker", life: 18, maxLife: 18, strength: 5, armor: 2, speed: 5); + attacker.Inventory.Add(new Item { Id = ItemId.OldGiantWoodenClub, Value = 4 }); + var defender = CreateCharacter("Defender", life: 25, maxLife: 25, strength: 10, armor: 10, speed: 4); + + var outcome = resolver.ExecuteAttack(attacker, defender, round: 2); + + Assert.AreEqual(1, outcome.Damage); + Assert.AreEqual(4, outcome.ArmorShredded); + Assert.AreEqual(6, defender.Armor); + } + + [TestMethod] + public void ExecuteAttack_LifeStealRestoresHealthWhenChanceSucceeds() + { + var resolver = CreateResolver(0.0); + var attacker = CreateCharacter("Attacker", life: 5, maxLife: 20, strength: 12, armor: 2, speed: 5); + attacker.Inventory.Add(new Item { Id = ItemId.DaggerLifeSteal, Value = 3 }); + var defender = CreateCharacter("Defender", life: 18, maxLife: 18, strength: 15, armor: 4, speed: 4); + + var outcome = resolver.ExecuteAttack(attacker, defender, round: 2); + + Assert.AreEqual(8, outcome.Damage); + Assert.AreEqual(3, outcome.LifeStolen); + Assert.AreEqual(8, attacker.LifePoint); + } + + [TestMethod] + public void ExecuteAttack_ThornsReflectDamageWhenChanceSucceeds() + { + var resolver = CreateResolver(0.0); + var attacker = CreateCharacter("Attacker", life: 20, maxLife: 20, strength: 10, armor: 2, speed: 5); + var defender = CreateCharacter("Defender", life: 18, maxLife: 18, strength: 15, armor: 4, speed: 4); + defender.Inventory.Add(new Item { Id = ItemId.ThornBreastplate, Value = 4 }); + + var outcome = resolver.ExecuteAttack(attacker, defender, round: 2); + + Assert.AreEqual(6, outcome.Damage); + Assert.AreEqual(4, outcome.ThornsReflected); + Assert.AreEqual(16, attacker.LifePoint); + } + + [TestMethod] + public void ExecuteAttack_DodgeRestoresHealthWithFeathersOfHope() + { + var resolver = CreateResolver(0.0); + var attacker = CreateCharacter("Attacker", life: 20, maxLife: 20, strength: 8, armor: 2, speed: 5); + var defender = CreateCharacter("Defender", life: 10, maxLife: 15, strength: 8, armor: 4, speed: 12); + defender.Inventory.Add(new Item { Id = ItemId.FeathersOfHope, Value = 3 }); + + var outcome = resolver.ExecuteAttack(attacker, defender, round: 2); + + Assert.IsTrue(outcome.Dodged); + Assert.AreEqual(0, outcome.Damage); + Assert.AreEqual(13, defender.LifePoint); + Assert.AreEqual(3, outcome.LifeStolen); + } + + private static CombatResolver CreateResolver(params double[] randomSequence) + { + var resolver = new CombatResolver(); + var sequence = new SequenceRandom(randomSequence); + var randomField = typeof(CombatResolver).GetField("_random", BindingFlags.Instance | BindingFlags.NonPublic); + randomField!.SetValue(resolver, sequence); + return resolver; + } + + private static TestCharacter CreateCharacter(string name, int life, int maxLife, int strength, int armor, int speed) + { + return new TestCharacter + { + Name = name, + LifePoint = life, + MaxLifePoint = maxLife, + Strength = strength, + Armor = armor, + Speed = speed, + }; + } + + private sealed class TestCharacter : Character + { + private string _name = string.Empty; + + public override string Name + { + get => _name; + set => _name = value; + } + } + + private sealed class SequenceRandom : Random + { + private readonly Queue _values; + + public SequenceRandom(params double[] values) + { + _values = new Queue(values.Length == 0 ? new[] { 1.0 } : values); + } + + protected override double Sample() + { + return _values.Count > 0 ? _values.Dequeue() : 1.0; + } + } +} From 96b55e7acbc8b9fa7c0f1dca0c0d3b1534b2f489 Mon Sep 17 00:00:00 2001 From: Jonathan E Date: Mon, 22 Sep 2025 21:08:51 +0200 Subject: [PATCH 3/3] Fix talisman unit test assertions order --- Roguelike.Core.Tests/Game/Combats/CombatResolverTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Roguelike.Core.Tests/Game/Combats/CombatResolverTests.cs b/Roguelike.Core.Tests/Game/Combats/CombatResolverTests.cs index bc0d7eb..5983394 100644 --- a/Roguelike.Core.Tests/Game/Combats/CombatResolverTests.cs +++ b/Roguelike.Core.Tests/Game/Combats/CombatResolverTests.cs @@ -52,10 +52,12 @@ public void ExecuteAttack_TalismanTriggersOnlyOnceWhenDefenderWouldDie() defender.Inventory.Add(new Item { Id = ItemId.TalismanOfTheLastBreath, Value = 15 }); var firstOutcome = resolver.ExecuteAttack(attacker, defender, round: 2); - var secondOutcome = resolver.ExecuteAttack(attacker, defender, round: 2); Assert.IsTrue(firstOutcome.DefenderSavedByTalisman); Assert.AreEqual(15, defender.LifePoint); + + var secondOutcome = resolver.ExecuteAttack(attacker, defender, round: 2); + Assert.IsFalse(secondOutcome.DefenderSavedByTalisman); Assert.AreEqual(0, defender.LifePoint); }