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,