diff --git a/src/NosCore.GameObject/Services/BattleService/BuffService.cs b/src/NosCore.GameObject/Services/BattleService/BuffService.cs index d3321a103..8a425eea0 100644 --- a/src/NosCore.GameObject/Services/BattleService/BuffService.cs +++ b/src/NosCore.GameObject/Services/BattleService/BuffService.cs @@ -1,4 +1,4 @@ -// __ _ __ __ ___ __ ___ ___ +// __ _ __ __ ___ __ ___ ___ // | \| |/__\ /' _/ / _//__\| _ \ __| // | | ' | \/ |`._`.| \_| \/ | v / _| // |_|\__|\__/ |___/ \__/\__/|_|_\___| @@ -15,6 +15,7 @@ using NosCore.GameObject.Ecs; using NosCore.GameObject.Ecs.Components; using NosCore.GameObject.Ecs.Interfaces; +using NosCore.GameObject.Infastructure; using NosCore.GameObject.Services.BattleService.Model; using NosCore.Networking; using NosCore.Packets.ServerPackets.Battle; @@ -25,7 +26,8 @@ namespace NosCore.GameObject.Services.BattleService; // ExpiresAt rather than stacking — matches the one-icon-per-card client convention. // Expiration is pull-based: TickAsync is called from the world's life loop, which keeps // this service dependency-free (no timers, no background tasks) and trivial to test. -public sealed class BuffService(IClock clock) : IBuffService +public sealed class BuffService(IClock clock, ICardCatalog cardCatalog, + IRandomProvider randomProvider) : IBuffService { private static readonly IReadOnlyCollection EmptyBuffs = Array.Empty(); private static readonly IReadOnlyList EmptyExpired = Array.Empty(); @@ -164,6 +166,42 @@ await target.MapInstance.SendPacketAsync(new BfePacket // BuffStateComponent lives on the target's ECS world; walking through the bundle // gets us its dictionary handle without copying. Returning null means the entity // has no buff state (e.g. map items) — caller treats as "no buffs". + public async Task InflictCardsAsync(IAliveEntity target, IAliveEntity? caster, + IReadOnlyList skillBCards) + { + for (var i = 0; i < skillBCards.Count; i++) + { + var bCard = skillBCards[i]; + var effect = bCard.Effect(); + if (effect is not (BCardEffect.BuffChanceCausing or BCardEffect.BuffChanceRemoving)) + { + continue; + } + + if (!Rolls(bCard.FirstData)) + { + continue; + } + + var cardId = (short)bCard.SecondData; + if (effect == BCardEffect.BuffChanceRemoving) + { + await RemoveAsync(target, cardId); + continue; + } + + var card = cardCatalog.GetCard(cardId); + if (card == null) + { + continue; + } + + await ApplyAsync(target, card, cardCatalog.GetCardBCards(cardId), caster); + } + } + + private bool Rolls(int percent) => percent > 0 && randomProvider.Next(0, 100) < percent; + private static ConcurrentDictionary? ResolveState(IAliveEntity target) { return target switch diff --git a/src/NosCore.GameObject/Services/BattleService/HitQueue.cs b/src/NosCore.GameObject/Services/BattleService/HitQueue.cs index b4d6eb92e..2a3eafcb0 100644 --- a/src/NosCore.GameObject/Services/BattleService/HitQueue.cs +++ b/src/NosCore.GameObject/Services/BattleService/HitQueue.cs @@ -1,4 +1,4 @@ -// __ _ __ __ ___ __ ___ ___ +// __ _ __ __ ___ __ ___ ___ // | \| |/__\ /' _/ / _//__\| _ \ __| // | | ' | \/ |`._`.| \_| \/ | v / _| // |_|\__|\__/ |___/ \__/\__/|_|_\___| @@ -198,6 +198,11 @@ await buffService } } + if (!killed && request.Skill.BCards.Count > 0) + { + await buffService.InflictCardsAsync(target, request.Origin, request.Skill.BCards); + } + request.Completion.TrySetResult(new HitOutcome(HitStatus.Landed, damage.Damage, damage.HitMode, killed)); } catch (Exception ex) diff --git a/src/NosCore.GameObject/Services/BattleService/IBuffService.cs b/src/NosCore.GameObject/Services/BattleService/IBuffService.cs index b0d291129..4fae18d2c 100644 --- a/src/NosCore.GameObject/Services/BattleService/IBuffService.cs +++ b/src/NosCore.GameObject/Services/BattleService/IBuffService.cs @@ -25,6 +25,9 @@ public interface IBuffService // filtered out because damage is applied immediately, not as a lasting buff. Task ApplySkillBuffAsync(IAliveEntity target, short skillVnum, short skillDuration, IReadOnlyList bCards, IAliveEntity? caster); + Task InflictCardsAsync(IAliveEntity target, IAliveEntity? caster, + IReadOnlyList skillBCards); + Task RemoveAsync(IAliveEntity target, short cardId); IReadOnlyCollection GetActiveBuffs(IAliveEntity target); diff --git a/test/NosCore.GameObject.Tests/Services/BattleService/BuffServiceTests.cs b/test/NosCore.GameObject.Tests/Services/BattleService/BuffServiceTests.cs index ed5be477f..70676b98c 100644 --- a/test/NosCore.GameObject.Tests/Services/BattleService/BuffServiceTests.cs +++ b/test/NosCore.GameObject.Tests/Services/BattleService/BuffServiceTests.cs @@ -18,6 +18,8 @@ using NosCore.GameObject.Ecs; using NosCore.GameObject.Ecs.Components; using NosCore.GameObject.Ecs.Interfaces; +using Moq; +using NosCore.GameObject.Infastructure; using NosCore.GameObject.Services.BattleService; using NosCore.GameObject.Services.BattleService.Model; using NosCore.GameObject.Services.ShopService; @@ -40,7 +42,7 @@ public class BuffServiceTests public void Setup() { _clock = new FakeClock(Instant.FromUtc(2026, 1, 1, 0, 0)); - _service = new BuffService(_clock); + _service = new BuffService(_clock, new Mock().Object, new Mock().Object); } [TestMethod] diff --git a/test/NosCore.GameObject.Tests/Services/BattleService/HitQueueTests.cs b/test/NosCore.GameObject.Tests/Services/BattleService/HitQueueTests.cs index d9e0e62b8..a780f4942 100644 --- a/test/NosCore.GameObject.Tests/Services/BattleService/HitQueueTests.cs +++ b/test/NosCore.GameObject.Tests/Services/BattleService/HitQueueTests.cs @@ -1,4 +1,4 @@ -// __ _ __ __ ___ __ ___ ___ +// __ _ __ __ ___ __ ___ ___ // | \| |/__\ /' _/ / _//__\| _ \ __| // | | ' | \/ |`._`.| \_| \/ | v / _| // |_|\__|\__/ |___/ \__/\__/|_|_\___| @@ -109,6 +109,58 @@ public async Task LandedHitAppliesSkillBuffsWhenSkillHasDuration() await queue.EnqueueAsync(request); buffs.Verify(b => b.ApplySkillBuffAsync(target, (short)7, (short)100, skill.BCards, attacker), Times.Once); + + buffs.Verify(b => b.InflictCardsAsync(target, attacker, skill.BCards), Times.Once); + } + + [TestMethod] + public async Task ALandedHitDoesNotFinishBeforeTheCardIsApplied() + { + var target = new FakeBattleEntity { Hp = 100, MaxHp = 100 }; + var attacker = new FakeBattleEntity(); + var calc = new Mock(); + calc.Setup(c => c.Calculate(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new DamageResult(10, SuPacketHitMode.SuccessAttack)); + var stats = new Mock(); + stats.Setup(s => s.GetStats(It.IsAny())).Returns(new CombatStats()); + + var buffs = new Mock(); + var entered = new TaskCompletionSource(); + var applying = new TaskCompletionSource(); + buffs.Setup(b => b.InflictCardsAsync(It.IsAny(), It.IsAny(), + It.IsAny>())) + .Returns(() => + { + entered.TrySetResult(); + return applying.Task; + }); + + var queue = new HitQueue(calc.Object, stats.Object, buffs.Object, + new Mock().Object, new Mock().Object, + new Mock>().Object); + var skill = MakeSkill() with { BCards = new[] { new BCardDto { Type = 3 } } }; + + var hit = queue.EnqueueAsync(Request(attacker, target) with { Skill = skill }); + + try + { + Assert.AreSame(entered.Task, await Task.WhenAny(entered.Task, Task.Delay(5000)), + "the worker never reached the card application"); + + // Now that the call is in flight, the hit must not be finished. The wait can only + // fail in the safe direction: if the call is awaited the hit can never complete, + // so the delay always wins. + Assert.AreNotSame(hit, await Task.WhenAny(hit, Task.Delay(200)), + "the hit finished while the card was still being applied"); + } + finally + { + // In a finally, or a failed assertion above would leave the queue's worker parked + // on a gate nobody opens for the rest of the run. + applying.TrySetResult(); + } + + await hit; } [TestMethod] @@ -129,6 +181,9 @@ public async Task KillingHitSkipsBuffApplication() await queue.EnqueueAsync(Request(attacker, target) with { Skill = skill }); buffs.Verify(b => b.ApplySkillBuffAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); + + buffs.Verify(b => b.InflictCardsAsync(It.IsAny(), It.IsAny(), + It.IsAny>()), Times.Never); } [TestMethod] diff --git a/test/NosCore.GameObject.Tests/Services/BattleService/InflictedCardTests.cs b/test/NosCore.GameObject.Tests/Services/BattleService/InflictedCardTests.cs new file mode 100644 index 000000000..c350e088f --- /dev/null +++ b/test/NosCore.GameObject.Tests/Services/BattleService/InflictedCardTests.cs @@ -0,0 +1,164 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | v / _| +// |_|\__|\__/ |___/ \__/\__/|_|_\___| +// + +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using NodaTime; +using NodaTime.Testing; +using NosCore.Data.Enumerations.Buff; +using NosCore.Data.StaticEntities; +using NosCore.GameObject.Ecs; +using NosCore.GameObject.Ecs.Components; +using NosCore.GameObject.Ecs.Interfaces; +using NosCore.GameObject.Infastructure; +using NosCore.GameObject.Services.BattleService; +using NosCore.GameObject.Services.BattleService.Model; + +namespace NosCore.GameObject.Tests.Services.BattleService +{ + [TestClass] + public class InflictedCardTests + { + private const short StunCardId = 7; + + private BuffService _service = null!; + private Mock _catalog = null!; + private Mock _random = null!; + private MapWorld _world = null!; + private IAliveEntity _target = null!; + private IAliveEntity _caster = null!; + + private static readonly CardDto StunCard = new() { CardId = StunCardId, Duration = 30 }; + + private static readonly List StunCardEffects = new() + { + new BCardDto { CardId = StunCardId, Type = (byte)BCardType.CardType.SpecialActions, FirstData = 1 } + }; + + private static BCardDto Declares(BCardEffect effect, int percent, short cardId) => + new() + { + Type = effect.Type(), + SubType = effect.SubType(), + FirstData = (short)percent, + SecondData = cardId + }; + + [TestInitialize] + public void Setup() + { + _catalog = new Mock(); + _catalog.Setup(c => c.GetCard(StunCardId)).Returns(StunCard); + _catalog.Setup(c => c.GetCardBCards(StunCardId)).Returns(StunCardEffects); + + _random = new Mock(); + _world = new MapWorld(); + _target = Entity(); + _caster = Entity(); + + _service = new BuffService(new FakeClock(Instant.FromUtc(2026, 1, 1, 0, 0)), + _catalog.Object, _random.Object); + } + + // A bundle carrying only the two components the buff path reads. MapInstance comes off + // NpcStateComponent and is left null, so ApplyAsync stores the buff and sends no packet. + private IAliveEntity Entity() => + new MonsterComponentBundle( + _world.World.Create( + new BuffStateComponent(new ConcurrentDictionary()), + new NpcStateComponent(null!, null!, null!, null!, null, null, null, null!, null, false)), + _world); + + private void RollGives(int value) => _random.Setup(r => r.Next(0, 100)).Returns(value); + + private Task Inflict(params BCardDto[] declared) => + _service.InflictCardsAsync(_target, _caster, declared); + + [TestMethod] + public async Task TheCardTheSkillNamesIsTheOneApplied() + { + RollGives(0); + + await Inflict(Declares(BCardEffect.BuffChanceCausing, 60, StunCardId)); + + var buff = _service.GetActiveBuffs(_target); + Assert.AreEqual(1, buff.Count); + Assert.AreEqual(StunCardId, System.Linq.Enumerable.First(buff).CardId); + CollectionAssert.AreEqual(StunCardEffects, + (System.Collections.ICollection)System.Linq.Enumerable.First(buff).BCards); + Assert.AreSame(_caster, System.Linq.Enumerable.First(buff).Caster); + } + + [TestMethod] + public async Task AFailedRollAppliesNothing() + { + RollGives(60); + + await Inflict(Declares(BCardEffect.BuffChanceCausing, 60, StunCardId)); + + Assert.AreEqual(0, _service.GetActiveBuffs(_target).Count); + } + + [TestMethod] + public async Task AHundredPercentAlwaysLands() + { + RollGives(99); + + await Inflict(Declares(BCardEffect.BuffChanceCausing, 100, StunCardId)); + + Assert.IsTrue(_service.HasBuff(_target, StunCardId)); + } + + [TestMethod] + public async Task ZeroPercentNeverLands() + { + RollGives(0); + + await Inflict(Declares(BCardEffect.BuffChanceCausing, 0, StunCardId)); + + Assert.AreEqual(0, _service.GetActiveBuffs(_target).Count); + } + + [TestMethod] + public async Task TheRemovingSubtypeRemovesInsteadOfApplying() + { + RollGives(0); + await _service.ApplyAsync(_target, StunCard, StunCardEffects, _caster); + Assert.IsTrue(_service.HasBuff(_target, StunCardId)); + + await Inflict(Declares(BCardEffect.BuffChanceRemoving, 100, StunCardId)); + + Assert.IsFalse(_service.HasBuff(_target, StunCardId)); + } + + [TestMethod] + public async Task OtherEffectTypesAreLeftAlone() + { + RollGives(0); + + await Inflict(new BCardDto + { + Type = (byte)BCardType.CardType.AttackPower, SubType = 11, FirstData = 50 + }); + + Assert.AreEqual(0, _service.GetActiveBuffs(_target).Count); + } + + [TestMethod] + public async Task ACardTheFileDoesNotHaveIsSkipped() + { + RollGives(0); + _catalog.Setup(c => c.GetCard(It.Is(v => v != StunCardId))).Returns((CardDto?)null); + + await Inflict(Declares(BCardEffect.BuffChanceCausing, 100, 9999)); + + Assert.AreEqual(0, _service.GetActiveBuffs(_target).Count); + } + } +}