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. diff --git a/src/NosCore.GameObject/Ecs/Extensions/CharacterEntityExtension.cs b/src/NosCore.GameObject/Ecs/Extensions/CharacterEntityExtension.cs index faf927262..fce1afac8 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; @@ -446,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/MateExtensions.cs b/src/NosCore.GameObject/Ecs/Extensions/MateExtensions.cs index 85ef3b081..46938f99e 100644 --- a/src/NosCore.GameObject/Ecs/Extensions/MateExtensions.cs +++ b/src/NosCore.GameObject/Ecs/Extensions/MateExtensions.cs @@ -5,15 +5,19 @@ // using NosCore.Data.Enumerations.Character; +using NosCore.GameObject.Services.MapInstanceGenerationService; 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; using NosCore.Packets.ServerPackets.Visibility; +using NosCore.Networking; using NosCore.Shared.Enumerations; using System.Globalization; +using System.Threading.Tasks; namespace NosCore.GameObject.Ecs.Extensions { @@ -136,6 +140,45 @@ 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 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/Ecs/Extensions/PlayerBundleExtensions.cs b/src/NosCore.GameObject/Ecs/Extensions/PlayerBundleExtensions.cs index 485618e12..0c2fceb04 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) @@ -233,10 +238,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 @@ -386,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; @@ -411,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/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/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) diff --git a/src/NosCore.GameObject/Services/BattleService/CaptureService.cs b/src/NosCore.GameObject/Services/BattleService/CaptureService.cs index d55295c56..79dac6019 100644 --- a/src/NosCore.GameObject/Services/BattleService/CaptureService.cs +++ b/src/NosCore.GameObject/Services/BattleService/CaptureService.cs @@ -13,114 +13,160 @@ 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.Battle; 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 + 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; + private const short CaughtAnimation = 15; + private const short EscapedAnimation = 16; 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) { return; } - if (caster is not ICharacterEntity character) + // 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 (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)); + await owner.SendPacketAsync(Cancel(skill)); 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)); + await owner.SendPacketAsync(Cancel(skill)); 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)); + await monster.MapInstance.SendPacketAsync(Animation(owner, monster, hpPercent, EscapedAnimation)); return; } - var mateLevel = (byte)System.Math.Max(1, monster.NpcMonster.Level - MateStartLevelOffset); + // 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))); - 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; - monster.Hp = 0; - monster.IsAlive = false; - monster.HitList.Clear(); + 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(caughtAt); + await monster.MapInstance.SendPacketAsync(owner.GenerateEff(CaughtEffect)); 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. - 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, character)).ConfigureAwait(false); + 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 + }; + + // 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 }; + private static bool HasCaptureBCard(SkillInfo skill) => skill.BCards.Any(b => b.Effect() == BCardEffect.CaptureCaptureAnimal); } 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/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs b/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs index 82c60d1e8..47b22b5b1 100644 --- a/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs +++ b/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs @@ -210,12 +210,6 @@ position with if (group != null) { - await session.SendPacketAsync(group.GeneratePinit()); - if (!group.IsEmpty) - { - await session.SendPacketsAsync(group.GeneratePst()); - } - if (group.Type == GroupType.Group && group.Count > 1) { await newMapInstance.SendPacketAsync(group.GeneratePidx(character)); @@ -268,20 +262,13 @@ 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); + await mate.SpawnAsync(character, newMapInstance, mate.PositionX, mate.PositionY); } - var mateSpawns = teamMates.Select(s => s.GenerateIn(accountLanguage)).ToList(); - if (invisible) + await session.SendPacketAsync(character.GeneratePinit()); + if (group is { IsEmpty: false }) { - await session.SendPacketsAsync(mateSpawns); - } - else - { - await newMapInstance.SendPacketsAsync(mateSpawns); + await session.SendPacketsAsync(group.GeneratePst()); } await session.SendPacketsAsync(teamMates.Select(s => s.GenerateCond())); @@ -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/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/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/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..8bfa433a0 100644 --- a/src/NosCore.PacketHandlers/Game/GameStartPacketHandler.cs +++ b/src/NosCore.PacketHandlers/Game/GameStartPacketHandler.cs @@ -145,11 +145,10 @@ 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.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/Game/NpinfoPacketHandler.cs b/src/NosCore.PacketHandlers/Game/NpinfoPacketHandler.cs index d9fe09f80..b1147b2ed 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; +using NosCore.Packets.ClientPackets.Player; 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/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/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)); + } + } +} 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..577e09ec7 --- /dev/null +++ b/test/NosCore.GameObject.Tests/Services/BattleService/CaptureServiceTests.cs @@ -0,0 +1,272 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | 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.Dao.Interfaces; +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.Battle; +using NosCore.Packets.ServerPackets.Parcel; +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 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 + { + 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); + _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 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 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() + { + 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); + Assert.IsTrue(_session.LastPackets.OfType().Any(), "a refused cast must release the client"); + } + + [TestMethod] + public async Task AHealthyMonsterIsRefused() + { + _monster.Hp = 60; + + await Catching(); + + Assert.IsNull(Caught); + Assert.AreEqual(Game18NConstString.CaptureTooMuchLife, _session.LastPackets.OfType().Single().Message); + } + } +} 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() { diff --git a/test/NosCore.GameObject.Tests/Services/MapChangeService/MapChangeServiceTests.cs b/test/NosCore.GameObject.Tests/Services/MapChangeService/MapChangeServiceTests.cs index 1a88dfa7c..1c79f68df 100644 --- a/test/NosCore.GameObject.Tests/Services/MapChangeService/MapChangeServiceTests.cs +++ b/test/NosCore.GameObject.Tests/Services/MapChangeService/MapChangeServiceTests.cs @@ -14,6 +14,13 @@ using NosCore.GameObject.Services.MapChangeService; 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; using System.Threading.Tasks; @@ -142,6 +149,36 @@ 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"); + } + + [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)!; 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.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"); + } } } 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); + } + } +} 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] 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); diff --git a/test/NosCore.PacketHandlers.Tests/Game/PetWindowPacketHandlerTests.cs b/test/NosCore.PacketHandlers.Tests/Game/PetWindowPacketHandlerTests.cs new file mode 100644 index 000000000..37559d219 --- /dev/null +++ b/test/NosCore.PacketHandlers.Tests/Game/PetWindowPacketHandlerTests.cs @@ -0,0 +1,87 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | v / _| +// |_|\__|\__/ |___/ \__/\__/|_|_\___| +// + +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.ClientPackets.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 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() + { + 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"); + } + } +} 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 } + }; + } + } +}