From 1b20e7c325a06bba272eadc7965b1c65db0d5093 Mon Sep 17 00:00:00 2001 From: nevenit Date: Fri, 16 Oct 2026 05:19:22 +1000 Subject: [PATCH 01/15] feat(mate): the party panel lists the pet that is out Co-Authored-By: Claude Opus 5.5 --- .../Extensions/CharacterEntityExtension.cs | 19 +++++ .../Ecs/Extensions/MateExtensions.cs | 15 ++++ .../MateService/MatePartyPanelTests.cs | 85 +++++++++++++++++++ test/NosCore.Tests.Shared/TestMates.cs | 47 ++++++++++ 4 files changed, 166 insertions(+) create mode 100644 test/NosCore.GameObject.Tests/Services/MateService/MatePartyPanelTests.cs create mode 100644 test/NosCore.Tests.Shared/TestMates.cs diff --git a/src/NosCore.GameObject/Ecs/Extensions/CharacterEntityExtension.cs b/src/NosCore.GameObject/Ecs/Extensions/CharacterEntityExtension.cs index faf927262..004d76bdd 100644 --- a/src/NosCore.GameObject/Ecs/Extensions/CharacterEntityExtension.cs +++ b/src/NosCore.GameObject/Ecs/Extensions/CharacterEntityExtension.cs @@ -19,6 +19,7 @@ using NosCore.GameObject.Services.BattleService; using NosCore.GameObject.Services.BroadcastService; using NosCore.GameObject.Services.GroupService; +using NosCore.Packets.ServerPackets.Groups; using NosCore.GameObject.Services.InventoryService; using NosCore.GameObject.Services.ItemGenerationService; using NosCore.GameObject.Services.ItemGenerationService.Item; @@ -416,6 +417,24 @@ await characterEntity.SendPacketAsync(new MsgiPacket }); } + // Alone, the official server lists nobody; in a group the players come first. Either way + // the recipient's own pets that are out follow. + public static PinitPacket GeneratePinit(this ICharacterEntity character) + { + var group = character.Group; + var entries = group is { Count: > 1 } + ? group.GeneratePinit().PinitSubPackets ?? new List() + : new List(); + if (character is PlayerComponentBundle player) + { + entries.AddRange(player.Mates.Values + .Where(s => s.IsTeamMember) + .Select(s => (PinitSubPacket?)s.GenerateSubPinit(player.AccountLanguage))); + } + + return new PinitPacket { GroupSize = entries.Count, PinitSubPackets = entries }; + } + public static void JoinGroup(this ICharacterEntity characterEntity, Group group) { characterEntity.Group = group; diff --git a/src/NosCore.GameObject/Ecs/Extensions/MateExtensions.cs b/src/NosCore.GameObject/Ecs/Extensions/MateExtensions.cs index 85ef3b081..cc1814f25 100644 --- a/src/NosCore.GameObject/Ecs/Extensions/MateExtensions.cs +++ b/src/NosCore.GameObject/Ecs/Extensions/MateExtensions.cs @@ -8,6 +8,7 @@ using NosCore.GameObject.Services.MateService; using NosCore.Packets.Enumerations; using NosCore.Packets.ServerPackets.Entities; +using NosCore.Packets.ServerPackets.Groups; using NosCore.Packets.ServerPackets.Mates; using NosCore.Packets.ServerPackets.Parcel; using NosCore.Packets.ServerPackets.Player; @@ -136,6 +137,20 @@ public static InPacket GenerateIn(this Mate mate, RegionType language) }; } + public static PinitSubPacket GenerateSubPinit(this Mate mate, RegionType language) + { + return new PinitSubPacket + { + VisualType = VisualType.Npc, + VisualId = mate.MateTransportId, + GroupPosition = (int)mate.MateType, + Level = mate.Level, + Name = mate.DisplayName(language), + Unknown = -1, + Unknown2 = mate.VNum + }; + } + public static OutPacket GenerateOut(this Mate mate) { return new OutPacket diff --git a/test/NosCore.GameObject.Tests/Services/MateService/MatePartyPanelTests.cs b/test/NosCore.GameObject.Tests/Services/MateService/MatePartyPanelTests.cs new file mode 100644 index 000000000..1ada7dc0b --- /dev/null +++ b/test/NosCore.GameObject.Tests/Services/MateService/MatePartyPanelTests.cs @@ -0,0 +1,85 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | v / _| +// |_|\__|\__/ |___/ \__/\__/|_|_\___| +// + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NosCore.Data.Enumerations.Group; +using NosCore.GameObject.Ecs.Extensions; +using NosCore.GameObject.Networking.ClientSession; +using NosCore.GameObject.Services.GroupService; +using NosCore.Shared.Enumerations; +using NosCore.Tests.Shared; + +namespace NosCore.GameObject.Tests.Services.MateService +{ + [TestClass] + public class MatePartyPanelTests + { + private ClientSession _session = null!; + + [TestInitialize] + public async Task SetupAsync() + { + await TestHelpers.ResetAsync(); + _session = await TestHelpers.Instance.GenerateSessionAsync(); + } + + [TestMethod] + public void AloneWithoutAPetThePanelIsEmpty() + { + var pinit = _session.Character.GeneratePinit(); + + Assert.AreEqual(0, pinit.GroupSize); + Assert.AreEqual(0, pinit.PinitSubPackets!.Count, "the recording sends a bare pinit 0 when alone"); + } + + [TestMethod] + public void AnActivePetIsListedTheWayTheRecordingShows() + { + var pet = TestMates.Pet(_session.Character.CharacterId, level: 2); + _session.Character.Mates[pet.MateTransportId] = pet; + + var pinit = _session.Character.GeneratePinit(); + + Assert.AreEqual(1, pinit.GroupSize); + var entry = pinit.PinitSubPackets!.Single()!; + Assert.AreEqual(VisualType.Npc, entry.VisualType); + Assert.AreEqual(pet.MateTransportId, entry.VisualId); + Assert.AreEqual(1, entry.GroupPosition); + Assert.AreEqual(2, entry.Level); + Assert.AreEqual(-1, entry.Unknown); + Assert.AreEqual(333, entry.Unknown2, "the field after -1 carries the creature's vnum"); + } + + [TestMethod] + public void APetLeftAtHomeIsNotListed() + { + var pet = TestMates.Pet(_session.Character.CharacterId, isTeamMember: false); + _session.Character.Mates[pet.MateTransportId] = pet; + + Assert.AreEqual(0, _session.Character.GeneratePinit().PinitSubPackets!.Count); + } + + [TestMethod] + public async Task InAGroupThePetComesAfterThePlayers() + { + var other = await TestHelpers.Instance.GenerateSessionAsync(); + var group = new Group(GroupType.Group, TestHelpers.Instance.SessionGroupFactory); + _session.Character.JoinGroup(group); + other.Character.JoinGroup(group); + var pet = TestMates.Pet(_session.Character.CharacterId); + _session.Character.Mates[pet.MateTransportId] = pet; + + var pinit = _session.Character.GeneratePinit(); + + Assert.AreEqual(3, pinit.GroupSize); + CollectionAssert.AreEqual( + new[] { VisualType.Player, VisualType.Player, VisualType.Npc }, + pinit.PinitSubPackets!.Select(s => s!.VisualType).ToArray()); + } + } +} diff --git a/test/NosCore.Tests.Shared/TestMates.cs b/test/NosCore.Tests.Shared/TestMates.cs new file mode 100644 index 000000000..350c86840 --- /dev/null +++ b/test/NosCore.Tests.Shared/TestMates.cs @@ -0,0 +1,47 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | v / _| +// |_|\__|\__/ |___/ \__/\__/|_|_\___| +// + +using System.Threading; +using NosCore.Data.Dto; +using NosCore.Data.Enumerations.Character; +using NosCore.Data.StaticEntities; +using NosCore.GameObject.Services.MateService; +using NosCore.Shared.Enumerations; + +namespace NosCore.Tests.Shared +{ + public static class TestMates + { + private static long _nextTransportId = 3000000; + + public static Mate Pet(long characterId, bool isTeamMember = true, byte level = 1, short vnum = 333) => + Create(characterId, MateType.Pet, isTeamMember, level, vnum, "Chicken"); + + public static Mate Partner(long characterId, bool isTeamMember = true) => + Create(characterId, MateType.Partner, isTeamMember, 1, 317, "Partner"); + + private static Mate Create(long characterId, MateType type, bool isTeamMember, byte level, short vnum, + string name) + { + var i18N = new I18NString(); + i18N[RegionType.EN] = name; + return new Mate + { + CharacterId = characterId, + VNum = vnum, + MateType = type, + Level = level, + Loyalty = 3000, + Hp = 156, + Mp = 10, + IsTeamMember = isTeamMember, + IsSummonable = true, + MateTransportId = Interlocked.Increment(ref _nextTransportId), + NpcMonster = new NpcMonsterDto { NpcMonsterVNum = vnum, Name = i18N, Level = 1, MaxHp = 156, MaxMp = 10 } + }; + } + } +} From d9fee6f287dac29bf910251212edb5940502e80f Mon Sep 17 00:00:00 2001 From: nevenit Date: Fri, 16 Oct 2026 05:20:43 +1000 Subject: [PATCH 02/15] feat(mate): every party panel the player receives carries their pet Co-Authored-By: Claude Opus 5.5 --- .../Extensions/CharacterEntityExtension.cs | 2 +- .../Ecs/Extensions/PlayerBundleExtensions.cs | 4 +-- .../MapChangeService/MapChangeService.cs | 2 +- .../Battle/RevivalPacketHandler.cs | 2 +- .../Game/GameStartPacketHandler.cs | 2 +- .../Group/PjoinPacketHandler.cs | 2 +- .../Group/PleavePacketHandler.cs | 6 ++-- .../MapChangeService/MapChangeServiceTests.cs | 15 ++++++++++ .../Game/GameStartPacketHandlerTests.cs | 28 +++++++++++++++++++ 9 files changed, 53 insertions(+), 10 deletions(-) diff --git a/src/NosCore.GameObject/Ecs/Extensions/CharacterEntityExtension.cs b/src/NosCore.GameObject/Ecs/Extensions/CharacterEntityExtension.cs index 004d76bdd..fce1afac8 100644 --- a/src/NosCore.GameObject/Ecs/Extensions/CharacterEntityExtension.cs +++ b/src/NosCore.GameObject/Ecs/Extensions/CharacterEntityExtension.cs @@ -465,7 +465,7 @@ await groupMember.SendPacketAsync(new MsgiPacket }); } - await groupMember.SendPacketAsync(groupMember.Group.GeneratePinit()); + await groupMember.SendPacketAsync(groupMember.GeneratePinit()); } characterEntity.Group = new Group(GroupType.Group, sessionGroupFactory); diff --git a/src/NosCore.GameObject/Ecs/Extensions/PlayerBundleExtensions.cs b/src/NosCore.GameObject/Ecs/Extensions/PlayerBundleExtensions.cs index 485618e12..856fce4f7 100644 --- a/src/NosCore.GameObject/Ecs/Extensions/PlayerBundleExtensions.cs +++ b/src/NosCore.GameObject/Ecs/Extensions/PlayerBundleExtensions.cs @@ -233,10 +233,10 @@ await Task.WhenAll(mapSessions.Select(async s => if (sessionRegistry.TryGetCharacter(s => s.VisualId == member.Item2, out var groupMember)) { - await groupMember.SendPacketAsync(group.GeneratePinit()); + await groupMember.SendPacketAsync(groupMember.GeneratePinit()); } } - await player.SendPacketAsync(group.GeneratePinit()); + await player.SendPacketAsync(player.GeneratePinit()); } await player.SendPacketAsync(new MsgiPacket diff --git a/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs b/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs index 82c60d1e8..768700910 100644 --- a/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs +++ b/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs @@ -210,7 +210,6 @@ position with if (group != null) { - await session.SendPacketAsync(group.GeneratePinit()); if (!group.IsEmpty) { await session.SendPacketsAsync(group.GeneratePst()); @@ -284,6 +283,7 @@ await session.SendPacketsAsync(otherCharacter.Mates.Values await newMapInstance.SendPacketsAsync(mateSpawns); } + await session.SendPacketAsync(character.GeneratePinit()); await session.SendPacketsAsync(teamMates.Select(s => s.GenerateCond())); await session.SendPacketsAsync(teamMates.Select(s => s.GeneratePst())); diff --git a/src/NosCore.PacketHandlers/Battle/RevivalPacketHandler.cs b/src/NosCore.PacketHandlers/Battle/RevivalPacketHandler.cs index 1c5ff7fdc..1ef7b3dfa 100644 --- a/src/NosCore.PacketHandlers/Battle/RevivalPacketHandler.cs +++ b/src/NosCore.PacketHandlers/Battle/RevivalPacketHandler.cs @@ -166,7 +166,7 @@ await mapInstance.SendPacketAsync(new RevivePacket Data = 0, }).ConfigureAwait(false); - await session.SendPacketAsync(character.Group.GeneratePinit()).ConfigureAwait(false); + await session.SendPacketAsync(character.GeneratePinit()).ConfigureAwait(false); await session.SendPacketAsync(character.GenerateStat()).ConfigureAwait(false); } } diff --git a/src/NosCore.PacketHandlers/Game/GameStartPacketHandler.cs b/src/NosCore.PacketHandlers/Game/GameStartPacketHandler.cs index 26a61d5ee..30fffc7f3 100644 --- a/src/NosCore.PacketHandlers/Game/GameStartPacketHandler.cs +++ b/src/NosCore.PacketHandlers/Game/GameStartPacketHandler.cs @@ -149,7 +149,7 @@ await session.SendPacketAsync(new TwkPacket(session.Account.Name, session.Charac await session.SendPacketsAsync(MateService.GenerateScPackets(session.Character.Mates.Values, session.Character.AccountLanguage)); await session.SendPacketAsync(new ScPStcPacket { MaxMateCountTenths = 0 }); - await session.SendPacketAsync(session.Character.Group.GeneratePinit()); + await session.SendPacketAsync(session.Character.GeneratePinit()); await session.SendPacketsAsync(session.Character.Group.GeneratePst()); await session.SendPacketsAsync(session.Character.Mates.Values .Where(s => s.IsTeamMember).Select(s => s.GeneratePst())); diff --git a/src/NosCore.PacketHandlers/Group/PjoinPacketHandler.cs b/src/NosCore.PacketHandlers/Group/PjoinPacketHandler.cs index 7621f73af..da10c7cb9 100644 --- a/src/NosCore.PacketHandlers/Group/PjoinPacketHandler.cs +++ b/src/NosCore.PacketHandlers/Group/PjoinPacketHandler.cs @@ -247,7 +247,7 @@ await targetSession.SendPacketAsync(new InfoiPacket if (sessionRegistry.TryGetCharacter(s => s.VisualId == member.Item2.VisualId, out var session)) { - await session.SendPacketAsync(currentGroup.GeneratePinit()); + await session.SendPacketAsync(session.GeneratePinit()); await session.SendPacketsAsync(currentGroup.GeneratePst().Where(p => p.VisualId != session.VisualId)); } } diff --git a/src/NosCore.PacketHandlers/Group/PleavePacketHandler.cs b/src/NosCore.PacketHandlers/Group/PleavePacketHandler.cs index f093b0333..3cc33cffc 100644 --- a/src/NosCore.PacketHandlers/Group/PleavePacketHandler.cs +++ b/src/NosCore.PacketHandlers/Group/PleavePacketHandler.cs @@ -61,10 +61,10 @@ await targetsession.SendPacketAsync(new InfoiPacket foreach (var member in group.Values.Where(s => s.Item2 is ICharacterEntity)) { var character = member.Item2 as ICharacterEntity; - await (character == null ? Task.CompletedTask : character.SendPacketAsync(character.Group.GeneratePinit())); + await (character == null ? Task.CompletedTask : character.SendPacketAsync(character.GeneratePinit())); } - await clientSession.SendPacketAsync(clientSession.Character.Group.GeneratePinit()); + await clientSession.SendPacketAsync(clientSession.Character.GeneratePinit()); await clientSession.Character.MapInstance.SendPacketAsync( clientSession.Character.Group.GeneratePidx(clientSession.Character)); } @@ -88,7 +88,7 @@ await targetsession.SendPacketAsync(new MsgiPacket }); await targetsession.LeaveGroupAsync(sessionGroupFactory, sessionRegistry); - await targetsession.SendPacketAsync(targetsession.Group.GeneratePinit()); + await targetsession.SendPacketAsync(targetsession.GeneratePinit()); await targetsession.MapInstance.SendPacketAsync(targetsession.Group.GeneratePidx(targetsession)); } diff --git a/test/NosCore.GameObject.Tests/Services/MapChangeService/MapChangeServiceTests.cs b/test/NosCore.GameObject.Tests/Services/MapChangeService/MapChangeServiceTests.cs index 1a88dfa7c..e4b25dba3 100644 --- a/test/NosCore.GameObject.Tests/Services/MapChangeService/MapChangeServiceTests.cs +++ b/test/NosCore.GameObject.Tests/Services/MapChangeService/MapChangeServiceTests.cs @@ -14,6 +14,8 @@ using NosCore.GameObject.Services.MapChangeService; using NosCore.GameObject.Services.MinilandService; using NosCore.Tests.Shared; +using NosCore.Packets.ServerPackets.Groups; +using System.Linq; using Microsoft.Extensions.Logging; using SpecLight; using System.Threading.Tasks; @@ -142,6 +144,19 @@ private void CharacterMapIdIsPinnedTo_(short mapId) Session.Character.MapId = mapId; } + [TestMethod] + public async Task AMapChangeListsThePetInThePartyPanel() + { + var pet = TestMates.Pet(Session.Character.CharacterId); + Session.Character.Mates[pet.MateTransportId] = pet; + + await ChangingToBaseMap1(); + + var pinit = Session.LastPackets.OfType().Last(); + Assert.IsTrue(pinit.PinitSubPackets!.Any(s => s!.VisualId == pet.MateTransportId)); + Assert.IsNotNull(pet.Entity, "the pet must be on the new map before the panel names it"); + } + private async Task ChangingToBaseMap1() { var map1 = TestHelpers.Instance.MapInstanceAccessorService.GetBaseMapById(1)!; diff --git a/test/NosCore.PacketHandlers.Tests/Game/GameStartPacketHandlerTests.cs b/test/NosCore.PacketHandlers.Tests/Game/GameStartPacketHandlerTests.cs index 1bccaec96..75cfa7371 100644 --- a/test/NosCore.PacketHandlers.Tests/Game/GameStartPacketHandlerTests.cs +++ b/test/NosCore.PacketHandlers.Tests/Game/GameStartPacketHandlerTests.cs @@ -23,6 +23,9 @@ using NosCore.Packets.ClientPackets.CharacterSelectionScreen; using NosCore.Packets.Interfaces; using NosCore.Tests.Shared; +using NosCore.Packets.ServerPackets.Groups; +using NosCore.Shared.Enumerations; +using System.Linq; using SpecLight; using System; using System.Collections.Concurrent; @@ -169,6 +172,31 @@ private async Task ExecutingGameStart() await Handler.ExecuteAsync(new GameStartPacket(), Session); } + [TestMethod] + public async Task GameStartListsThePetInThePartyPanel() + { + await new Spec("The login pinit carries the pet that is out") + .Given(CharacterIsReadyToStart) + .And(CharacterHasAPetOut) + .WhenAsync(ExecutingGameStart) + .Then(ThePartyPanelShouldListThePet) + .ExecuteAsync(); + } + + private NosCore.GameObject.Services.MateService.Mate _pet = null!; + + private void CharacterHasAPetOut() + { + _pet = TestMates.Pet(Session.Character.CharacterId); + Session.Character.Mates[_pet.MateTransportId] = _pet; + } + + private void ThePartyPanelShouldListThePet() + { + var pinit = Session.LastPackets.OfType().Last(); + Assert.IsTrue(pinit.PinitSubPackets!.Any(s => s!.VisualType == VisualType.Npc && s.VisualId == _pet.MateTransportId)); + } + private void GameStartedShouldBeFalse() { Assert.IsFalse(Session.GameStarted); From 3dba0dfe672d1c8dec99bd33b0a686da9d597f01 Mon Sep 17 00:00:00 2001 From: nevenit Date: Fri, 16 Oct 2026 05:22:07 +1000 Subject: [PATCH 03/15] fix(mate): opening the pet window lists the pets instead of wiping them Co-Authored-By: Claude Opus 5.5 --- .../Ecs/Extensions/PlayerBundleExtensions.cs | 5 ++ .../Game/GameStartPacketHandler.cs | 3 +- .../Game/NpinfoPacketHandler.cs | 11 +--- .../Game/ScpCtsPacketHandler.cs | 13 +--- .../Game/PetWindowPacketHandlerTests.cs | 66 +++++++++++++++++++ 5 files changed, 76 insertions(+), 22 deletions(-) create mode 100644 test/NosCore.PacketHandlers.Tests/Game/PetWindowPacketHandlerTests.cs diff --git a/src/NosCore.GameObject/Ecs/Extensions/PlayerBundleExtensions.cs b/src/NosCore.GameObject/Ecs/Extensions/PlayerBundleExtensions.cs index 856fce4f7..5a5448274 100644 --- a/src/NosCore.GameObject/Ecs/Extensions/PlayerBundleExtensions.cs +++ b/src/NosCore.GameObject/Ecs/Extensions/PlayerBundleExtensions.cs @@ -24,6 +24,7 @@ using NosCore.GameObject.Services.BroadcastService; using NosCore.GameObject.Services.GroupService; using NosCore.GameObject.Services.InventoryService; +using NosCore.GameObject.Services.MateService; using NosCore.GameObject.Services.ItemGenerationService; using NosCore.GameObject.Services.ItemGenerationService.Item; using NosCore.Networking; @@ -73,6 +74,10 @@ public static Task SendPacketsAsync(this PlayerComponentBundle player, IEnumerab public static bool CanSitDown(this PlayerComponentBundle player, Instant now) => now - player.LastAttack >= SitLockAfterAttack; + public static IEnumerable GenerateMateRoster(this PlayerComponentBundle player) => + new IPacket[] { new PclearPacket() } + .Concat(MateService.GenerateScPackets(player.Mates.Values, player.AccountLanguage)); + public static Task RestAsync(this PlayerComponentBundle player) { if (player.IsSitting) diff --git a/src/NosCore.PacketHandlers/Game/GameStartPacketHandler.cs b/src/NosCore.PacketHandlers/Game/GameStartPacketHandler.cs index 30fffc7f3..8bfa433a0 100644 --- a/src/NosCore.PacketHandlers/Game/GameStartPacketHandler.cs +++ b/src/NosCore.PacketHandlers/Game/GameStartPacketHandler.cs @@ -145,8 +145,7 @@ await session.SendPacketAsync(new TwkPacket(session.Account.Name, session.Charac // Session.SendPacket(Session.Character.GenerateFaction()); // p_clear wipes one panel holding both the party and the mate list, so both bursts // have to follow it rather than straddle it. - await session.SendPacketAsync(new PclearPacket()); - await session.SendPacketsAsync(MateService.GenerateScPackets(session.Character.Mates.Values, session.Character.AccountLanguage)); + await session.SendPacketsAsync(session.Character.GenerateMateRoster()); await session.SendPacketAsync(new ScPStcPacket { MaxMateCountTenths = 0 }); await session.SendPacketAsync(session.Character.GeneratePinit()); diff --git a/src/NosCore.PacketHandlers/Game/NpinfoPacketHandler.cs b/src/NosCore.PacketHandlers/Game/NpinfoPacketHandler.cs index d9fe09f80..4544f6216 100644 --- a/src/NosCore.PacketHandlers/Game/NpinfoPacketHandler.cs +++ b/src/NosCore.PacketHandlers/Game/NpinfoPacketHandler.cs @@ -5,26 +5,19 @@ // using System.Threading.Tasks; +using NosCore.GameObject.Ecs.Extensions; using NosCore.GameObject.Infastructure; using NosCore.GameObject.Networking.ClientSession; using NosCore.Packets.ServerPackets.Player; -using NosCore.Packets.ServerPackets.UI; namespace NosCore.PacketHandlers.Game { - // Client sends `npinfo ` when it opens or pages through the pet panel. - // OpenNos BasicPacketHandler.GetStats replies with stat_char, caches the page, - // emits p_clear, then a sc_p per Mate on that page. - // - // NosCore has no runtime Mate subsystem yet, so we reply with p_clear so the - // client renders an empty roster rather than the "loading" state that pins the - // tab open forever. No sc_p follow-ups. public sealed class NpinfoPacketHandler : PacketHandler, IWorldPacketHandler { public override Task ExecuteAsync(NpInfoPacket packet, ClientSession session) { - return session.SendPacketAsync(new PclearPacket()); + return session.SendPacketsAsync(session.Character.GenerateMateRoster()); } } } diff --git a/src/NosCore.PacketHandlers/Game/ScpCtsPacketHandler.cs b/src/NosCore.PacketHandlers/Game/ScpCtsPacketHandler.cs index 4dab383b7..473cec5aa 100644 --- a/src/NosCore.PacketHandlers/Game/ScpCtsPacketHandler.cs +++ b/src/NosCore.PacketHandlers/Game/ScpCtsPacketHandler.cs @@ -8,22 +8,13 @@ using NosCore.GameObject.Infastructure; using NosCore.GameObject.Networking.ClientSession; using NosCore.Packets.ClientPackets; -using NosCore.Packets.ServerPackets.Specialists; namespace NosCore.PacketHandlers.Game { - // Post-OpenNos "refresh pet tab" request; the `_cts` suffix is the client-to-server - // convention (same as frank_cts / fhis_cts). OpenNos never implemented a handler - // because the packet post-dates the codebase. Siblings of this packet (in a live - // session) also come with sc_p_stc 0 0 from the server to confirm the roster cap. - // Mate subsystem isn't wired, so we answer with sc_p_stc 0 (empty roster) and - // leave it at that. + // The official server leaves the pet window's refresh request unanswered. public sealed class ScpCtsPacketHandler : PacketHandler, IWorldPacketHandler { - public override Task ExecuteAsync(ScpCtsPacket packet, ClientSession session) - { - return session.SendPacketAsync(new ScPStcPacket { MaxMateCountTenths = 0 }); - } + public override Task ExecuteAsync(ScpCtsPacket packet, ClientSession session) => Task.CompletedTask; } } diff --git a/test/NosCore.PacketHandlers.Tests/Game/PetWindowPacketHandlerTests.cs b/test/NosCore.PacketHandlers.Tests/Game/PetWindowPacketHandlerTests.cs new file mode 100644 index 000000000..14cae1606 --- /dev/null +++ b/test/NosCore.PacketHandlers.Tests/Game/PetWindowPacketHandlerTests.cs @@ -0,0 +1,66 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | v / _| +// |_|\__|\__/ |___/ \__/\__/|_|_\___| +// + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NosCore.GameObject.Networking.ClientSession; +using NosCore.PacketHandlers.Game; +using NosCore.Packets.ClientPackets; +using NosCore.Packets.ServerPackets.Mates; +using NosCore.Packets.ServerPackets.Player; +using NosCore.Packets.ServerPackets.UI; +using NosCore.Tests.Shared; + +namespace NosCore.PacketHandlers.Tests.Game +{ + [TestClass] + public class PetWindowPacketHandlerTests + { + private ClientSession _session = null!; + + [TestInitialize] + public async Task SetupAsync() + { + await TestHelpers.ResetAsync(); + _session = await TestHelpers.Instance.GenerateSessionAsync(); + _session.LastPackets.Clear(); + } + + [TestMethod] + public async Task OpeningThePetWindowResendsTheRoster() + { + var pet = TestMates.Pet(_session.Character.CharacterId); + var partner = TestMates.Partner(_session.Character.CharacterId); + _session.Character.Mates[pet.MateTransportId] = pet; + _session.Character.Mates[partner.MateTransportId] = partner; + + await new NpinfoPacketHandler().ExecuteAsync(new NpInfoPacket { Page = 0 }, _session); + + var sent = _session.LastPackets.ToList(); + Assert.IsInstanceOfType(sent[0], typeof(PclearPacket), "the list follows a p_clear"); + Assert.AreEqual(1, sent.OfType().Count()); + Assert.AreEqual(1, sent.OfType().Count()); + } + + [TestMethod] + public async Task OpeningThePetWindowWithoutPetsOnlyClears() + { + await new NpinfoPacketHandler().ExecuteAsync(new NpInfoPacket { Page = 0 }, _session); + + Assert.AreEqual(1, _session.LastPackets.Count); + Assert.IsInstanceOfType(_session.LastPackets.Single(), typeof(PclearPacket)); + } + + [TestMethod] + public async Task RefreshingThePetWindowGetsNoReply() + { + await new ScpCtsPacketHandler().ExecuteAsync(new ScpCtsPacket(), _session); + + Assert.AreEqual(0, _session.LastPackets.Count, "the recording shows no answer to sc_p_cts"); + } + } +} From 08a6cc54f34b27578ac58d9b4dd8e20f29637bf6 Mon Sep 17 00:00:00 2001 From: nevenit Date: Fri, 16 Oct 2026 05:22:56 +1000 Subject: [PATCH 04/15] feat(mate): build a live mate from a freshly written row Co-Authored-By: Claude Opus 5.5 --- .../Services/MateService/IMateService.cs | 4 +++ .../Services/MateService/MateService.cs | 26 +++++++++++---- .../Services/MateService/MateServiceTests.cs | 33 +++++++++++++++++++ 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/src/NosCore.GameObject/Services/MateService/IMateService.cs b/src/NosCore.GameObject/Services/MateService/IMateService.cs index 59e3be34d..1de5302ee 100644 --- a/src/NosCore.GameObject/Services/MateService/IMateService.cs +++ b/src/NosCore.GameObject/Services/MateService/IMateService.cs @@ -5,6 +5,8 @@ // using System.Collections.Generic; +using NosCore.Data.Dto; +using NosCore.Data.StaticEntities; using System.Threading.Tasks; namespace NosCore.GameObject.Services.MateService @@ -14,5 +16,7 @@ public interface IMateService Task> LoadAsync(long characterId); Task SaveAsync(IEnumerable mates); + + Mate Adopt(MateDto row, NpcMonsterDto npcMonster, IEnumerable existing); } } diff --git a/src/NosCore.GameObject/Services/MateService/MateService.cs b/src/NosCore.GameObject/Services/MateService/MateService.cs index 78b6b660e..f89527680 100644 --- a/src/NosCore.GameObject/Services/MateService/MateService.cs +++ b/src/NosCore.GameObject/Services/MateService/MateService.cs @@ -39,13 +39,7 @@ public Task> LoadAsync(long characterId) continue; } - var mate = row.Adapt(); - mate.NpcMonster = npcMonster; - mate.MateTransportId = mateIdService.GetNextId(); - mate.XpLoad = mate.MateType == MateType.Pet - ? mateExperienceService.GetPetExperience(mate.Level) - : mateExperienceService.GetPartnerExperience(mate.Level); - mates.Add(mate); + mates.Add(Build(row, npcMonster)); } foreach (var group in mates.GroupBy(s => s.MateType)) @@ -69,6 +63,24 @@ public Task> LoadAsync(long characterId) return Task.FromResult(mates); } + public Mate Adopt(MateDto row, NpcMonsterDto npcMonster, IEnumerable existing) + { + var mate = Build(row, npcMonster); + mate.PetSlot = (byte)existing.Count(s => s.MateType == row.MateType); + return mate; + } + + private Mate Build(MateDto row, NpcMonsterDto npcMonster) + { + var mate = row.Adapt(); + mate.NpcMonster = npcMonster; + mate.MateTransportId = mateIdService.GetNextId(); + mate.XpLoad = mate.MateType == MateType.Pet + ? mateExperienceService.GetPetExperience(mate.Level) + : mateExperienceService.GetPartnerExperience(mate.Level); + return mate; + } + public async Task SaveAsync(IEnumerable mates) { foreach (var mate in mates) diff --git a/test/NosCore.GameObject.Tests/Services/MateService/MateServiceTests.cs b/test/NosCore.GameObject.Tests/Services/MateService/MateServiceTests.cs index 6a307866e..117839f4e 100644 --- a/test/NosCore.GameObject.Tests/Services/MateService/MateServiceTests.cs +++ b/test/NosCore.GameObject.Tests/Services/MateService/MateServiceTests.cs @@ -351,5 +351,38 @@ public async Task ASpacedMateNameReachesTheClientAsOneFieldAsync() $"the name has to travel as one field: {line}"); } } + + [TestMethod] + public void AnAdoptedPetTakesTheNextPetSlot() + { + var service = Build(System.Array.Empty()); + var existing = new[] + { + new Mate { MateType = MateType.Pet, PetSlot = 0 }, + new Mate { MateType = MateType.Pet, PetSlot = 1 }, + new Mate { MateType = MateType.Partner, PetSlot = 0 } + }; + + var mate = service.Adopt( + new MateDto { MateId = 9, CharacterId = CharacterId, VNum = ChickenVNum, MateType = MateType.Pet, Level = 1 }, + Creature(ChickenVNum, "Chicken", 156, 10), existing); + + Assert.AreEqual(2, mate.PetSlot, "pets and partners are numbered separately"); + Assert.AreEqual(9, mate.MateId, "the row id is what a later save updates"); + } + + [TestMethod] + public void AnAdoptedPetIsReadyToShow() + { + var service = Build(System.Array.Empty()); + + var mate = service.Adopt( + new MateDto { MateId = 9, CharacterId = CharacterId, VNum = ChickenVNum, MateType = MateType.Pet, Level = 1 }, + Creature(ChickenVNum, "Chicken", 156, 10), System.Array.Empty()); + + Assert.IsTrue(mate.MateTransportId >= 2000000); + Assert.AreEqual(ChickenVNum, mate.NpcMonster.NpcMonsterVNum); + Assert.AreEqual(15, mate.XpLoad, "a level 1 pet needs 15 experience in the recording"); + } } } From 408f1994da01f3f34d2c5d11795508efd902c5a2 Mon Sep 17 00:00:00 2001 From: nevenit Date: Fri, 16 Oct 2026 05:23:54 +1000 Subject: [PATCH 05/15] refactor(mate): one path puts a mate on a map and one takes it off Co-Authored-By: Claude Opus 5.5 --- .../Ecs/Extensions/MateExtensions.cs | 28 +++++++ .../MapChangeService/MapChangeService.cs | 24 +----- .../Services/MateService/MateSpawnTests.cs | 81 +++++++++++++++++++ 3 files changed, 113 insertions(+), 20 deletions(-) create mode 100644 test/NosCore.GameObject.Tests/Services/MateService/MateSpawnTests.cs diff --git a/src/NosCore.GameObject/Ecs/Extensions/MateExtensions.cs b/src/NosCore.GameObject/Ecs/Extensions/MateExtensions.cs index cc1814f25..46938f99e 100644 --- a/src/NosCore.GameObject/Ecs/Extensions/MateExtensions.cs +++ b/src/NosCore.GameObject/Ecs/Extensions/MateExtensions.cs @@ -5,6 +5,7 @@ // using NosCore.Data.Enumerations.Character; +using NosCore.GameObject.Services.MapInstanceGenerationService; using NosCore.GameObject.Services.MateService; using NosCore.Packets.Enumerations; using NosCore.Packets.ServerPackets.Entities; @@ -13,8 +14,10 @@ using NosCore.Packets.ServerPackets.Parcel; using NosCore.Packets.ServerPackets.Player; using NosCore.Packets.ServerPackets.Visibility; +using NosCore.Networking; using NosCore.Shared.Enumerations; using System.Globalization; +using System.Threading.Tasks; namespace NosCore.GameObject.Ecs.Extensions { @@ -151,6 +154,31 @@ public static PinitSubPacket GenerateSubPinit(this Mate mate, RegionType languag }; } + public static Task SpawnAsync(this Mate mate, PlayerComponentBundle owner, MapInstance mapInstance, + short x, short y) + { + mate.PositionX = x; + mate.PositionY = y; + var handle = mapInstance.EcsWorld.CreateMate((int)mate.MateTransportId, mate, mapInstance, x, y, 2); + mate.Entity = new MateComponentBundle(handle, mapInstance.EcsWorld); + + // A hidden owner's pet would put the owner back on screen. + var spawn = mate.GenerateIn(owner.AccountLanguage); + return owner.Invisible ? owner.SendPacketAsync(spawn) : mapInstance.SendPacketAsync(spawn); + } + + public static async Task DespawnAsync(this Mate mate, MapInstance mapInstance) + { + if (mate.Entity is not { } entity) + { + return; + } + + await mapInstance.SendPacketAsync(mate.GenerateOut()); + mapInstance.EcsWorld.DestroyEntity(entity.Handle); + mate.Entity = null; + } + public static OutPacket GenerateOut(this Mate mate) { return new OutPacket diff --git a/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs b/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs index 768700910..19001411e 100644 --- a/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs +++ b/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs @@ -267,20 +267,7 @@ await session.SendPacketsAsync(otherCharacter.Mates.Values foreach (var mate in teamMates) { - var handle = newMapInstance.EcsWorld.CreateMate( - (int)mate.MateTransportId, mate, newMapInstance, - mate.PositionX, mate.PositionY, 2); - mate.Entity = new Ecs.MateComponentBundle(handle, newMapInstance.EcsWorld); - } - - var mateSpawns = teamMates.Select(s => s.GenerateIn(accountLanguage)).ToList(); - if (invisible) - { - await session.SendPacketsAsync(mateSpawns); - } - else - { - await newMapInstance.SendPacketsAsync(mateSpawns); + await mate.SpawnAsync(character, newMapInstance, mate.PositionX, mate.PositionY); } await session.SendPacketAsync(character.GeneratePinit()); @@ -318,14 +305,11 @@ private async Task LeaveMapAsync(ClientSession session) var mapInstance = character.MapInstance; var channelId = session.Channel!.Id; await mapInstance.SendPacketAsync(outPacket, new EveryoneBut(channelId)); - var leaving = character.Mates.Values.Where(s => s.IsTeamMember).ToList(); - await mapInstance.SendPacketsAsync(leaving.Select(s => s.GenerateOut())); - - foreach (var mate in leaving.Where(s => s.Entity.HasValue)) + foreach (var mate in character.Mates.Values) { - mapInstance.EcsWorld.DestroyEntity(mate.Entity!.Value.Handle); - mate.Entity = null; + await mate.DespawnAsync(mapInstance); } + session.ClearPlayerEntity(); await session.SendPacketAsync(new MapOutPacket()); } diff --git a/test/NosCore.GameObject.Tests/Services/MateService/MateSpawnTests.cs b/test/NosCore.GameObject.Tests/Services/MateService/MateSpawnTests.cs new file mode 100644 index 000000000..bc106e73c --- /dev/null +++ b/test/NosCore.GameObject.Tests/Services/MateService/MateSpawnTests.cs @@ -0,0 +1,81 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | v / _| +// |_|\__|\__/ |___/ \__/\__/|_|_\___| +// + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NosCore.GameObject.Ecs.Extensions; +using NosCore.GameObject.Networking.ClientSession; +using NosCore.Packets.ServerPackets.Entities; +using NosCore.Packets.ServerPackets.Visibility; +using NosCore.Shared.Enumerations; +using NosCore.Tests.Shared; + +namespace NosCore.GameObject.Tests.Services.MateService +{ + [TestClass] + public class MateSpawnTests + { + private ClientSession _session = null!; + private GameObject.Services.MateService.Mate _pet = null!; + + [TestInitialize] + public async Task SetupAsync() + { + await TestHelpers.ResetAsync(); + _session = await TestHelpers.Instance.GenerateSessionAsync(); + _pet = TestMates.Pet(_session.Character.CharacterId); + _session.Character.Mates[_pet.MateTransportId] = _pet; + _session.Character.MapInstance.LastPackets.Clear(); + _session.LastPackets.Clear(); + } + + [TestMethod] + public async Task ASpawnedMateStandsWhereItWasPut() + { + await _pet.SpawnAsync(_session.Character, _session.Character.MapInstance, 7, 9); + + Assert.IsNotNull(_pet.Entity); + Assert.AreEqual((7, 9), ((int)_pet.PositionX, (int)_pet.PositionY)); + var spawn = _session.Character.MapInstance.LastPackets.OfType().Single(); + Assert.AreEqual(_pet.MateTransportId, spawn.VisualId); + Assert.AreEqual((short)7, spawn.PositionX); + } + + [TestMethod] + public async Task AHiddenOwnersMateIsOnlyShownToTheOwner() + { + _session.Character.Invisible = true; + + await _pet.SpawnAsync(_session.Character, _session.Character.MapInstance, 7, 9); + + Assert.IsFalse(_session.Character.MapInstance.LastPackets.OfType().Any()); + Assert.IsTrue(_session.LastPackets.OfType().Any(s => s.VisualId == _pet.MateTransportId)); + } + + [TestMethod] + public async Task ADespawnedMateLeavesTheMap() + { + await _pet.SpawnAsync(_session.Character, _session.Character.MapInstance, 7, 9); + _session.Character.MapInstance.LastPackets.Clear(); + + await _pet.DespawnAsync(_session.Character.MapInstance); + + Assert.IsNull(_pet.Entity); + var gone = _session.Character.MapInstance.LastPackets.OfType().Single(); + Assert.AreEqual(VisualType.Npc, gone.VisualType); + Assert.AreEqual(_pet.MateTransportId, gone.VisualId); + } + + [TestMethod] + public async Task DespawningAMateThatIsNotOutSendsNothing() + { + await _pet.DespawnAsync(_session.Character.MapInstance); + + Assert.AreEqual(0, _session.Character.MapInstance.LastPackets.Count); + } + } +} From d95a3a359c9d9cc178718a00ceb79fbbebc5eca3 Mon Sep 17 00:00:00 2001 From: nevenit Date: Fri, 16 Oct 2026 05:25:47 +1000 Subject: [PATCH 06/15] feat(mate): a caught pet joins at once, and the owner is told why a capture failed Co-Authored-By: Claude Opus 5.5 --- .../Services/BattleService/CaptureService.cs | 95 +++++----- .../BattleService/CaptureServiceTests.cs | 171 ++++++++++++++++++ 2 files changed, 221 insertions(+), 45 deletions(-) create mode 100644 test/NosCore.GameObject.Tests/Services/BattleService/CaptureServiceTests.cs diff --git a/src/NosCore.GameObject/Services/BattleService/CaptureService.cs b/src/NosCore.GameObject/Services/BattleService/CaptureService.cs index d55295c56..97a9d95da 100644 --- a/src/NosCore.GameObject/Services/BattleService/CaptureService.cs +++ b/src/NosCore.GameObject/Services/BattleService/CaptureService.cs @@ -13,92 +13,98 @@ using NosCore.Data.Enumerations.Buff; using NosCore.Data.Enumerations.Character; using NosCore.GameObject.Ecs; +using NosCore.GameObject.Ecs.Extensions; using NosCore.GameObject.Ecs.Interfaces; using NosCore.GameObject.Messaging.Events; using NosCore.GameObject.Services.BattleService.Model; +using NosCore.GameObject.Services.MateService; using NosCore.Networking; using NosCore.Packets.Enumerations; using NosCore.Packets.ServerPackets.Entities; +using NosCore.Packets.ServerPackets.Mates; +using NosCore.Packets.ServerPackets.UI; using NosCore.Shared.Enumerations; -using NosCore.Shared.Helpers; using Wolverine; namespace NosCore.GameObject.Services.BattleService { - // Capture rules mirror vanosilla BCardCaptureHandler + MonsterCaptureEventHandler: - // - target must be a monster at <50 % HP - // - monster.Level <= player.Level - // - guards for raid instance / player dignity skipped here (kept for a follow-up; - // dignity/MapInstanceType don't affect the mechanic enough to block an MVP) - // - rate: 90 % if player < level 20, else 50 % - // - mate level = max(monster.Level - 15, 1) - public sealed class CaptureService(IDao mateDao, IMessageBus messageBus, IClock clock) : ICaptureService + // A monster can be caught below half health and at or under the player's level. The new pet + // starts fifteen levels below the monster and comes out at once when no other pet is out. + public sealed class CaptureService(IDao mateDao, IMateService mateService, + IMessageBus messageBus, IClock clock, IRandomProvider random) : ICaptureService { private const int CaptureHpThresholdPercent = 50; private const int LowLevelCaptureRate = 90; private const int DefaultCaptureRate = 50; private const int LowLevelCutoff = 20; private const byte MateStartLevelOffset = 15; + private const short NewPetLoyalty = 3000; + private const int CaughtEffect = 197; public bool IsCaptureSkill(SkillInfo skill) => HasCaptureBCard(skill); public async Task TryCaptureAsync(IAliveEntity caster, IAliveEntity target, SkillInfo skill) { - if (!HasCaptureBCard(skill)) + if (!HasCaptureBCard(skill) || caster is not PlayerComponentBundle owner + || target is not MonsterComponentBundle monster + || monster.NpcMonster == null || !monster.IsAlive || monster.MapInstance == null) { return; } - if (caster is not ICharacterEntity character) - { - return; - } - - if (target is not MonsterComponentBundle monster) - { - return; - } - - if (monster.NpcMonster == null || !monster.IsAlive || monster.MapInstance == null) - { - return; - } - - if (monster.NpcMonster.Level > character.Level) + if (monster.NpcMonster.Level > owner.Level) { + await owner.SendPacketAsync(Message(Game18NConstString.CaptureToHighLevel)); return; } var maxHp = monster.NpcMonster.MaxHp; - var hpPercent = maxHp > 0 ? (monster.Hp * 100 / maxHp) : 100; + var hpPercent = maxHp > 0 ? monster.Hp * 100 / maxHp : 100; if (hpPercent >= CaptureHpThresholdPercent) { + await owner.SendPacketAsync(Message(Game18NConstString.CaptureTooMuchLife)); return; } - var rate = character.Level < LowLevelCutoff ? LowLevelCaptureRate : DefaultCaptureRate; - if (RandomHelper.Instance.RandomNumber(0, 100) >= rate) + var rate = owner.Level < LowLevelCutoff ? LowLevelCaptureRate : DefaultCaptureRate; + if (random.Next(0, 100) >= rate) { + await owner.SendPacketAsync(Message(Game18NConstString.CaptureFailed)); return; } - var mateLevel = (byte)System.Math.Max(1, monster.NpcMonster.Level - MateStartLevelOffset); - - await mateDao.TryInsertOrUpdateAsync(new MateDto + var row = await mateDao.TryInsertOrUpdateAsync(new MateDto { - CharacterId = character.CharacterId, + CharacterId = owner.CharacterId, VNum = (short)monster.NpcMonster.NpcMonsterVNum, MateType = MateType.Pet, - Level = mateLevel, - Loyalty = 1000, + Level = (byte)Math.Max(1, monster.NpcMonster.Level - MateStartLevelOffset), + Loyalty = NewPetLoyalty, Experience = 0, Hp = monster.NpcMonster.MaxHp, Mp = monster.NpcMonster.MaxMp, IsSummonable = true, - IsTeamMember = !mateDao.Where(s => s.CharacterId == character.CharacterId - && s.MateType == MateType.Pet && s.IsTeamMember)!.Any() - }).ConfigureAwait(false); + IsTeamMember = !owner.Mates.Values.Any(s => s.MateType == MateType.Pet && s.IsTeamMember) + }); + var mate = mateService.Adopt(row, monster.NpcMonster, owner.Mates.Values); + owner.Mates[mate.MateTransportId] = mate; + + // Action 3 is the value the capture shows; the same ctl goes out when a pet leaves a map. + await owner.SendPacketAsync(new CtlPacket + { + Type = (byte)VisualType.Npc, PetId = mate.MateTransportId, Action = 3, Unknown = 0 + }); + await owner.SendPacketAsync(Message(Game18NConstString.CaptureSuccessful)); + await owner.SendPacketsAsync(owner.GenerateMateRoster()); + if (mate.IsTeamMember) + { + await mate.SpawnAsync(owner, monster.MapInstance, monster.PositionX, monster.PositionY); + await owner.SendPacketAsync(owner.GeneratePinit()); + await owner.SendPacketsAsync(owner.GenerateMateRoster()); + } + + await monster.MapInstance.SendPacketAsync(owner.GenerateEff(CaughtEffect)); monster.Hp = 0; monster.IsAlive = false; @@ -108,19 +114,18 @@ await monster.MapInstance.SendPacketAsync(new OutPacket { VisualType = VisualType.Monster, VisualId = monster.VisualId - }).ConfigureAwait(false); + }); - // Capture still vacates the spawn point; re-use the same map-tick respawn - // path MonsterRespawnHandler uses for kills so a captured monster eventually - // reappears at its spawn. Going through ScheduleRespawn directly (rather - // than via EntityDiedEvent) avoids pulling in the kill pipeline's xp/gold - // rewards, death bcards and hunt-quest credit. + // Same delayed respawn as a kill, without the kill's rewards and death effects. monster.MapInstance.ScheduleRespawn(monster, clock.GetCurrentInstant().Plus(RespawnTiming.For(monster.NpcMonster))); - await messageBus.PublishAsync(new EntityCapturedEvent(monster, character)).ConfigureAwait(false); + await messageBus.PublishAsync(new EntityCapturedEvent(monster, owner)); } + private static MsgiPacket Message(Game18NConstString message) => + new() { Type = MessageType.Default, Message = message }; + private static bool HasCaptureBCard(SkillInfo skill) => skill.BCards.Any(b => b.Effect() == BCardEffect.CaptureCaptureAnimal); } diff --git a/test/NosCore.GameObject.Tests/Services/BattleService/CaptureServiceTests.cs b/test/NosCore.GameObject.Tests/Services/BattleService/CaptureServiceTests.cs new file mode 100644 index 000000000..fec950e63 --- /dev/null +++ b/test/NosCore.GameObject.Tests/Services/BattleService/CaptureServiceTests.cs @@ -0,0 +1,171 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | v / _| +// |_|\__|\__/ |___/ \__/\__/|_|_\___| +// + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using NosCore.Algorithm.MateExperienceService; +using NosCore.Core.Services.IdService; +using NosCore.Data.Dto; +using NosCore.Data.Enumerations.Buff; +using NosCore.Data.StaticEntities; +using NosCore.GameObject.Ecs; +using NosCore.GameObject.Networking.ClientSession; +using NosCore.GameObject.Services.BattleService; +using NosCore.GameObject.Services.BattleService.Model; +using NosCore.Packets.Enumerations; +using NosCore.Packets.ServerPackets.Groups; +using NosCore.Packets.ServerPackets.Mates; +using NosCore.Packets.ServerPackets.Player; +using NosCore.Packets.ServerPackets.UI; +using NosCore.Packets.ServerPackets.Visibility; +using NosCore.Shared.Enumerations; +using NosCore.Tests.Shared; +using Mate = NosCore.GameObject.Services.MateService.Mate; +using MateServiceImpl = NosCore.GameObject.Services.MateService.MateService; + +namespace NosCore.GameObject.Tests.Services.BattleService +{ + [TestClass] + public class CaptureServiceTests + { + private ClientSession _session = null!; + private MonsterComponentBundle _monster; + private Mock _random = null!; + private CaptureService _service = null!; + + private static readonly SkillInfo Capture = new(209, 16, 150, 15, -1, 0, 0, default, 1, 0, 0, 0, 0, 0, + new List + { + new() { Type = BCardEffect.CaptureCaptureAnimal.Type(), SubType = BCardEffect.CaptureCaptureAnimal.SubType() } + }); + + [TestInitialize] + public async Task SetupAsync() + { + await TestHelpers.ResetAsync(); + _session = await TestHelpers.Instance.GenerateSessionAsync(); + var map = _session.Character.MapInstance; + var name = new I18NString(); + name[RegionType.EN] = "Chicken"; + var creature = new NpcMonsterDto { NpcMonsterVNum = 333, Level = 1, MaxHp = 100, MaxMp = 10, Name = name }; + _monster = new MonsterComponentBundle( + map.EcsWorld.CreateMonster(9001, creature, map, 12, 14, 2, 12, 14, false, true, false), map.EcsWorld); + _monster.Hp = 40; + + _random = new Mock(); + _random.Setup(r => r.Next(0, 100)).Returns(0); + var mateService = new MateServiceImpl(TestHelpers.Instance.MateDao, new List { creature }, + new IdService(2000000), new MateExperienceService(), NullLogger.Instance); + _service = new CaptureService(TestHelpers.Instance.MateDao, mateService, + new Mock().Object, TestHelpers.Instance.Clock, _random.Object); + + map.LastPackets.Clear(); + _session.LastPackets.Clear(); + } + + private Task Catching() => _service.TryCaptureAsync(_session.Character, _monster, Capture); + + private Mate? Caught => _session.Character.Mates.Values.SingleOrDefault(s => s.VNum == 333); + + [TestMethod] + public async Task TheFirstPetComesOutWhereTheMonsterStood() + { + await Catching(); + + Assert.IsNotNull(Caught); + Assert.IsTrue(Caught!.IsTeamMember); + Assert.AreEqual(3000, Caught.Loyalty, "every fresh catch in the recording starts at 3000"); + Assert.IsTrue(Caught.MateId > 0, "the live pet keeps the row id so a save updates it"); + var spawn = _session.Character.MapInstance.LastPackets.OfType().Single(s => s.VisualId == Caught.MateTransportId); + Assert.AreEqual(((short)12, (short)14), (spawn.PositionX, spawn.PositionY)); + } + + [TestMethod] + public async Task ASuccessfulCaptureTellsTheOwnerInTheRecordedOrder() + { + await Catching(); + + var order = _session.LastPackets + .Where(p => p is CtlPacket or MsgiPacket or PclearPacket or PinitPacket) + .Select(p => p!.GetType().Name) + .ToArray(); + CollectionAssert.AreEqual( + new[] { nameof(CtlPacket), nameof(MsgiPacket), nameof(PclearPacket), nameof(PinitPacket), nameof(PclearPacket) }, + order); + Assert.AreEqual(Game18NConstString.CaptureSuccessful, _session.LastPackets.OfType().Single().Message); + Assert.IsTrue(_session.Character.MapInstance.LastPackets.OfType().Any(e => e.Id == 197)); + } + + [TestMethod] + public async Task WithAPetAlreadyOutTheNewOneStaysInTheList() + { + var alreadyOut = TestMates.Pet(_session.Character.CharacterId, vnum: 200); + _session.Character.Mates[alreadyOut.MateTransportId] = alreadyOut; + + await Catching(); + + Assert.IsFalse(Caught!.IsTeamMember); + Assert.IsFalse(_session.LastPackets.OfType().Any()); + Assert.IsFalse(_session.Character.MapInstance.LastPackets.OfType().Any()); + } + + [TestMethod] + public async Task ASecondCaptureLeavesTheFirstPetOut() + { + await Catching(); + var map = _session.Character.MapInstance; + var second = new MonsterComponentBundle( + map.EcsWorld.CreateMonster(9002, _monster.NpcMonster!, map, 3, 3, 2, 3, 3, false, true, false), map.EcsWorld); + second.Hp = 40; + + await _service.TryCaptureAsync(_session.Character, second, Capture); + + Assert.AreEqual(2, _session.Character.Mates.Count); + Assert.AreEqual(1, _session.Character.Mates.Values.Count(s => s.IsTeamMember)); + } + + [TestMethod] + public async Task AFailedRollSaysSoAndCatchesNothing() + { + _random.Setup(r => r.Next(0, 100)).Returns(95); + + await Catching(); + + Assert.IsNull(Caught); + Assert.AreEqual(Game18NConstString.CaptureFailed, _session.LastPackets.OfType().Single().Message); + } + + [TestMethod] + public async Task AMonsterAboveTheOwnersLevelIsRefused() + { + _session.Character.Level = 1; + var map = _session.Character.MapInstance; + var strong = new NpcMonsterDto { NpcMonsterVNum = 334, Level = 5, MaxHp = 100, Name = _monster.NpcMonster!.Name }; + var monster = new MonsterComponentBundle( + map.EcsWorld.CreateMonster(9003, strong, map, 3, 3, 2, 3, 3, false, true, false), map.EcsWorld); + monster.Hp = 10; + + await _service.TryCaptureAsync(_session.Character, monster, Capture); + + Assert.AreEqual(Game18NConstString.CaptureToHighLevel, _session.LastPackets.OfType().Single().Message); + } + + [TestMethod] + public async Task AHealthyMonsterIsRefused() + { + _monster.Hp = 60; + + await Catching(); + + Assert.IsNull(Caught); + Assert.AreEqual(Game18NConstString.CaptureTooMuchLife, _session.LastPackets.OfType().Single().Message); + } + } +} From f6fd9d203711edc32332e6b867ecfa16c6d95c52 Mon Sep 17 00:00:00 2001 From: nevenit Date: Fri, 16 Oct 2026 05:27:08 +1000 Subject: [PATCH 07/15] fix(mate): pets are saved with the character Co-Authored-By: Claude Opus 5.5 --- .../Services/SaveService/SaveService.cs | 4 +- .../Services/SaveService/SaveServiceTests.cs | 43 ++++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/NosCore.GameObject/Services/SaveService/SaveService.cs b/src/NosCore.GameObject/Services/SaveService/SaveService.cs index e7d03bb8b..e0972c8b1 100644 --- a/src/NosCore.GameObject/Services/SaveService/SaveService.cs +++ b/src/NosCore.GameObject/Services/SaveService/SaveService.cs @@ -5,6 +5,7 @@ // using NosCore.Dao.Interfaces; +using NosCore.GameObject.Services.MateService; using NosCore.Data.Dto; using NosCore.Data.Enumerations.I18N; using NosCore.Data.Enumerations.Map; @@ -27,7 +28,7 @@ public class SaveService(IDao characterDao, IDao characterQuestDao, IDao characterQuestObjectiveDao, IDao respawnDao, ILogger logger, - ILogLanguageLocalizer logLanguage) + ILogLanguageLocalizer logLanguage, IMateService mateService) : ISaveService { public async Task SaveAsync(ClientSession session) @@ -169,6 +170,7 @@ public async Task SaveAsync(ClientSession session) } await respawnDao.TryInsertOrUpdateAsync(character.Respawns); + await mateService.SaveAsync(character.Mates.Values); } catch (Exception e) { diff --git a/test/NosCore.GameObject.Tests/Services/SaveService/SaveServiceTests.cs b/test/NosCore.GameObject.Tests/Services/SaveService/SaveServiceTests.cs index 199cab18e..9d2e25ae3 100644 --- a/test/NosCore.GameObject.Tests/Services/SaveService/SaveServiceTests.cs +++ b/test/NosCore.GameObject.Tests/Services/SaveService/SaveServiceTests.cs @@ -4,6 +4,7 @@ // |_|\__|\__/ |___/ \__/\__/|_|_\___| // +using Mapster; using Microsoft.EntityFrameworkCore; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -76,7 +77,47 @@ public async Task SetupAsync() characterQuestObjectiveDao, respawnDao, NullLogger.Instance, - TestHelpers.Instance.LogLanguageLocalizer); + TestHelpers.Instance.LogLanguageLocalizer, + new NosCore.GameObject.Services.MateService.MateService(TestHelpers.Instance.MateDao, + new List(), + new NosCore.Core.Services.IdService.IdService(2000000), + new NosCore.Algorithm.MateExperienceService.MateExperienceService(), + NullLogger.Instance)); + } + + [TestMethod] + public async Task SavingWritesThePetsCurrentState() + { + var pet = await OwnedPetAsync(); + pet.Loyalty = 2500; + pet.Experience = 9; + + await Service.SaveAsync(Session); + + var row = await TestHelpers.Instance.MateDao.FirstOrDefaultAsync(s => s.MateId == pet.MateId); + Assert.AreEqual(2500, row!.Loyalty); + Assert.AreEqual(9, row.Experience); + } + + [TestMethod] + public async Task SavingTwiceKeepsOneRow() + { + await OwnedPetAsync(); + + await Service.SaveAsync(Session); + await Service.SaveAsync(Session); + + var characterId = Session.Character.CharacterId; + Assert.AreEqual(1, TestHelpers.Instance.MateDao.Where(s => s.CharacterId == characterId)!.Count()); + } + + private async Task OwnedPetAsync() + { + var pet = TestMates.Pet(Session.Character.CharacterId); + var row = await TestHelpers.Instance.MateDao.TryInsertOrUpdateAsync(pet.Adapt()); + pet.MateId = row.MateId; + Session.Character.Mates[pet.MateTransportId] = pet; + return pet; } [TestMethod] From 4b71a9d8f4cf57f8f98e196083ec2bdcfc39a914 Mon Sep 17 00:00:00 2001 From: nevenit Date: Fri, 16 Oct 2026 05:27:52 +1000 Subject: [PATCH 08/15] fix(mate): logging out takes the pet off other players' screens Co-Authored-By: Claude Opus 5.5 --- .../MapDisconnectHandler.cs | 7 ++- .../Networking/MapDisconnectHandlerTests.cs | 60 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 test/NosCore.GameObject.Tests/Networking/MapDisconnectHandlerTests.cs diff --git a/src/NosCore.GameObject/Networking/ClientSession/DisconnectHandlers/MapDisconnectHandler.cs b/src/NosCore.GameObject/Networking/ClientSession/DisconnectHandlers/MapDisconnectHandler.cs index 2e9dcf53b..1f8bc1695 100644 --- a/src/NosCore.GameObject/Networking/ClientSession/DisconnectHandlers/MapDisconnectHandler.cs +++ b/src/NosCore.GameObject/Networking/ClientSession/DisconnectHandlers/MapDisconnectHandler.cs @@ -26,7 +26,12 @@ public async Task HandleDisconnectAsync(ClientSession session) if (session.HasPlayerEntity) { - await session.Character.MapInstance!.SendPacketAsync(session.Character.GenerateOut()); + var mapInstance = session.Character.MapInstance!; + await mapInstance.SendPacketAsync(session.Character.GenerateOut()); + foreach (var mate in session.Character.Mates.Values) + { + await mate.DespawnAsync(mapInstance); + } } } } diff --git a/test/NosCore.GameObject.Tests/Networking/MapDisconnectHandlerTests.cs b/test/NosCore.GameObject.Tests/Networking/MapDisconnectHandlerTests.cs new file mode 100644 index 000000000..bd7efbd4a --- /dev/null +++ b/test/NosCore.GameObject.Tests/Networking/MapDisconnectHandlerTests.cs @@ -0,0 +1,60 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | v / _| +// |_|\__|\__/ |___/ \__/\__/|_|_\___| +// + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NosCore.GameObject.Ecs.Extensions; +using NosCore.GameObject.Networking.ClientSession; +using NosCore.GameObject.Networking.ClientSession.DisconnectHandlers; +using NosCore.Packets.ServerPackets.Entities; +using NosCore.Shared.Enumerations; +using NosCore.Tests.Shared; + +namespace NosCore.GameObject.Tests.Networking +{ + [TestClass] + public class MapDisconnectHandlerTests + { + private ClientSession _session = null!; + + [TestInitialize] + public async Task SetupAsync() + { + await TestHelpers.ResetAsync(); + _session = await TestHelpers.Instance.GenerateSessionAsync(); + } + + [TestMethod] + public async Task LoggingOutTakesThePetOffTheMap() + { + var pet = TestMates.Pet(_session.Character.CharacterId); + _session.Character.Mates[pet.MateTransportId] = pet; + await pet.SpawnAsync(_session.Character, _session.Character.MapInstance, 4, 4); + var map = _session.Character.MapInstance; + map.LastPackets.Clear(); + + await new MapDisconnectHandler().HandleDisconnectAsync(_session); + + Assert.IsTrue(map.LastPackets.OfType() + .Any(s => s.VisualType == VisualType.Npc && s.VisualId == pet.MateTransportId)); + Assert.IsNull(pet.Entity); + } + + [TestMethod] + public async Task APetLeftAtHomeSendsNothingOnLogout() + { + var pet = TestMates.Pet(_session.Character.CharacterId, isTeamMember: false); + _session.Character.Mates[pet.MateTransportId] = pet; + var map = _session.Character.MapInstance; + map.LastPackets.Clear(); + + await new MapDisconnectHandler().HandleDisconnectAsync(_session); + + Assert.IsFalse(map.LastPackets.OfType().Any(s => s.VisualType == VisualType.Npc)); + } + } +} From 03b91b79e17fd04d6db98aa2788619f780dc8cf6 Mon Sep 17 00:00:00 2001 From: nevenit Date: Fri, 16 Oct 2026 05:29:11 +1000 Subject: [PATCH 09/15] docs: manual checks for catching and keeping pets Co-Authored-By: Claude Opus 5.5 --- documentation/manual-test-plan.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/documentation/manual-test-plan.md b/documentation/manual-test-plan.md index b864bbd9e..c2874f1a3 100644 --- a/documentation/manual-test-plan.md +++ b/documentation/manual-test-plan.md @@ -159,6 +159,21 @@ The maximum comes from the parser, so re-run it on an existing database first. - [ ] Sit with a Roasted Herring digesting and let a monster hit you: you stand up and the green numbers stop +### Pets + +Capture is skill 209, which every new character has. Weaken a monster at or below your level to +under half health first. + +- [ ] With no pet out, catch one: "capture successful" shows, the pet appears where the monster + stood, it is in the party panel, and the pet window lists it +- [ ] With a pet already out, catch another: it joins the pet window only +- [ ] A failed roll says the capture failed; a monster above your level or above half health + says why it cannot be caught +- [ ] Open the pet window: the list stays, it does not empty +- [ ] Change map: the pet comes along and stays in the party panel +- [ ] Log out and back in: every pet, its level and loyalty are still there +- [ ] With a second client on the map, log out: your pet disappears from their screen too + ### Food and snacks Lose some HP first, otherwise there is nothing to watch. @@ -202,6 +217,6 @@ Recorded so nobody spends time hunting for them: - **Family experience and the `gidx` family id** — the family feature itself is not merged yet, so neither the corrected curve nor the flattened packet field has a path to the client. -- **Mates and instances** — same, still open. +- **Instances** — same, still open. - **Food and snacks in a miniland** — minilands are created after start-up and never get a life loop, so nothing that ticks per map (regeneration, digestion) runs there yet. From e3f6f18258ce503e679b5aa94ccb822c55e31b7e Mon Sep 17 00:00:00 2001 From: nevenit Date: Fri, 16 Oct 2026 05:43:47 +1000 Subject: [PATCH 10/15] fix(mate): the pet window handler takes the packet the client actually sends Co-Authored-By: Claude Opus 5.5 --- .../Game/NpinfoPacketHandler.cs | 2 +- .../Game/PetWindowPacketHandlerTests.cs | 23 ++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/NosCore.PacketHandlers/Game/NpinfoPacketHandler.cs b/src/NosCore.PacketHandlers/Game/NpinfoPacketHandler.cs index 4544f6216..b1147b2ed 100644 --- a/src/NosCore.PacketHandlers/Game/NpinfoPacketHandler.cs +++ b/src/NosCore.PacketHandlers/Game/NpinfoPacketHandler.cs @@ -8,7 +8,7 @@ using NosCore.GameObject.Ecs.Extensions; using NosCore.GameObject.Infastructure; using NosCore.GameObject.Networking.ClientSession; -using NosCore.Packets.ServerPackets.Player; +using NosCore.Packets.ClientPackets.Player; namespace NosCore.PacketHandlers.Game { diff --git a/test/NosCore.PacketHandlers.Tests/Game/PetWindowPacketHandlerTests.cs b/test/NosCore.PacketHandlers.Tests/Game/PetWindowPacketHandlerTests.cs index 14cae1606..37559d219 100644 --- a/test/NosCore.PacketHandlers.Tests/Game/PetWindowPacketHandlerTests.cs +++ b/test/NosCore.PacketHandlers.Tests/Game/PetWindowPacketHandlerTests.cs @@ -5,13 +5,18 @@ // using System.Linq; +using System.Reflection; using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; using NosCore.GameObject.Networking.ClientSession; using NosCore.PacketHandlers.Game; +using NosCore.Packets; +using NosCore.Packets.Attributes; using NosCore.Packets.ClientPackets; +using NosCore.Packets.Enumerations; +using NosCore.Packets.Interfaces; using NosCore.Packets.ServerPackets.Mates; -using NosCore.Packets.ServerPackets.Player; +using NosCore.Packets.ClientPackets.Player; using NosCore.Packets.ServerPackets.UI; using NosCore.Tests.Shared; @@ -46,6 +51,22 @@ public async Task OpeningThePetWindowResendsTheRoster() Assert.AreEqual(1, sent.OfType().Count()); } + [TestMethod] + public void TheClientsPetWindowRequestReachesTheHandler() + { + // The same type list the world server hands its deserializer. + var packetTypes = typeof(IPacket).Assembly.GetTypes() + .Where(p => p.GetInterfaces().Contains(typeof(IPacket)) + && (p.GetCustomAttribute() == null + || (p.GetCustomAttribute()!.Scopes & Scope.OnLoginScreen) == 0) + && p.IsClass && !p.IsAbstract); + + var packet = new Deserializer(packetTypes).Deserialize("npinfo 0"); + + Assert.IsInstanceOfType(packet, typeof(NpinfoPacketHandler).BaseType!.GetGenericArguments()[0], + "handlers are looked up by the exact type the deserializer produces"); + } + [TestMethod] public async Task OpeningThePetWindowWithoutPetsOnlyClears() { From c5b2be879642439a93fbd4da7c71d2c984f6021c Mon Sep 17 00:00:00 2001 From: nevenit Date: Fri, 16 Oct 2026 05:44:13 +1000 Subject: [PATCH 11/15] chore: comments in touched files describe NosCore in its own terms Co-Authored-By: Claude Opus 5.5 --- .../Ecs/Extensions/PlayerBundleExtensions.cs | 9 +++------ .../Services/BattleService/BattleService.cs | 19 ++++++------------- 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/src/NosCore.GameObject/Ecs/Extensions/PlayerBundleExtensions.cs b/src/NosCore.GameObject/Ecs/Extensions/PlayerBundleExtensions.cs index 5a5448274..0c2fceb04 100644 --- a/src/NosCore.GameObject/Ecs/Extensions/PlayerBundleExtensions.cs +++ b/src/NosCore.GameObject/Ecs/Extensions/PlayerBundleExtensions.cs @@ -391,11 +391,8 @@ public static FdPacket GenerateFd(this PlayerComponentBundle player) }; } - // Mirrors OpenNos's Character.GenerateReqInfo (Character.cs:3433). Returns the tc_info - // packet sent in response to a client req_info on a player target — the right-click - // info card showing class, level, equipped gear rare/upgrade, faction, PvP stats, etc. - // Family / talent / Act4 fields default to 0 because those subsystems aren't wired in - // NosCore yet; once they are, populate from the corresponding character state. + // The right-click info card for a player: class, level, worn gear, faction and PvP stats. + // Family, talent and Act4 fields stay 0 until those systems exist. public static TcInfoPacket GenerateReqInfo(this PlayerComponentBundle player) { var inv = player.InventoryService; @@ -416,7 +413,7 @@ public static TcInfoPacket GenerateReqInfo(this PlayerComponentBundle player) ElementRate = fairy?.Item?.ElementRate ?? 0, Class = player.Class, Gender = player.Gender, - // No family system yet; OpenNos sends "{familyId} {name}({rank})" or "-1 -" when none. + // No family system yet; "-1 -" is how the card reads "no family". Family = "-1 -", ReputationIco = (ExtendedReputationType)GetReputationIcon(player.Reputation), DignityIco = (CharacterDignity)GetDignityIcon(player.Dignity), diff --git a/src/NosCore.GameObject/Services/BattleService/BattleService.cs b/src/NosCore.GameObject/Services/BattleService/BattleService.cs index ca5da48c1..74774ab6b 100644 --- a/src/NosCore.GameObject/Services/BattleService/BattleService.cs +++ b/src/NosCore.GameObject/Services/BattleService/BattleService.cs @@ -61,9 +61,8 @@ public async Task Hit(IAliveEntity origin, IAliveEntity target, HitArguments arg return; } - // Capture skills branch before damage — vanosilla / OpenNos both suppress - // normal damage and SuPacket for skills carrying a CaptureAnimal bcard. - // A fail still consumes the cooldown via ScheduleCooldownReset below. + // A capture deals no damage: CaptureService sends its own animation and outcome, and a + // failed attempt still costs the cooldown below. if (captureService.IsCaptureSkill(skill)) { try @@ -116,11 +115,8 @@ private async Task ProcessHitAsync(IAliveEntity origin, IAliveEntity target, Ski return; } - // Order matters: OpenNos ships stat-to-target BEFORE the SuPacket so the - // client HUD bar settles to the new HP before the damage floater animates. - // Swapping the order makes the bar appear "frozen" even though the packet - // arrived — the client paints the floater first, re-reads its cached HP - // for the bar, then finally applies the stat update out-of-band. + // stat goes to the target before su: the client paints the damage number first and + // re-reads its cached HP for the bar, so a stat arriving later leaves the bar frozen. await SendTargetStatAsync(target).ConfigureAwait(false); await BroadcastHitAsync(origin, target, skill, outcome).ConfigureAwait(false); await BroadcastTargetInfoAsync(target).ConfigureAwait(false); @@ -155,11 +151,8 @@ private async Task ProcessHitAsync(IAliveEntity origin, IAliveEntity target, Ski } } - // The attacked character's own top-left HP/MP HUD reads off `stat`. OpenNos - // emits this unicast to just the target (`ReceiverType.OnlySomeone`) BEFORE - // the SuPacket — BattleEntity.TargetHit builds GenerateStat, broadcasts it to - // only the target, then broadcasts SuPacket to the whole map. We mirror that - // order so the client settles the bar before animating damage. + // The target's own HP/MP bars read off `stat`, which goes to the target alone before the su + // goes to the map, so the bar settles before the damage animates. private static Task SendTargetStatAsync(IAliveEntity target) { if (target is PlayerComponentBundle player) From a325fc2ae07ce6bb2795178887ab4f70e67d922a Mon Sep 17 00:00:00 2001 From: nevenit Date: Fri, 16 Oct 2026 05:45:46 +1000 Subject: [PATCH 12/15] fix(mate): a capture plays its animation, claims the monster at once, and shows the new pet's bar Co-Authored-By: Claude Opus 5.5 --- .../Services/BattleService/CaptureService.cs | 52 ++++++++++---- .../BattleService/CaptureServiceTests.cs | 71 ++++++++++++++++++- 2 files changed, 109 insertions(+), 14 deletions(-) diff --git a/src/NosCore.GameObject/Services/BattleService/CaptureService.cs b/src/NosCore.GameObject/Services/BattleService/CaptureService.cs index 97a9d95da..04d024262 100644 --- a/src/NosCore.GameObject/Services/BattleService/CaptureService.cs +++ b/src/NosCore.GameObject/Services/BattleService/CaptureService.cs @@ -20,6 +20,7 @@ using NosCore.GameObject.Services.MateService; using NosCore.Networking; using NosCore.Packets.Enumerations; +using NosCore.Packets.ServerPackets.Battle; using NosCore.Packets.ServerPackets.Entities; using NosCore.Packets.ServerPackets.Mates; using NosCore.Packets.ServerPackets.UI; @@ -28,8 +29,6 @@ namespace NosCore.GameObject.Services.BattleService { - // A monster can be caught below half health and at or under the player's level. The new pet - // starts fifteen levels below the monster and comes out at once when no other pet is out. public sealed class CaptureService(IDao mateDao, IMateService mateService, IMessageBus messageBus, IClock clock, IRandomProvider random) : ICaptureService { @@ -40,6 +39,8 @@ public sealed class CaptureService(IDao mateDao, IMateService mat private const byte MateStartLevelOffset = 15; private const short NewPetLoyalty = 3000; private const int CaughtEffect = 197; + private const short CaughtAnimation = 15; + private const short EscapedAnimation = 16; public bool IsCaptureSkill(SkillInfo skill) => HasCaptureBCard(skill); @@ -71,9 +72,18 @@ public async Task TryCaptureAsync(IAliveEntity caster, IAliveEntity target, Skil if (random.Next(0, 100) >= rate) { await owner.SendPacketAsync(Message(Game18NConstString.CaptureFailed)); + await monster.MapInstance.SendPacketAsync(Animation(owner, monster, hpPercent, EscapedAnimation)); return; } + // Claimed before anything is awaited, so a second capture or a kill cannot take it as well. + var caughtAt = Animation(owner, monster, hpPercent, CaughtAnimation); + monster.Hp = 0; + monster.IsAlive = false; + monster.HitList.Clear(); + monster.MapInstance.ScheduleRespawn(monster, + clock.GetCurrentInstant().Plus(RespawnTiming.For(monster.NpcMonster))); + var row = await mateDao.TryInsertOrUpdateAsync(new MateDto { CharacterId = owner.CharacterId, @@ -90,7 +100,6 @@ public async Task TryCaptureAsync(IAliveEntity caster, IAliveEntity target, Skil var mate = mateService.Adopt(row, monster.NpcMonster, owner.Mates.Values); owner.Mates[mate.MateTransportId] = mate; - // Action 3 is the value the capture shows; the same ctl goes out when a pet leaves a map. await owner.SendPacketAsync(new CtlPacket { Type = (byte)VisualType.Npc, PetId = mate.MateTransportId, Action = 3, Unknown = 0 @@ -104,25 +113,44 @@ await owner.SendPacketAsync(new CtlPacket await owner.SendPacketsAsync(owner.GenerateMateRoster()); } + await monster.MapInstance.SendPacketAsync(caughtAt); await monster.MapInstance.SendPacketAsync(owner.GenerateEff(CaughtEffect)); - - monster.Hp = 0; - monster.IsAlive = false; - monster.HitList.Clear(); - await monster.MapInstance.SendPacketAsync(new OutPacket { VisualType = VisualType.Monster, VisualId = monster.VisualId }); - - // Same delayed respawn as a kill, without the kill's rewards and death effects. - monster.MapInstance.ScheduleRespawn(monster, - clock.GetCurrentInstant().Plus(RespawnTiming.For(monster.NpcMonster))); + if (mate.IsTeamMember) + { + await owner.SendPacketAsync(mate.GenerateCond()); + await owner.SendPacketAsync(mate.GeneratePst()); + } await messageBus.PublishAsync(new EntityCapturedEvent(monster, owner)); } + private static SuPacket Animation(PlayerComponentBundle owner, MonsterComponentBundle monster, int hpPercent, + short animation) => new() + { + VisualType = VisualType.Player, + VisualId = owner.VisualId, + TargetVisualType = VisualType.Monster, + TargetId = monster.VisualId, + SkillVnum = -1, + SkillCooldown = 0, + AttackAnimation = animation, + SkillEffect = -1, + PositionX = -1, + PositionY = -1, + TargetIsAlive = true, + HpPercentage = (byte)hpPercent, + Damage = 0, + HitMode = SuPacketHitMode.NoDamageSuccess, + SkillTypeMinusOne = 0, + TargetCurrentHp = monster.Hp, + TargetMaxHp = monster.NpcMonster!.MaxHp + }; + private static MsgiPacket Message(Game18NConstString message) => new() { Type = MessageType.Default, Message = message }; diff --git a/test/NosCore.GameObject.Tests/Services/BattleService/CaptureServiceTests.cs b/test/NosCore.GameObject.Tests/Services/BattleService/CaptureServiceTests.cs index fec950e63..fb3466ac9 100644 --- a/test/NosCore.GameObject.Tests/Services/BattleService/CaptureServiceTests.cs +++ b/test/NosCore.GameObject.Tests/Services/BattleService/CaptureServiceTests.cs @@ -11,6 +11,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using NosCore.Algorithm.MateExperienceService; +using NosCore.Dao.Interfaces; using NosCore.Core.Services.IdService; using NosCore.Data.Dto; using NosCore.Data.Enumerations.Buff; @@ -20,6 +21,8 @@ using NosCore.GameObject.Services.BattleService; using NosCore.GameObject.Services.BattleService.Model; using NosCore.Packets.Enumerations; +using NosCore.Packets.ServerPackets.Battle; +using NosCore.Packets.ServerPackets.Parcel; using NosCore.Packets.ServerPackets.Groups; using NosCore.Packets.ServerPackets.Mates; using NosCore.Packets.ServerPackets.Player; @@ -39,6 +42,7 @@ public class CaptureServiceTests private MonsterComponentBundle _monster; private Mock _random = null!; private CaptureService _service = null!; + private MateServiceImpl _mateService = null!; private static readonly SkillInfo Capture = new(209, 16, 150, 15, -1, 0, 0, default, 1, 0, 0, 0, 0, 0, new List @@ -61,9 +65,9 @@ public async Task SetupAsync() _random = new Mock(); _random.Setup(r => r.Next(0, 100)).Returns(0); - var mateService = new MateServiceImpl(TestHelpers.Instance.MateDao, new List { creature }, + _mateService = new MateServiceImpl(TestHelpers.Instance.MateDao, new List { creature }, new IdService(2000000), new MateExperienceService(), NullLogger.Instance); - _service = new CaptureService(TestHelpers.Instance.MateDao, mateService, + _service = new CaptureService(TestHelpers.Instance.MateDao, _mateService, new Mock().Object, TestHelpers.Instance.Clock, _random.Object); map.LastPackets.Clear(); @@ -103,6 +107,69 @@ public async Task ASuccessfulCaptureTellsTheOwnerInTheRecordedOrder() Assert.IsTrue(_session.Character.MapInstance.LastPackets.OfType().Any(e => e.Id == 197)); } + [TestMethod] + public async Task ASuccessfulCapturePlaysTheCaptureAnimation() + { + await Catching(); + + var map = _session.Character.MapInstance.LastPackets.ToList(); + var su = map.OfType().Single(); + Assert.AreEqual(_monster.VisualId, su.TargetId); + Assert.AreEqual(15, su.AttackAnimation); + Assert.AreEqual(-1, su.SkillVnum); + Assert.IsTrue(su.TargetIsAlive, "the recording shows the monster alive in the animation"); + Assert.AreEqual(40, su.HpPercentage); + Assert.AreEqual(SuPacketHitMode.NoDamageSuccess, su.HitMode); + Assert.IsTrue(map.IndexOf(su) < map.FindIndex(p => p is EffectPacket), "the animation comes before the sparkle"); + } + + [TestMethod] + public async Task AFailedRollPlaysTheFailAnimation() + { + _random.Setup(r => r.Next(0, 100)).Returns(95); + + await Catching(); + + Assert.AreEqual(16, _session.Character.MapInstance.LastPackets.OfType().Single().AttackAnimation); + } + + [TestMethod] + public async Task TheNewPetGetsItsBarAndSpeedAfterThePanel() + { + await Catching(); + + var sent = _session.LastPackets.ToList(); + var panel = sent.FindIndex(p => p is PinitPacket); + Assert.IsTrue(sent.FindIndex(p => p is CondPacket c && c.VisualId == Caught!.MateTransportId) > panel); + Assert.IsTrue(sent.FindIndex(p => p is PstPacket b && b.VisualId == Caught!.MateTransportId) > panel); + } + + [TestMethod] + public async Task AMonsterBeingCaughtCannotBeCaughtTwice() + { + // A second capture lands while the first is still writing its row. + var dao = new Mock>(); + CaptureService service = null!; + var reentered = false; + dao.Setup(d => d.TryInsertOrUpdateAsync(It.IsAny())) + .Returns(async (MateDto row) => + { + if (!reentered) + { + reentered = true; + await service.TryCaptureAsync(_session.Character, _monster, Capture); + } + + return await TestHelpers.Instance.MateDao.TryInsertOrUpdateAsync(row); + }); + service = new CaptureService(dao.Object, _mateService, new Mock().Object, + TestHelpers.Instance.Clock, _random.Object); + + await service.TryCaptureAsync(_session.Character, _monster, Capture); + + Assert.AreEqual(1, _session.Character.Mates.Count); + } + [TestMethod] public async Task WithAPetAlreadyOutTheNewOneStaysInTheList() { From d4b628b61fdd5c6bf25ab36217c5d403eff10926 Mon Sep 17 00:00:00 2001 From: nevenit Date: Fri, 16 Oct 2026 05:46:21 +1000 Subject: [PATCH 13/15] fix(group): on a map change the party panel goes out before the members' bars Co-Authored-By: Claude Opus 5.5 --- .../MapChangeService/MapChangeService.cs | 10 ++++----- .../MapChangeService/MapChangeServiceTests.cs | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs b/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs index 19001411e..47b22b5b1 100644 --- a/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs +++ b/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs @@ -210,11 +210,6 @@ position with if (group != null) { - if (!group.IsEmpty) - { - await session.SendPacketsAsync(group.GeneratePst()); - } - if (group.Type == GroupType.Group && group.Count > 1) { await newMapInstance.SendPacketAsync(group.GeneratePidx(character)); @@ -271,6 +266,11 @@ await session.SendPacketsAsync(otherCharacter.Mates.Values } await session.SendPacketAsync(character.GeneratePinit()); + if (group is { IsEmpty: false }) + { + await session.SendPacketsAsync(group.GeneratePst()); + } + await session.SendPacketsAsync(teamMates.Select(s => s.GenerateCond())); await session.SendPacketsAsync(teamMates.Select(s => s.GeneratePst())); diff --git a/test/NosCore.GameObject.Tests/Services/MapChangeService/MapChangeServiceTests.cs b/test/NosCore.GameObject.Tests/Services/MapChangeService/MapChangeServiceTests.cs index e4b25dba3..1c79f68df 100644 --- a/test/NosCore.GameObject.Tests/Services/MapChangeService/MapChangeServiceTests.cs +++ b/test/NosCore.GameObject.Tests/Services/MapChangeService/MapChangeServiceTests.cs @@ -15,6 +15,11 @@ using NosCore.GameObject.Services.MinilandService; using NosCore.Tests.Shared; using NosCore.Packets.ServerPackets.Groups; +using NosCore.Packets.ServerPackets.Parcel; +using NosCore.Data.Enumerations.Group; +using NosCore.GameObject.Ecs.Extensions; +using NosCore.GameObject.Services.GroupService; +using NosCore.Shared.Enumerations; using System.Linq; using Microsoft.Extensions.Logging; using SpecLight; @@ -157,6 +162,23 @@ public async Task AMapChangeListsThePetInThePartyPanel() Assert.IsNotNull(pet.Entity, "the pet must be on the new map before the panel names it"); } + [TestMethod] + public async Task InAGroupThePanelArrivesBeforeTheMembersBars() + { + var other = await TestHelpers.Instance.GenerateSessionAsync(); + var group = new Group(GroupType.Group, TestHelpers.Instance.SessionGroupFactory); + Session.Character.JoinGroup(group); + other.Character.JoinGroup(group); + Session.LastPackets.Clear(); + + await ChangingToBaseMap1(); + + var sent = Session.LastPackets.ToList(); + var panel = sent.FindLastIndex(p => p is PinitPacket); + var firstBar = sent.FindIndex(p => p is PstPacket s && s.Type == VisualType.Player); + Assert.IsTrue(panel >= 0 && firstBar > panel, "a pinit rebuilds the panel, so bars sent before it are lost"); + } + private async Task ChangingToBaseMap1() { var map1 = TestHelpers.Instance.MapInstanceAccessorService.GetBaseMapById(1)!; From c8255cc332584fa1d4ef30b8d98c1ab0e19e098b Mon Sep 17 00:00:00 2001 From: nevenit Date: Fri, 16 Oct 2026 06:41:26 +1000 Subject: [PATCH 14/15] fix(battle): Capture and the other active skills with an upgrade field resolve again Every class's Capture skill carries a non-zero UpgradeSkill without being an upgrade, so the resolver cancelled every capture cast. Co-Authored-By: Claude Opus 5.5 --- .../Services/BattleService/SkillResolver.cs | 10 +++++-- .../BattleService/SkillResolverTests.cs | 28 +++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/NosCore.GameObject/Services/BattleService/SkillResolver.cs b/src/NosCore.GameObject/Services/BattleService/SkillResolver.cs index ba08c482b..8d0804cda 100644 --- a/src/NosCore.GameObject/Services/BattleService/SkillResolver.cs +++ b/src/NosCore.GameObject/Services/BattleService/SkillResolver.cs @@ -49,9 +49,13 @@ public SkillResolver(IReadOnlyDictionary skillsByVnum, INpcComb { // The same skill family can have multiple upgrades; client sends the base CastId // and the server picks the highest-tier version the character owns. - var match = character.Skills.Values - .Where(s => s.Skill != null && s.Skill.CastId == castId && s.Skill.UpgradeSkill == 0) - .FirstOrDefault(); + // Upgrades are type 2. A few active skills, every class's Capture among them, carry a + // non-zero UpgradeSkill without being one, so they are only taken when no base skill is. + var owned = character.Skills.Values + .Where(s => s.Skill != null && s.Skill.CastId == castId) + .ToList(); + var match = owned.FirstOrDefault(s => s.Skill!.UpgradeSkill == 0) + ?? owned.FirstOrDefault(s => s.Skill!.SkillType != 2); if (match?.Skill == null) return null; var upgrade = character.Skills.Values diff --git a/test/NosCore.GameObject.Tests/Services/BattleService/SkillResolverTests.cs b/test/NosCore.GameObject.Tests/Services/BattleService/SkillResolverTests.cs index 57d7899ca..fd77c75f9 100644 --- a/test/NosCore.GameObject.Tests/Services/BattleService/SkillResolverTests.cs +++ b/test/NosCore.GameObject.Tests/Services/BattleService/SkillResolverTests.cs @@ -68,6 +68,34 @@ public void CharacterResolvesByCastId() Assert.AreEqual(TargetHitType.AoeTargetHit, resolved.HitType); } + [TestMethod] + public void CaptureResolvesThoughItsDataNamesAnUpgradeField() + { + // Every class's Capture skill carries a non-zero UpgradeSkill without being an upgrade. + var capture = new SkillDto { SkillVNum = 209, CastId = 16, UpgradeSkill = 3, SkillType = 1 }; + var character = new Mock(); + var skills = new ConcurrentDictionary(); + skills.TryAdd(100, new CharacterSkill { SkillVNum = 100, Skill = _skills[100] }); + skills.TryAdd(209, new CharacterSkill { SkillVNum = 209, Skill = capture }); + character.SetupGet(c => c.Skills).Returns(skills); + + var resolved = new SkillResolver(_skills, _catalog).Resolve(character.Object, 16); + + Assert.AreEqual((short)209, resolved?.SkillVnum); + } + + [TestMethod] + public void AnUpgradeAloneDoesNotStandInForItsBaseSkill() + { + var upgrade = new SkillDto { SkillVNum = 101, CastId = 1, UpgradeSkill = 100, SkillType = 2, Effect = 5 }; + var character = new Mock(); + var skills = new ConcurrentDictionary(); + skills.TryAdd(101, new CharacterSkill { SkillVNum = 101, Skill = upgrade }); + character.SetupGet(c => c.Skills).Returns(skills); + + Assert.IsNull(new SkillResolver(_skills, _catalog).Resolve(character.Object, 1)); + } + [TestMethod] public void CharacterWithoutMatchingCastIdReturnsNull() { From 54c7328a9a37474adf0581405df09771bb162b2a Mon Sep 17 00:00:00 2001 From: nevenit Date: Fri, 16 Oct 2026 17:39:45 +1000 Subject: [PATCH 15/15] fix(mate): a refused capture releases the client instead of locking the character Co-Authored-By: Claude Opus 5.5 --- .../Services/BattleService/CaptureService.cs | 17 ++++++++-- .../BattleService/CaptureServiceTests.cs | 34 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/NosCore.GameObject/Services/BattleService/CaptureService.cs b/src/NosCore.GameObject/Services/BattleService/CaptureService.cs index 04d024262..79dac6019 100644 --- a/src/NosCore.GameObject/Services/BattleService/CaptureService.cs +++ b/src/NosCore.GameObject/Services/BattleService/CaptureService.cs @@ -47,16 +47,24 @@ public bool IsCaptureSkill(SkillInfo skill) => public async Task TryCaptureAsync(IAliveEntity caster, IAliveEntity target, SkillInfo skill) { - if (!HasCaptureBCard(skill) || caster is not PlayerComponentBundle owner - || target is not MonsterComponentBundle monster + if (!HasCaptureBCard(skill) || caster is not PlayerComponentBundle owner) + { + return; + } + + // The client holds the cast until it hears back; a refusal that only explains itself + // leaves the character unable to act. + if (target is not MonsterComponentBundle monster || monster.NpcMonster == null || !monster.IsAlive || monster.MapInstance == null) { + await owner.SendPacketAsync(Cancel(skill)); return; } if (monster.NpcMonster.Level > owner.Level) { await owner.SendPacketAsync(Message(Game18NConstString.CaptureToHighLevel)); + await owner.SendPacketAsync(Cancel(skill)); return; } @@ -65,6 +73,7 @@ public async Task TryCaptureAsync(IAliveEntity caster, IAliveEntity target, Skil if (hpPercent >= CaptureHpThresholdPercent) { await owner.SendPacketAsync(Message(Game18NConstString.CaptureTooMuchLife)); + await owner.SendPacketAsync(Cancel(skill)); return; } @@ -151,6 +160,10 @@ private static SuPacket Animation(PlayerComponentBundle owner, MonsterComponentB TargetMaxHp = monster.NpcMonster!.MaxHp }; + // In a cast cancel the TargetId field carries the cast id. + private static CancelPacket Cancel(SkillInfo skill) => + new() { Type = CancelPacketType.CancelCast, TargetId = skill.CastId, Unknow = -1 }; + private static MsgiPacket Message(Game18NConstString message) => new() { Type = MessageType.Default, Message = message }; diff --git a/test/NosCore.GameObject.Tests/Services/BattleService/CaptureServiceTests.cs b/test/NosCore.GameObject.Tests/Services/BattleService/CaptureServiceTests.cs index fb3466ac9..577e09ec7 100644 --- a/test/NosCore.GameObject.Tests/Services/BattleService/CaptureServiceTests.cs +++ b/test/NosCore.GameObject.Tests/Services/BattleService/CaptureServiceTests.cs @@ -170,6 +170,39 @@ public async Task AMonsterBeingCaughtCannotBeCaughtTwice() Assert.AreEqual(1, _session.Character.Mates.Count); } + [TestMethod] + public async Task ARefusedCaptureReleasesTheClient() + { + _monster.Hp = 60; + + await Catching(); + + var sent = _session.LastPackets.ToList(); + var cancel = sent.OfType().Single(); + Assert.AreEqual(CancelPacketType.CancelCast, cancel.Type); + Assert.AreEqual(16, cancel.TargetId, "the recording's refusal names the skill's cast id"); + Assert.AreEqual(-1, cancel.Unknow); + Assert.IsTrue(sent.IndexOf(cancel) > sent.FindIndex(p => p is MsgiPacket), "the reason comes first"); + } + + [TestMethod] + public async Task CapturingSomethingThatIsNotAMonsterReleasesTheClient() + { + var other = await TestHelpers.Instance.GenerateSessionAsync(); + + await _service.TryCaptureAsync(_session.Character, other.Character, Capture); + + Assert.AreEqual(CancelPacketType.CancelCast, _session.LastPackets.OfType().Single().Type); + } + + [TestMethod] + public async Task ACaptureThatGoesOffIsNotCancelled() + { + await Catching(); + + Assert.IsFalse(_session.LastPackets.OfType().Any(), "the animation already resolves the cast"); + } + [TestMethod] public async Task WithAPetAlreadyOutTheNewOneStaysInTheList() { @@ -222,6 +255,7 @@ public async Task AMonsterAboveTheOwnersLevelIsRefused() await _service.TryCaptureAsync(_session.Character, monster, Capture); Assert.AreEqual(Game18NConstString.CaptureToHighLevel, _session.LastPackets.OfType().Single().Message); + Assert.IsTrue(_session.LastPackets.OfType().Any(), "a refused cast must release the client"); } [TestMethod]