diff --git a/src/NosCore.GameObject/Services/BattleService/HitQueue.cs b/src/NosCore.GameObject/Services/BattleService/HitQueue.cs index 103f0f65a..91f4546bd 100644 --- a/src/NosCore.GameObject/Services/BattleService/HitQueue.cs +++ b/src/NosCore.GameObject/Services/BattleService/HitQueue.cs @@ -132,7 +132,8 @@ private async Task TryApplyHit(HitRequest request) return; } - var newHp = target.Hp - damage.Damage; + // Damage proportional to HP rather than to the stats, on top of the blow. + var newHp = target.Hp - damage.Damage - PercentageHpLoss(target, request.Skill.BCards); var overkill = 0; var killed = false; if (newHp <= 0) @@ -191,6 +192,39 @@ await ApplySpecialActionsAsync(request.Skill.BCards, request.Origin, target) } } + /// + /// Type 37 subtype 31: "Decreases the opponent's HP by %s%%." 112 of the 122 declarations on + /// skills are this subtype, and the case was not read at all. + /// + /// + /// The percentage is taken from maximum HP. The file says only "HP by %s%%"; off current HP + /// the loss would halve and halve again without ever finishing anything, which is not what a + /// skill declaring 90% is for. + /// + /// Subtype 32 hits the caster instead and no skill declares it, so it is left out. + /// + /// The loss can kill: it is part of the same subtraction as the blow, so the ordinary death + /// path handles it and nothing says the effect must leave its victim standing. + /// + private static int PercentageHpLoss(IAliveEntity target, IReadOnlyList bCards) + { + var loss = 0; + for (var i = 0; i < bCards.Count; i++) + { + var bCard = bCards[i]; + if ((BCardType.CardType)bCard.Type != BCardType.CardType.RecoveryAndDamagePercent + || bCard.SubType != (byte)AdditionalTypes.RecoveryAndDamagePercent.DecreaseEnemyHp + || bCard.FirstData <= 0) + { + continue; + } + + loss += target.MaxHp * bCard.FirstData / 100; + } + + return loss; + } + private static void FlipIsAlive(IAliveEntity entity, bool alive) { switch (entity) diff --git a/test/NosCore.GameObject.Tests/Services/BattleService/HitQueueTests.cs b/test/NosCore.GameObject.Tests/Services/BattleService/HitQueueTests.cs index de4918f88..8398ae422 100644 --- a/test/NosCore.GameObject.Tests/Services/BattleService/HitQueueTests.cs +++ b/test/NosCore.GameObject.Tests/Services/BattleService/HitQueueTests.cs @@ -4,6 +4,7 @@ // |_|\__|\__/ |___/ \__/\__/|_|_\___| // +using NosCore.Data.Enumerations.Buff; using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -144,6 +145,115 @@ public async Task MissHitsDoNotAffectHpOrHitList() Assert.AreEqual(0, target.HitList.Count); } + // --- Type 37 subtype 31, "Decreases the opponent's HP by %s%%" ---------------------- + // + // 112 of the 122 declarations on skills are this subtype, and the case was not read at + // all. The percentage comes off MAXIMUM HP - our assumption, stated in the code, because + // the file says only "HP by %s%%". These pin it: read off current HP every number below + // changes, and nothing would raise. + + private static BCardDto PercentOfHp(short percent) => new() + { + Type = (byte)BCardType.CardType.RecoveryAndDamagePercent, + SubType = (byte)AdditionalTypes.RecoveryAndDamagePercent.DecreaseEnemyHp, + FirstData = percent + }; + + private static HitQueue QueueDealing(int ordinaryDamage) + { + var calc = new Mock(); + calc.Setup(c => c.Calculate(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new DamageResult(ordinaryDamage, SuPacketHitMode.SuccessAttack)); + var stats = new Mock(); + stats.Setup(s => s.GetStats(It.IsAny())).Returns(new CombatStats()); + return new HitQueue(calc.Object, stats.Object, new Mock().Object, + new Mock().Object, new Mock>().Object); + } + + [TestMethod] + public async Task ThePercentageComesOffTheMaximumAndNotTheCurrentHp() + { + // Half of the maximum is 500. Off the current 600 it would be 300, and the target + // would end at 290 instead of 90. + var target = new FakeBattleEntity { Hp = 600, MaxHp = 1000 }; + var attacker = new FakeBattleEntity(); + + await QueueDealing(10).EnqueueAsync(Request(attacker, target) with + { + Skill = MakeSkill() with { BCards = new[] { PercentOfHp(50) } } + }); + + Assert.AreEqual(90, target.Hp); + } + + [TestMethod] + public async Task WithoutTheEffectOnlyTheOrdinaryDamageLands() + { + var target = new FakeBattleEntity { Hp = 600, MaxHp = 1000 }; + var attacker = new FakeBattleEntity(); + + await QueueDealing(10).EnqueueAsync(Request(attacker, target) with { Skill = MakeSkill() }); + + Assert.AreEqual(590, target.Hp); + } + + // Two slots on one skill add up: the loop must not stop at the first. + [TestMethod] + public async Task TwoDeclarationsOnOneSkillBothCount() + { + var target = new FakeBattleEntity { Hp = 1000, MaxHp = 1000 }; + var attacker = new FakeBattleEntity(); + + // One point of ordinary damage, not zero: a blow that rolls zero is already a miss + // by the guard above, and the percentage rides along with a blow that landed. + await QueueDealing(1).EnqueueAsync(Request(attacker, target) with + { + Skill = MakeSkill() with { BCards = new[] { PercentOfHp(20), PercentOfHp(30) } } + }); + + Assert.AreEqual(499, target.Hp); + } + + // It goes through the ordinary death rather than being floored short of it. + [TestMethod] + public async Task ThePercentageDamageCanKillAndTheDeathIsTheOrdinaryOne() + { + var target = new FakeBattleEntity { Hp = 200, MaxHp = 1000 }; + var attacker = new FakeBattleEntity(); + + var outcome = await QueueDealing(1).EnqueueAsync(Request(attacker, target) with + { + Skill = MakeSkill() with { BCards = new[] { PercentOfHp(90) } } + }); + + Assert.AreEqual(0, target.Hp); + Assert.IsTrue(outcome.Killed); + Assert.IsFalse(target.IsAlive); + } + + // Subtype 32 hits the caster, and no skill in the file declares it. Reading it here would + // take the HP off the wrong entity. + [TestMethod] + public async Task TheSelfInflictedSubtypeIsNotReadAsIfItHitTheTarget() + { + var target = new FakeBattleEntity { Hp = 1000, MaxHp = 1000 }; + var attacker = new FakeBattleEntity(); + var selfInflicted = new BCardDto + { + Type = (byte)BCardType.CardType.RecoveryAndDamagePercent, + SubType = (byte)AdditionalTypes.RecoveryAndDamagePercent.DecreaseSelfHp, + FirstData = 50 + }; + + await QueueDealing(1).EnqueueAsync(Request(attacker, target) with + { + Skill = MakeSkill() with { BCards = new[] { selfInflicted } } + }); + + // Only the ordinary point of damage: the self-inflicted subtype took nothing here. + Assert.AreEqual(999, target.Hp); + } + private static HitRequest Request(IAliveEntity attacker, IAliveEntity target) => new( Origin: attacker, Target: target,