Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion src/NosCore.GameObject/Services/BattleService/HitQueue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -191,6 +192,39 @@ await ApplySpecialActionsAsync(request.Skill.BCards, request.Origin, target)
}
}

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private static int PercentageHpLoss(IAliveEntity target, IReadOnlyList<BCardDto> 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)
Expand Down
110 changes: 110 additions & 0 deletions test/NosCore.GameObject.Tests/Services/BattleService/HitQueueTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// |_|\__|\__/ |___/ \__/\__/|_|_\___|
//

using NosCore.Data.Enumerations.Buff;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
Expand Down Expand Up @@ -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<IDamageCalculator>();
calc.Setup(c => c.Calculate(It.IsAny<CombatStats>(), It.IsAny<CombatStats>(), It.IsAny<SkillInfo>()))
.Returns(new DamageResult(ordinaryDamage, SuPacketHitMode.SuccessAttack));
var stats = new Mock<IBattleStatsProvider>();
stats.Setup(s => s.GetStats(It.IsAny<IAliveEntity>())).Returns(new CombatStats());
return new HitQueue(calc.Object, stats.Object, new Mock<IBuffService>().Object,
new Mock<IRegenerationService>().Object, new Mock<ILogger<HitQueue>>().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,
Expand Down
Loading