diff --git a/src/NosCore.GameObject/Ecs/Components/MateStateComponent.cs b/src/NosCore.GameObject/Ecs/Components/MateStateComponent.cs new file mode 100644 index 000000000..dc9dc1bde --- /dev/null +++ b/src/NosCore.GameObject/Ecs/Components/MateStateComponent.cs @@ -0,0 +1,15 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | v / _| +// |_|\__|\__/ |___/ \__/\__/|_|_\___| +// + +using NosCore.GameObject.Services.MateService; + +namespace NosCore.GameObject.Ecs.Components; + +/// +/// What makes an entity somebody's mate rather than a monster: the stored row it came from and +/// the character it belongs to. +/// +public record struct MateStateComponent(Mate Mate, long OwnerId); diff --git a/src/NosCore.GameObject/Ecs/Components/PlayerMatesComponent.cs b/src/NosCore.GameObject/Ecs/Components/PlayerMatesComponent.cs new file mode 100644 index 000000000..ce0ef93bd --- /dev/null +++ b/src/NosCore.GameObject/Ecs/Components/PlayerMatesComponent.cs @@ -0,0 +1,12 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | v / _| +// |_|\__|\__/ |___/ \__/\__/|_|_\___| +// + +using NosCore.GameObject.Services.MateService; +using System.Collections.Concurrent; + +namespace NosCore.GameObject.Ecs.Components; + +public record struct PlayerMatesComponent(ConcurrentDictionary Mates); diff --git a/src/NosCore.GameObject/Ecs/Extensions/MateExtensions.cs b/src/NosCore.GameObject/Ecs/Extensions/MateExtensions.cs new file mode 100644 index 000000000..67222d743 --- /dev/null +++ b/src/NosCore.GameObject/Ecs/Extensions/MateExtensions.cs @@ -0,0 +1,206 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | v / _| +// |_|\__|\__/ |___/ \__/\__/|_|_\___| +// + +using NosCore.Data.Enumerations.Character; +using NosCore.GameObject.Services.MateService; +using NosCore.Packets.Enumerations; +using NosCore.Packets.ServerPackets.Entities; +using NosCore.Packets.ServerPackets.Mates; +using NosCore.Packets.ServerPackets.Parcel; +using NosCore.Packets.ServerPackets.Player; +using NosCore.Packets.ServerPackets.Visibility; +using NosCore.Shared.Enumerations; +using System.Globalization; + +namespace NosCore.GameObject.Ecs.Extensions +{ + public static class MateExtensions + { + public static ScpPacket GenerateScp(this Mate mate, RegionType language) + { + return new ScpPacket + { + PetId = mate.PetSlot, + NpcMonsterVNum = mate.VNum, + TransportId = mate.MateTransportId, + Level = mate.Level, + Loyalty = mate.Loyalty, + Experience = mate.Experience, + Unknow1 = 0, + AttackUpgrade = mate.NpcMonster.AttackUpgrade, + DamageMinimum = mate.NpcMonster.DamageMinimum, + DamageMaximum = mate.NpcMonster.DamageMaximum, + Concentrate = mate.NpcMonster.Concentrate, + CriticalChance = mate.NpcMonster.CriticalChance, + CriticalRate = mate.NpcMonster.CriticalRate, + DefenceUpgrade = mate.NpcMonster.DefenceUpgrade, + CloseDefence = mate.NpcMonster.CloseDefence, + DefenceDodge = mate.NpcMonster.DefenceDodge, + DistanceDefence = mate.NpcMonster.DistanceDefence, + DistanceDefenceDodge = mate.NpcMonster.DistanceDefenceDodge, + MagicDefence = mate.NpcMonster.MagicDefence, + Element = mate.NpcMonster.Element, + FireResistance = mate.NpcMonster.FireResistance, + WaterResistance = mate.NpcMonster.WaterResistance, + LightResistance = mate.NpcMonster.LightResistance, + DarkResistance = mate.NpcMonster.DarkResistance, + Hp = mate.Hp, + MaxHp = mate.MaxHp, + Mp = mate.Mp, + MaxMp = mate.MaxMp, + IsTeamMember = mate.IsTeamMember, + XpLoad = mate.XpLoad, + CanPickUp = mate.CanPickUp, + Name = mate.DisplayName(language), + IsSummonable = mate.IsSummonable + }; + } + + public static ScnPacket GenerateScn(this Mate mate, RegionType language) + { + return new ScnPacket + { + PetId = mate.PetSlot, + NpcMonsterVNum = mate.VNum, + TransportId = mate.MateTransportId, + Level = mate.Level, + Loyalty = mate.Loyalty, + Experience = mate.Experience, + WeaponInstanceDetails = EmptySlot, + ArmorInstanceDetails = EmptySlot, + GauntletInstanceDetails = EmptySlot, + BootsInstanceDetails = EmptySlot, + AttackUpgrade = mate.NpcMonster.AttackUpgrade, + MinimumAttack = mate.NpcMonster.DamageMinimum, + MaximumAttack = mate.NpcMonster.DamageMaximum, + Precision = mate.NpcMonster.Concentrate, + CriticalRate = mate.NpcMonster.CriticalChance, + CriticalDamageRate = mate.NpcMonster.CriticalRate, + DefenceUpgrade = mate.NpcMonster.DefenceUpgrade, + Defence = mate.NpcMonster.CloseDefence, + DefenceDodge = mate.NpcMonster.DefenceDodge, + DistanceDefence = mate.NpcMonster.DistanceDefence, + DistanceDodge = mate.NpcMonster.DistanceDefenceDodge, + DodgeRate = mate.NpcMonster.MagicDefence, + ElementRate = mate.NpcMonster.Element, + FireResistance = mate.NpcMonster.FireResistance, + WaterResistance = mate.NpcMonster.WaterResistance, + LightResistance = mate.NpcMonster.LightResistance, + DarkResistance = mate.NpcMonster.DarkResistance, + Hp = mate.Hp, + HpMax = mate.MaxHp, + Mp = mate.Mp, + MpMax = mate.MaxMp, + IsTeamMember = mate.IsTeamMember, + LevelXp = (int)mate.XpLoad, + Name = mate.DisplayName(language), + MorphId = mate.Skin != 0 ? mate.Skin : -1, + IsSummonable = mate.IsSummonable, + SpDetails = null, + Skill1Details = null, + Skill2Details = null, + Skill3Details = null + }; + } + + /// + /// in 2 1506 445562 26 26 2 100 100 0 0 3 626114 1 0 -1 Ratufu^pirate^(Feu) + /// + public static InPacket GenerateIn(this Mate mate, RegionType language) + { + return new InPacket + { + VisualType = VisualType.Npc, + VNum = mate.VNum.ToString(CultureInfo.InvariantCulture), + VisualId = mate.MateTransportId, + PositionX = mate.PositionX, + PositionY = mate.PositionY, + Direction = mate.Direction, + InNonPlayerSubPacket = new InNonPlayerSubPacket + { + InAliveSubPacket = new InAliveSubPacket + { + Hp = Percent(mate.Hp, mate.MaxHp, 100), + Mp = Percent(mate.Mp, mate.MaxMp, 100) + }, + Dialog = 0, + Faction = 0, + GroupEffect = 3, + Owner = mate.CharacterId, + SpawnEffect = SpawnEffectType.NoEffect, + IsSitting = false, + Morph = (short?)(mate.Skin != 0 ? mate.Skin : -1), + Name = mate.DisplayName(language), + Unknow1 = (byte)(mate.MateType == MateType.Partner ? 1 : 0) + } + }; + } + + public static OutPacket GenerateOut(this Mate mate) + { + return new OutPacket + { + VisualType = VisualType.Npc, + VisualId = mate.MateTransportId + }; + } + + /// + /// GroupOrder carries the mate type, not a party position: pst 2 22687 0 100 100 ... + /// + public static PstPacket GeneratePst(this Mate mate) + { + return new PstPacket + { + Type = VisualType.Npc, + VisualId = mate.MateTransportId, + GroupOrder = (int)mate.MateType, + HpLeft = Percent(mate.Hp, mate.MaxHp, 0), + MpLeft = Percent(mate.Mp, mate.MaxMp, 0), + HpLoad = mate.MaxHp, + MpLoad = mate.MaxMp, + Race = 0, + Gender = GenderType.Male, + Morph = 0, + BuffIds = null + }; + } + + public static CondPacket GenerateCond(this Mate mate) + { + return new CondPacket + { + VisualType = VisualType.Npc, + VisualId = mate.MateTransportId, + NoAttack = false, + NoMove = false, + Speed = mate.NpcMonster.Speed + }; + } + + private static string DisplayName(this Mate mate, RegionType language) + { + if (!string.IsNullOrEmpty(mate.Name)) + { + return mate.Name; + } + + return mate.NpcMonster.Name.TryGetValue(language, out var localized) + ? localized + : mate.NpcMonster.Name[RegionType.EN]; + } + + private static int Percent(int current, int maximum, int whenUnknown) => + maximum > 0 ? (int)(current / (float)maximum * 100) : whenUnknown; + + private static ScnPacket.ScEquipmentDetails EmptySlot => new() + { + ItemId = -1, + ItemRare = 0, + ItemUpgrade = 0 + }; + } +} diff --git a/src/NosCore.GameObject/Ecs/MapWorld.cs b/src/NosCore.GameObject/Ecs/MapWorld.cs index f94668425..efe2857ad 100644 --- a/src/NosCore.GameObject/Ecs/MapWorld.cs +++ b/src/NosCore.GameObject/Ecs/MapWorld.cs @@ -137,6 +137,35 @@ public Entity CreateNpc( return entity; } + public Entity CreateMate( + int visualId, + Services.MateService.Mate mate, + MapInstance mapInstance, + short positionX, + short positionY, + byte direction) + { + var now = SystemClock.Instance.GetCurrentInstant(); + return World.Create( + new EntityIdentityComponent(visualId, VisualType.Npc, mate.CharacterId), + new HealthComponent(mate.Hp, mate.MaxHp, true), + new ManaComponent(mate.Mp, mate.MaxMp), + new PositionComponent(positionX, positionY, direction, mapInstance.MapInstanceId), + new VisualComponent(0, 0, 0, 0, false, false, false), + new NpcDataComponent(mate.VNum, mate.NpcMonster.Race, mate.Level, 0, mate.NpcMonster.Speed, 10), + new SpawnComponent(positionX, positionY, false, false), + new EffectComponent(0, 0), + new TimingComponent(now, now), + new NpcStateComponent(mate.NpcMonster, mapInstance, new SemaphoreSlim(1, 1), + new ConcurrentDictionary(), null, null, null, + new Dictionary>(), null, false), + new BuffStateComponent(new ConcurrentDictionary()), + new AggroComponent(VisualType.Object, 0, 0, Instant.MinValue), + new SkillCooldownComponent(new ConcurrentDictionary()), + new MateStateComponent(mate, mate.CharacterId) + ); + } + public Entity CreateMapItem( long visualId, short vNum, @@ -236,11 +265,12 @@ public Entity ClonePlayer( PlayerContextComponent context, PlayerInventoryComponent inventory, PlayerSocialComponent social, - PlayerRequestsComponent requests) + PlayerRequestsComponent requests, + PlayerMatesComponent mates) { return World.Create(identity, health, mana, position, visual, appearance, experience, gold, reputation, sp, name, combat, buffs, player, playerFlags, timing, speed, state, network, - context, inventory, social, requests); + context, inventory, social, requests, mates); } public void DestroyEntity(Entity entity) diff --git a/src/NosCore.GameObject/Ecs/MateComponentBundle.cs b/src/NosCore.GameObject/Ecs/MateComponentBundle.cs new file mode 100644 index 000000000..a58f42586 --- /dev/null +++ b/src/NosCore.GameObject/Ecs/MateComponentBundle.cs @@ -0,0 +1,34 @@ +using NosCore.GameObject.Ecs.Attributes; +using NosCore.GameObject.Ecs.Components; +using NosCore.GameObject.Ecs.Interfaces; + +namespace NosCore.GameObject.Ecs; + +// A mate on the map is a monster that belongs to somebody: it stands, takes hits, carries buffs +// and cooldowns, and dies. Giving it the monster's component set rather than a set of its own +// is what lets the battle service treat it as a combatant without a second notion of one. +[ComponentBundle( + typeof(EntityIdentityComponent), + typeof(HealthComponent), + typeof(ManaComponent), + typeof(PositionComponent), + typeof(VisualComponent), + typeof(NpcDataComponent), + typeof(SpawnComponent), + typeof(EffectComponent), + typeof(TimingComponent), + typeof(NpcStateComponent), + typeof(BuffStateComponent), + typeof(AggroComponent), + typeof(SkillCooldownComponent), + typeof(MateStateComponent) +)] +public readonly partial struct MateComponentBundle : INonPlayableEntity +{ + public Arch.Core.Entity Handle => Entity; + + // A monster answers with the square it spawned on; a mate follows its owner, so the live + // position is the only meaningful one. + public short MapX => PositionX; + public short MapY => PositionY; +} diff --git a/src/NosCore.GameObject/Ecs/PlayerComponentBundle.cs b/src/NosCore.GameObject/Ecs/PlayerComponentBundle.cs index 0113702c8..efc07b9fb 100644 --- a/src/NosCore.GameObject/Ecs/PlayerComponentBundle.cs +++ b/src/NosCore.GameObject/Ecs/PlayerComponentBundle.cs @@ -32,7 +32,8 @@ namespace NosCore.GameObject.Ecs; typeof(PlayerContextComponent), typeof(PlayerInventoryComponent), typeof(PlayerSocialComponent), - typeof(PlayerRequestsComponent) + typeof(PlayerRequestsComponent), + typeof(PlayerMatesComponent) )] public readonly partial struct PlayerComponentBundle : ICharacterEntity { diff --git a/src/NosCore.GameObject/Messaging/Handlers/Mate/MateFollowHandler.cs b/src/NosCore.GameObject/Messaging/Handlers/Mate/MateFollowHandler.cs new file mode 100644 index 000000000..491512079 --- /dev/null +++ b/src/NosCore.GameObject/Messaging/Handlers/Mate/MateFollowHandler.cs @@ -0,0 +1,70 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | v / _| +// |_|\__|\__/ |___/ \__/\__/|_|_\___| +// + +using JetBrains.Annotations; +using NosCore.GameObject.Ecs; +using NosCore.GameObject.Ecs.Extensions; +using NosCore.GameObject.Messaging.Events; +using NosCore.GameObject.Services.MateService; +using NosCore.Networking; +using NosCore.Packets.ServerPackets.Entities; +using NosCore.Shared.Enumerations; +using System.Linq; +using System.Threading.Tasks; + +namespace NosCore.GameObject.Messaging.Handlers.Mate +{ + // Mates follow on the same event the owner's own step publishes. + [UsedImplicitly] + public sealed class MateFollowHandler + { + [UsedImplicitly] + public async Task Handle(CharacterMovedEvent evt) + { + if (evt.Character is not PlayerComponentBundle character) + { + return; + } + + var mates = character.Mates.Values.Where(s => s.IsTeamMember).ToList(); + if (mates.Count == 0) + { + return; + } + + var map = character.MapInstance; + MatePlacement.Arrange(character.PositionX, character.PositionY, map.Map, mates); + + foreach (var mate in mates) + { + // The entity carries the position aggro and range checks read, not the packet. + if (mate.Entity is { } handle) + { + handle.PositionX = mate.PositionX; + handle.PositionY = mate.PositionY; + } + + var move = new MovePacket + { + VisualType = VisualType.Npc, + VisualEntityId = mate.MateTransportId, + MapX = mate.PositionX, + MapY = mate.PositionY, + Speed = mate.NpcMonster.Speed + }; + + // Broadcasting a hidden owner's mates would put the owner back on screen. + if (character.Invisible) + { + await character.SendPacketAsync(move).ConfigureAwait(false); + continue; + } + + await map.SendPacketAsync(move).ConfigureAwait(false); + } + } + } +} diff --git a/src/NosCore.GameObject/Messaging/WolverineDependencyRegistrar.cs b/src/NosCore.GameObject/Messaging/WolverineDependencyRegistrar.cs index 6e989835a..db2f31549 100644 --- a/src/NosCore.GameObject/Messaging/WolverineDependencyRegistrar.cs +++ b/src/NosCore.GameObject/Messaging/WolverineDependencyRegistrar.cs @@ -17,6 +17,7 @@ using NosCore.GameObject.Services.ExchangeService; using NosCore.GameObject.Services.GroupService; using NosCore.GameObject.Services.MapInstanceGenerationService; +using NosCore.GameObject.Services.MateService; using NosCore.GameObject.Services.MinilandService; using NosCore.Networking; using NosCore.Networking.SessionGroup; @@ -27,14 +28,12 @@ namespace NosCore.GameObject.Messaging; // Single source of truth for every GameObject-side registration that both MSDI // (for Wolverine codegen) and Autofac (for runtime resolution) need to see. -// // AutofacServiceProviderFactory.Populate() copies MSDI registrations into the // Autofac container at host-build time, so anything registered here becomes // visible to both: Wolverine at codegen, Autofac at runtime. Registering a // service here and again on the Autofac side duplicates the registration and // is the most common cause of "An item with the same key..." style drift // bugs — so bootstrap and tests both call this and nowhere else. -// // DAO/DbContext side is mirrored separately by PersistenceModule.MirrorTo so // this assembly doesn't have to reference NosCore.Database. public static class WolverineDependencyRegistrar @@ -47,6 +46,7 @@ public static void RegisterDependencies(IServiceCollection services) services.AddSingleton>(_ => new IdService(1)); services.AddSingleton>(_ => new IdService(100000)); services.AddSingleton>(_ => new IdService(1)); + services.AddSingleton>(_ => new IdService(2000000)); // Pathfinder / heuristic — OctileDistance is the standard NosTale grid // metric (diagonal moves cost sqrt(2), orthogonal cost 1). @@ -59,6 +59,7 @@ public static void RegisterDependencies(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // Inter-channel hub clients — one instance per concrete HubClient, each // exposed as all of its implemented interfaces so features that depend on @@ -78,7 +79,6 @@ public static void RegisterDependencies(IServiceCollection services) // the ISingletonService marker interface (implemented by classes that own // shared state: caches, queues, per-entity maps). Everything else is // transient so short-lived handlers don't accidentally share mutable state. - // // Matched suffixes cover the vocabulary we actually use across the codebase: // *Service, *Provider, *Resolver, *Calculator, *Catalog, *Queue, *Ai. // New classes can add a suffix here if they want auto-discovery, or they diff --git a/src/NosCore.GameObject/Services/BattleService/CaptureService.cs b/src/NosCore.GameObject/Services/BattleService/CaptureService.cs index 7d1e584c1..e206c9c24 100644 --- a/src/NosCore.GameObject/Services/BattleService/CaptureService.cs +++ b/src/NosCore.GameObject/Services/BattleService/CaptureService.cs @@ -96,6 +96,11 @@ await mateDao.TryInsertOrUpdateAsync(new MateDto Hp = monster.NpcMonster.MaxHp, Mp = monster.NpcMonster.MaxMp, IsSummonable = true, + // A pet you have just caught walks out beside you; it does not go into storage + // for you to fetch later. Only if the pet slot is already taken does it wait, + // because a character may keep one pet and one partner out at a time. + IsTeamMember = !mateDao.Where(s => s.CharacterId == character.CharacterId + && s.MateType == MateType.Pet && s.IsTeamMember)!.Any() }).ConfigureAwait(false); monster.Hp = 0; diff --git a/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs b/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs index 75908a932..bccd1dae3 100644 --- a/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs +++ b/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs @@ -15,6 +15,7 @@ using NosCore.GameObject.Services.BroadcastService; using NosCore.GameObject.Services.ItemGenerationService.Item; using NosCore.GameObject.Services.MapInstanceAccessService; +using NosCore.GameObject.Services.MateService; using NosCore.GameObject.Services.MapInstanceGenerationService; using NosCore.GameObject.Services.MinilandService; using NosCore.Networking; @@ -123,6 +124,7 @@ public async Task ChangeMapInstanceAsync(ClientSession session, Guid mapInstance var inventory = oldWorld.TryGetComponent(oldEntity) ?? default; var social = oldWorld.TryGetComponent(oldEntity) ?? default; var requests = oldWorld.TryGetComponent(oldEntity) ?? default; + var mates = oldWorld.TryGetComponent(oldEntity) ?? default; if (session.Channel?.Id != null) { @@ -164,7 +166,8 @@ position with context with { MapInstance = newMapInstance }, inventory, social, - requests); + requests, + mates); session.SetPlayerEntity(playerEntity, newMapInstance.EcsWorld); character = session.Character; @@ -226,6 +229,15 @@ await Task.WhenAll(mapSessions.Select(async s => : string.Empty; await session.SendPacketAsync(otherCharacter.GenerateIn(prefix)); + // And whatever is at their heel — unless they are hidden, in which case the + // pet would announce them: its spawn packet names its owner. + if (!otherCharacter.Invisible) + { + await session.SendPacketsAsync(otherCharacter.Mates.Values + .Where(m => m.IsTeamMember) + .Select(m => m.GenerateIn(accountLanguage))); + } + var shop = otherCharacter.Shop; if (shop != null) { @@ -249,6 +261,38 @@ await Task.WhenAll(mapSessions.Select(async s => } } + // The mates arrive with their owner, each on its own walkable square: their + // stored square belongs to whichever map they were last saved on, and reusing + // it here would put a pet through a wall. + var teamMates = character.Mates.Values.Where(s => s.IsTeamMember).ToList(); + MatePlacement.Arrange(character.PositionX, character.PositionY, + newMapInstance.Map, teamMates); + + // The mate becomes a real entity on the map it is standing on: that is what lets + // it be targeted, buffed and killed like anything else that fights. + 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) + { + // A hidden owner keeps their mates to themselves: a visible pet with an + // Owner field on it announces the character it belongs to. + await session.SendPacketsAsync(mateSpawns); + } + else + { + await newMapInstance.SendPacketsAsync(mateSpawns); + } + + await session.SendPacketsAsync(teamMates.Select(s => s.GenerateCond())); + await session.SendPacketsAsync(teamMates.Select(s => s.GeneratePst())); + await messageBus.PublishAsync(new Messaging.Events.MapInstanceEnteredEvent(session, newMapInstance)); } catch (Exception ex) @@ -280,6 +324,16 @@ 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())); + + // The entity belongs to the map being left, so it goes with it. A new one is made + // on arrival; keeping this one would leave a mate standing in a world nobody is in. + foreach (var mate in leaving.Where(s => s.Entity.HasValue)) + { + mapInstance.EcsWorld.DestroyEntity(mate.Entity!.Value.Handle); + mate.Entity = null; + } 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 new file mode 100644 index 000000000..59e3be34d --- /dev/null +++ b/src/NosCore.GameObject/Services/MateService/IMateService.cs @@ -0,0 +1,18 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | v / _| +// |_|\__|\__/ |___/ \__/\__/|_|_\___| +// + +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace NosCore.GameObject.Services.MateService +{ + public interface IMateService + { + Task> LoadAsync(long characterId); + + Task SaveAsync(IEnumerable mates); + } +} diff --git a/src/NosCore.GameObject/Services/MateService/Mate.cs b/src/NosCore.GameObject/Services/MateService/Mate.cs new file mode 100644 index 000000000..d9533cfe2 --- /dev/null +++ b/src/NosCore.GameObject/Services/MateService/Mate.cs @@ -0,0 +1,36 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | v / _| +// |_|\__|\__/ |___/ \__/\__/|_|_\___| +// + +using NosCore.Data.Dto; +using NosCore.Data.StaticEntities; + +namespace NosCore.GameObject.Services.MateService +{ + public class Mate : MateDto + { + public NpcMonsterDto NpcMonster { get; set; } = null!; + + public long MateTransportId { get; set; } + + public byte PetSlot { get; set; } + + /// Where the mate is now; MapX/MapY are where it was saved. + public short PositionX { get; set; } + + /// + public short PositionY { get; set; } + + /// The mate's ECS entity while it is out, null while it is not. + public Ecs.MateComponentBundle? Entity { get; set; } + + public int MaxHp => NpcMonster.MaxHp; + + public int MaxMp => NpcMonster.MaxMp; + + /// Set on load; the curve lives in NosCore.Algorithm. + public long XpLoad { get; set; } + } +} diff --git a/src/NosCore.GameObject/Services/MateService/MatePlacement.cs b/src/NosCore.GameObject/Services/MateService/MatePlacement.cs new file mode 100644 index 000000000..3fca25ef9 --- /dev/null +++ b/src/NosCore.GameObject/Services/MateService/MatePlacement.cs @@ -0,0 +1,54 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | v / _| +// |_|\__|\__/ |___/ \__/\__/|_|_\___| +// + +using NosCore.GameObject.Map; +using System.Collections.Generic; + +namespace NosCore.GameObject.Services.MateService +{ + /// + /// Puts a character's mates around them. + /// + public static class MatePlacement + { + // Tried in order: the first walkable square wins. + private static readonly (short X, short Y)[] Offsets = + [(1, 1), (-1, 1), (1, -1), (-1, -1), (1, 0), (-1, 0), (0, 1), (0, -1)]; + + /// + /// Places every mate on its own walkable square around the owner. Squares are reserved + /// as handed out so a pet and a partner do not stack; with none free the mate stands on + /// the owner. + /// + public static void Arrange(short ownerX, short ownerY, Map.Map map, IEnumerable mates) + { + var taken = new HashSet<(short, short)>(); + foreach (var mate in mates) + { + var spot = Free(ownerX, ownerY, map, taken); + mate.PositionX = spot.X; + mate.PositionY = spot.Y; + taken.Add(spot); + } + } + + private static (short X, short Y) Free(short ownerX, short ownerY, Map.Map map, + HashSet<(short, short)> taken) + { + foreach (var offset in Offsets) + { + var x = (short)(ownerX + offset.X); + var y = (short)(ownerY + offset.Y); + if (!taken.Contains((x, y)) && map.IsWalkable(x, y)) + { + return (x, y); + } + } + + return (ownerX, ownerY); + } + } +} diff --git a/src/NosCore.GameObject/Services/MateService/MateService.cs b/src/NosCore.GameObject/Services/MateService/MateService.cs new file mode 100644 index 000000000..8a34fd9d6 --- /dev/null +++ b/src/NosCore.GameObject/Services/MateService/MateService.cs @@ -0,0 +1,93 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | v / _| +// |_|\__|\__/ |___/ \__/\__/|_|_\___| +// + +using NosCore.GameObject.Ecs.Extensions; +using Mapster; +using Microsoft.Extensions.Logging; +using NosCore.Algorithm.MateExperienceService; +using NosCore.Core.Services.IdService; +using NosCore.Dao.Interfaces; +using NosCore.Data.Dto; +using NosCore.Data.Enumerations.Character; +using NosCore.Data.StaticEntities; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using NosCore.Shared.Enumerations; + +namespace NosCore.GameObject.Services.MateService +{ + public class MateService(IDao mateDao, List npcMonsters, + IIdService mateIdService, IMateExperienceService mateExperienceService, + ILogger logger) : IMateService + { + public Task> LoadAsync(long characterId) + { + var rows = mateDao.Where(s => s.CharacterId == characterId)?.ToList() ?? new List(); + var mates = new List(); + + foreach (var row in rows.OrderBy(s => s.MateId)) + { + var npcMonster = npcMonsters.Find(o => o.NpcMonsterVNum == row.VNum); + if (npcMonster == null) + { + logger.LogWarning("Mate {MateId} refers to unknown NpcMonster {VNum} and was skipped", + row.MateId, row.VNum); + 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); + } + + foreach (var group in mates.GroupBy(s => s.MateType)) + { + byte slot = 0; + var alreadyOut = false; + foreach (var mate in group) + { + mate.PetSlot = slot++; + + // A character keeps one pet and one partner out at a time. Two rows can + // claim the slot — two captures racing, or a database edited by hand — and + // the second would spawn on top of the first with no error anywhere. The + // reader decides, so a bad row costs a mate that stays in the list rather + // than a broken map. + if (!mate.IsTeamMember) + { + continue; + } + + mate.IsTeamMember = !alreadyOut; + alreadyOut = true; + } + } + + return Task.FromResult(mates); + } + + public async Task SaveAsync(IEnumerable mates) + { + foreach (var mate in mates) + { + await mateDao.TryInsertOrUpdateAsync(mate.Adapt()).ConfigureAwait(false); + } + } + + public static IEnumerable GenerateScPackets( + IEnumerable mates, RegionType language) + { + return mates.Select(mate => mate.MateType == MateType.Pet + ? (NosCore.Packets.Interfaces.IPacket)mate.GenerateScp(language) + : mate.GenerateScn(language)); + } + } +} diff --git a/src/NosCore.PacketHandlers/CharacterScreen/SelectPacketHandler.cs b/src/NosCore.PacketHandlers/CharacterScreen/SelectPacketHandler.cs index 705acf6e7..11d022ab0 100644 --- a/src/NosCore.PacketHandlers/CharacterScreen/SelectPacketHandler.cs +++ b/src/NosCore.PacketHandlers/CharacterScreen/SelectPacketHandler.cs @@ -33,6 +33,7 @@ using NosCore.GameObject.Services.ItemGenerationService; using NosCore.GameObject.Messaging.Events; using NosCore.GameObject.Services.MapInstanceAccessService; +using NosCore.GameObject.Services.MateService; using NosCore.GameObject.Services.QuestService; using NosCore.Networking.SessionGroup; using Wolverine; @@ -64,7 +65,8 @@ public class SelectPacketHandler(IDao characterDao, ILogger< IPubSubHub pubSubHub, IClock clock, List items, IHpService hpService, IMpService mpService, ISpeedService speedService, ISessionGroupFactory sessionGroupFactory, - ICharacterInitializationService characterInitializationService, IMessageBus messageBus) + ICharacterInitializationService characterInitializationService, IMessageBus messageBus, + IMateService mateService) : PacketHandler, IWorldPacketHandler { public override async Task ExecuteAsync(SelectPacket packet, ClientSession clientSession) @@ -192,6 +194,8 @@ await pubSubHub.SubscribeAsync(new Subscriber mapInstance.EcsWorld.AddComponent(playerEntity, new PlayerSocialComponent( new ConcurrentDictionary(), null)); + mapInstance.EcsWorld.AddComponent(playerEntity, new PlayerMatesComponent( + new ConcurrentDictionary())); mapInstance.EcsWorld.AddComponent(playerEntity, new PlayerRequestsComponent( new Dictionary> { @@ -244,6 +248,11 @@ await pubSubHub.SubscribeAsync(new Subscriber character.Respawns = respawnDao .Where(s => s.CharacterId == characterId)?.ToList() ?? new List(); + foreach (var mate in await mateService.LoadAsync(characterId).ConfigureAwait(false)) + { + character.Mates[mate.MateTransportId] = mate; + } + await characterInitializationService.InitializeAsync(character); await clientSession.SendPacketAsync(new OkPacket()); diff --git a/src/NosCore.PacketHandlers/Game/GameStartPacketHandler.cs b/src/NosCore.PacketHandlers/Game/GameStartPacketHandler.cs index aa548c49b..c6bfad416 100644 --- a/src/NosCore.PacketHandlers/Game/GameStartPacketHandler.cs +++ b/src/NosCore.PacketHandlers/Game/GameStartPacketHandler.cs @@ -16,6 +16,7 @@ using NosCore.GameObject.InterChannelCommunication.Hubs.PubSub; using NosCore.GameObject.Networking.ClientSession; using NosCore.GameObject.Services.MapChangeService; +using NosCore.GameObject.Services.MateService; using NosCore.GameObject.Services.QuestService; using NosCore.GameObject.Services.SkillService; using NosCore.Packets.ClientPackets.CharacterSelectionScreen; @@ -23,6 +24,7 @@ using NosCore.Packets.Interfaces; using NosCore.Packets.ServerPackets.Chats; using NosCore.Packets.ServerPackets.Quest; +using NosCore.Packets.ServerPackets.Specialists; using NosCore.Packets.ServerPackets.UI; using NosCore.Shared.Enumerations; using System.Linq; @@ -58,7 +60,6 @@ await session.SendPacketAsync(session.Character.GenerateSay("------------------- SayColorType.Yellow)); } - await skillService.LoadSkill(session.Character); await session.SendPacketAsync(session.Character.GenerateTit()); await session.SendPacketAsync(session.Character.GenerateSpPoint(worldConfiguration)); @@ -127,13 +128,6 @@ await session.SendPacketAsync(new SayiPacket session.Character.LoadExpensions(); await session.SendPacketAsync(session.Character.GenerateExts(worldConfiguration)); // Session.SendPacket(Session.Character.GenerateMlinfo()); - await session.SendPacketAsync(new PclearPacket()); - - // Group init even for solo players — the client expects pinit + a self-row pst - // so its party UI is in a known state for later joins/leaves. - await session.SendPacketAsync(session.Character.Group.GeneratePinit()); - await session.SendPacketsAsync(session.Character.Group.GeneratePst()); - // Session.SendPacket("zzim"); await session.SendPacketAsync(new TwkPacket(session.Account.Name, session.Character.Name) { @@ -149,9 +143,19 @@ await session.SendPacketAsync(new TwkPacket(session.Account.Name, session.Charac // // sqst bf // Session.SendPacket("act6"); // Session.SendPacket(Session.Character.GenerateFaction()); - // // MATES - // Session.SendPackets(Session.Character.GenerateScP()); - // Session.SendPackets(Session.Character.GenerateScN()); + // p_clear wipes one panel that holds both the party and the mate list, so the two + // bursts have to follow it rather than straddle it. The capture puts them in this + // order: p_clear, the sc packets, sc_p_stc, then pinit. + await session.SendPacketAsync(new PclearPacket()); + await session.SendPacketsAsync(MateService.GenerateScPackets(session.Character.Mates.Values, session.Character.AccountLanguage)); + await session.SendPacketAsync(new ScPStcPacket { MaxMateCountTenths = 0 }); + + // Party init even for a solo player: the client wants pinit and a self-row pst so + // its party frame is in a known state for later joins and leaves. + await session.SendPacketAsync(session.Character.Group.GeneratePinit()); + await session.SendPacketsAsync(session.Character.Group.GeneratePst()); + await session.SendPacketsAsync(session.Character.Mates.Values + .Where(s => s.IsTeamMember).Select(s => s.GeneratePst())); // Session.Character.GenerateStartupInventory(); await session.SendPacketAsync(session.Character.GenerateGold()); diff --git a/src/NosCore.PacketHandlers/Mates/UpetPacketHandler.cs b/src/NosCore.PacketHandlers/Mates/UpetPacketHandler.cs new file mode 100644 index 000000000..2a4aa8825 --- /dev/null +++ b/src/NosCore.PacketHandlers/Mates/UpetPacketHandler.cs @@ -0,0 +1,74 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | v / _| +// |_|\__|\__/ |___/ \__/\__/|_|_\___| +// + +using Microsoft.Extensions.Logging; +using NosCore.Data.Enumerations.I18N; +using NosCore.GameObject.Ecs.Interfaces; +using NosCore.GameObject.Infastructure; +using NosCore.GameObject.Networking.ClientSession; +using NosCore.GameObject.Services.BattleService; +using NosCore.GameObject.Services.BroadcastService; +using NosCore.Packets.ClientPackets.Mates; +using NosCore.Shared.Enumerations; +using NosCore.Shared.I18N; +using System.Threading.Tasks; + +namespace NosCore.PacketHandlers.Mates +{ + // A mate goes through IBattleService.Hit like anything else that fights - it is an entity + // with a monster's components, so there is no second damage path. + public class UpetPacketHandler( + IBattleService battleService, + ISessionRegistry sessionRegistry, + ILogger logger, + ILogLanguageLocalizer logLanguage) + : PacketHandler, IWorldPacketHandler + { + public override async Task ExecuteAsync(UpetPacket packet, ClientSession session) + { + var character = session.Character; + + // Trusting the id would let a client drive somebody else's pet. + if (!character.Mates.TryGetValue(packet.MateTransportId, out var mate) + || !mate.IsTeamMember + || mate.Entity is not { } attacker) + { + return; + } + + var target = ResolveTarget(packet, session); + if (target == null) + { + return; + } + + // Cast id zero is the creature's own basic attack, read off the NpcMonster. + await battleService.Hit(attacker, target, new HitArguments { SkillId = 0 }) + .ConfigureAwait(false); + } + + private IAliveEntity? ResolveTarget(UpetPacket packet, ClientSession session) + { + var map = session.Character.MapInstance; + IAliveEntity? candidate = packet.TargetType switch + { + VisualType.Player => sessionRegistry.TryGetCharacter(s => s.VisualId == packet.TargetId, out var player) + ? player + : null, + VisualType.Npc => map.FindNpc(s => s.VisualId == packet.TargetId), + VisualType.Monster => map.FindMonster(s => s.VisualId == packet.TargetId), + _ => null + }; + + if (candidate == null) + { + logger.LogError(logLanguage[LogLanguageKey.VISUALENTITY_DOES_NOT_EXIST]); + } + + return candidate; + } + } +} diff --git a/test/NosCore.GameObject.Tests/Services/MateService/MateServiceTests.cs b/test/NosCore.GameObject.Tests/Services/MateService/MateServiceTests.cs new file mode 100644 index 000000000..b89bc4881 --- /dev/null +++ b/test/NosCore.GameObject.Tests/Services/MateService/MateServiceTests.cs @@ -0,0 +1,336 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | v / _| +// |_|\__|\__/ |___/ \__/\__/|_|_\___| +// + +using NosCore.GameObject.Ecs.Extensions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using NosCore.Algorithm.MateExperienceService; +using NosCore.Core.Services.IdService; +using NosCore.Dao.Interfaces; +using NosCore.Data.Dto; +using NosCore.Data.Enumerations.Character; +using NosCore.Data.StaticEntities; +using NosCore.Packets.ServerPackets.Mates; +using NosCore.Shared.Enumerations; +using System.Collections.Generic; +using NosCore.Packets.ServerPackets.Visibility; +using System.Linq; +using System.Threading.Tasks; +using Mate = NosCore.GameObject.Services.MateService.Mate; +using MatePlacement = NosCore.GameObject.Services.MateService.MatePlacement; +using MateServiceImpl = NosCore.GameObject.Services.MateService.MateService; + +namespace NosCore.GameObject.Tests.Services.MateService +{ + [TestClass] + public class MateServiceTests + { + private const short ChickenVNum = 333; + private const short PartnerVNum = 317; + private const long CharacterId = 42; + + private static NpcMonsterDto Creature(short vNum, string name, int maxHp, int maxMp) + { + var i18N = new I18NString(); + i18N[RegionType.EN] = name; + return new NpcMonsterDto + { + NpcMonsterVNum = vNum, + Name = i18N, + Level = 1, + MaxHp = maxHp, + MaxMp = maxMp + }; + } + + private static MateServiceImpl Build(IEnumerable rows, params NpcMonsterDto[] creatures) + { + var dao = new Mock>(); + dao.Setup(s => s.Where(It.IsAny>>())) + .Returns((System.Linq.Expressions.Expression> predicate) => + rows.Where(predicate.Compile())); + + return new MateServiceImpl(dao.Object, creatures.ToList(), + new IdService(2000000), new MateExperienceService(), NullLogger.Instance); + } + + [TestMethod] + public async Task LoadingAttachesTheCreatureAndGivesEachMateItsOwnTransportIdAsync() + { + var service = Build(new[] + { + new MateDto { MateId = 1, CharacterId = CharacterId, VNum = ChickenVNum, MateType = MateType.Pet, Level = 1 }, + new MateDto { MateId = 2, CharacterId = CharacterId, VNum = ChickenVNum, MateType = MateType.Pet, Level = 1 } + }, Creature(ChickenVNum, "Chicken", 157, 10)); + + var mates = await service.LoadAsync(CharacterId); + + Assert.AreEqual(2, mates.Count); + Assert.IsTrue(mates.All(s => s.NpcMonster.NpcMonsterVNum == ChickenVNum), + "a mate without its creature attached cannot say its own name"); + Assert.AreNotEqual(mates[0].MateTransportId, mates[1].MateTransportId, + "two mates sharing a transport id means the client addresses the wrong one"); + } + + [TestMethod] + public async Task PetsAndPartnersAreNumberedSeparatelyFromZeroAsync() + { + var service = Build(new[] + { + new MateDto { MateId = 1, CharacterId = CharacterId, VNum = PartnerVNum, MateType = MateType.Partner }, + new MateDto { MateId = 2, CharacterId = CharacterId, VNum = ChickenVNum, MateType = MateType.Pet }, + new MateDto { MateId = 3, CharacterId = CharacterId, VNum = ChickenVNum, MateType = MateType.Pet } + }, Creature(ChickenVNum, "Chicken", 157, 10), Creature(PartnerVNum, "Bob", 870, 200)); + + var mates = await service.LoadAsync(CharacterId); + + CollectionAssert.AreEqual(new byte[] { 0, 1 }, + mates.Where(s => s.MateType == MateType.Pet).Select(s => s.PetSlot).ToArray()); + CollectionAssert.AreEqual(new byte[] { 0 }, + mates.Where(s => s.MateType == MateType.Partner).Select(s => s.PetSlot).ToArray()); + } + + [TestMethod] + public async Task ARowPointingAtAnUnknownCreatureIsSkippedRatherThanSentAsync() + { + var service = Build(new[] + { + new MateDto { MateId = 1, CharacterId = CharacterId, VNum = 9999, MateType = MateType.Pet } + }, Creature(ChickenVNum, "Chicken", 157, 10)); + + Assert.AreEqual(0, (await service.LoadAsync(CharacterId)).Count); + } + + [TestMethod] + public async Task AnotherCharactersMatesAreNotLoadedAsync() + { + var service = Build(new[] + { + new MateDto { MateId = 1, CharacterId = CharacterId, VNum = ChickenVNum, MateType = MateType.Pet }, + new MateDto { MateId = 2, CharacterId = CharacterId + 1, VNum = ChickenVNum, MateType = MateType.Pet } + }, Creature(ChickenVNum, "Chicken", 157, 10)); + + var mates = await service.LoadAsync(CharacterId); + + Assert.AreEqual(1, mates.Count); + Assert.AreEqual(1L, mates[0].MateId); + } + + [TestMethod] + public async Task PetsGetScpAndPartnersGetScnAsync() + { + var service = Build(new[] + { + new MateDto { MateId = 1, CharacterId = CharacterId, VNum = ChickenVNum, MateType = MateType.Pet }, + new MateDto { MateId = 2, CharacterId = CharacterId, VNum = PartnerVNum, MateType = MateType.Partner } + }, Creature(ChickenVNum, "Chicken", 157, 10), Creature(PartnerVNum, "Bob", 870, 200)); + + var packets = MateServiceImpl + .GenerateScPackets(await service.LoadAsync(CharacterId), RegionType.EN).ToList(); + + Assert.AreEqual(1, packets.OfType().Count()); + Assert.AreEqual(1, packets.OfType().Count()); + } + + [TestMethod] + public async Task TheCreatureNameIsUsedWhenTheMateWasNeverRenamedAsync() + { + var service = Build(new[] + { + new MateDto { MateId = 1, CharacterId = CharacterId, VNum = ChickenVNum, MateType = MateType.Pet } + }, Creature(ChickenVNum, "Joyeux Mouton", 157, 10)); + + var packet = (await service.LoadAsync(CharacterId))[0].GenerateScp(RegionType.EN); + + // The serializer turns the space into a caret on the way out; the packet itself + // carries the name as it is. + Assert.AreEqual("Joyeux Mouton", packet.Name); + } + + [TestMethod] + public async Task ARenamedMateKeepsItsOwnNameAsync() + { + var service = Build(new[] + { + new MateDto { MateId = 1, CharacterId = CharacterId, VNum = ChickenVNum, MateType = MateType.Pet, Name = "Poule" } + }, Creature(ChickenVNum, "Chicken", 157, 10)); + + Assert.AreEqual("Poule", (await service.LoadAsync(CharacterId))[0].GenerateScp(RegionType.EN).Name); + } + + [TestMethod] + public async Task ScpReportsTheExperienceTheCaptureReportsAsync() + { + var service = Build(new[] + { + new MateDto { MateId = 1, CharacterId = CharacterId, VNum = ChickenVNum, MateType = MateType.Pet, Level = 3 } + }, Creature(ChickenVNum, "Chicken", 157, 10)); + + var packet = (await service.LoadAsync(CharacterId))[0].GenerateScp(RegionType.EN); + + Assert.AreEqual(90L, packet.XpLoad); + } + + [TestMethod] + public async Task TheSpawnPacketMarksTheMateAsBelongingToItsOwnerAsync() + { + // in 2 1506 445562 26 26 2 100 100 0 0 3 626114 1 0 -1 Ratufu^pirate^(Feu) 0 -1 ... + // Owner and GroupEffect are what separate a mate from a map npc; without them the + // client draws it as scenery and will not let the owner command it. + var service = Build(new[] + { + new MateDto { MateId = 1, CharacterId = CharacterId, VNum = ChickenVNum, MateType = MateType.Pet, Hp = 78, Mp = 5 } + }, Creature(ChickenVNum, "Chicken", 156, 10)); + + var mate = (await service.LoadAsync(CharacterId))[0]; + mate.PositionX = 26; + mate.PositionY = 26; + var packet = mate.GenerateIn(RegionType.EN); + + Assert.AreEqual(VisualType.Npc, packet.VisualType); + Assert.AreEqual(CharacterId, packet.InNonPlayerSubPacket!.Owner); + Assert.AreEqual(3, packet.InNonPlayerSubPacket.GroupEffect); + Assert.AreEqual(mate.MateTransportId, packet.VisualId); + Assert.AreEqual(26, packet.PositionX); + Assert.AreEqual(50, packet.InNonPlayerSubPacket.InAliveSubPacket!.Hp, + "the spawn carries health as a percentage, not as points"); + } + + [TestMethod] + public async Task APartnerIsFlaggedDifferentlyFromAPetOnSpawnAsync() + { + // Both partners in the capture carry 1 after the name where every pet carries 0. + var service = Build(new[] + { + new MateDto { MateId = 1, CharacterId = CharacterId, VNum = ChickenVNum, MateType = MateType.Pet }, + new MateDto { MateId = 2, CharacterId = CharacterId, VNum = PartnerVNum, MateType = MateType.Partner } + }, Creature(ChickenVNum, "Chicken", 157, 10), Creature(PartnerVNum, "Bob", 870, 200)); + + var mates = await service.LoadAsync(CharacterId); + + Assert.AreEqual(0, mates.Single(s => s.MateType == MateType.Pet) + .GenerateIn(RegionType.EN).InNonPlayerSubPacket!.Unknow1); + Assert.AreEqual(1, mates.Single(s => s.MateType == MateType.Partner) + .GenerateIn(RegionType.EN).InNonPlayerSubPacket!.Unknow1); + } + + [TestMethod] + public async Task TheHealthBarCarriesTheMateTypeWhereAPlayerCarriesAPartyPositionAsync() + { + var service = Build(new[] + { + new MateDto { MateId = 1, CharacterId = CharacterId, VNum = PartnerVNum, MateType = MateType.Partner, Hp = 435, Mp = 100 } + }, Creature(PartnerVNum, "Bob", 870, 200)); + + var packet = (await service.LoadAsync(CharacterId))[0].GeneratePst(); + + Assert.AreEqual(VisualType.Npc, packet.Type); + Assert.AreEqual((int)MateType.Partner, packet.GroupOrder); + Assert.AreEqual(50, packet.HpLeft); + Assert.AreEqual(870, packet.HpLoad); + } + + [TestMethod] + public async Task ADespawnNamesTheSameIdTheSpawnDidAsync() + { + // A mismatch leaves the pet drawn on everybody else's screen, silently. + var service = Build(new[] + { + new MateDto { MateId = 1, CharacterId = CharacterId, VNum = ChickenVNum, MateType = MateType.Pet } + }, Creature(ChickenVNum, "Chicken", 157, 10)); + + var mate = (await service.LoadAsync(CharacterId))[0]; + + Assert.AreEqual(mate.GenerateIn(RegionType.EN).VisualId, mate.GenerateOut().VisualId); + } + + [TestMethod] + public async Task OnlyOneMateOfEachTypeIsEverOutAsync() + { + // Two rows can claim one slot, and the second would spawn on top of the first. + var service = Build(new[] + { + new MateDto { MateId = 1, CharacterId = CharacterId, VNum = ChickenVNum, MateType = MateType.Pet, IsTeamMember = true }, + new MateDto { MateId = 2, CharacterId = CharacterId, VNum = ChickenVNum, MateType = MateType.Pet, IsTeamMember = true }, + new MateDto { MateId = 3, CharacterId = CharacterId, VNum = PartnerVNum, MateType = MateType.Partner, IsTeamMember = true } + }, Creature(ChickenVNum, "Chicken", 157, 10), Creature(PartnerVNum, "Bob", 870, 200)); + + var mates = await service.LoadAsync(CharacterId); + + Assert.AreEqual(1, mates.Count(s => s.MateType == MateType.Pet && s.IsTeamMember)); + Assert.AreEqual(1, mates.Count(s => s.MateType == MateType.Partner && s.IsTeamMember), + "a pet and a partner are two different slots and both may be out"); + Assert.AreEqual(1L, mates.Single(s => s.MateType == MateType.Pet && s.IsTeamMember).MateId, + "the first row keeps the slot, so which mate is out does not change between logins"); + } + + [TestMethod] + public async Task AMateThatIsNotOutStaysInTheListAsync() + { + var service = Build(new[] + { + new MateDto { MateId = 1, CharacterId = CharacterId, VNum = ChickenVNum, MateType = MateType.Pet, IsTeamMember = true }, + new MateDto { MateId = 2, CharacterId = CharacterId, VNum = ChickenVNum, MateType = MateType.Pet, IsTeamMember = true } + }, Creature(ChickenVNum, "Chicken", 157, 10)); + + Assert.AreEqual(2, (await service.LoadAsync(CharacterId)).Count); + } + + private static GameObject.Map.Map OpenGround() + { + return new GameObject.Map.Map + { + MapId = 1, + NameI18NKey = "openGround", + Data = [8, 0, 8, 0, .. new byte[64]] + }; + } + + [TestMethod] + public async Task TwoMatesNeverStandOnTheSameSquareAsync() + { + // A pet and a partner out at once must not share an offset. + var service = Build(new[] + { + new MateDto { MateId = 1, CharacterId = CharacterId, VNum = ChickenVNum, MateType = MateType.Pet, IsTeamMember = true }, + new MateDto { MateId = 2, CharacterId = CharacterId, VNum = PartnerVNum, MateType = MateType.Partner, IsTeamMember = true } + }, Creature(ChickenVNum, "Chicken", 157, 10), Creature(PartnerVNum, "Bob", 870, 200)); + + var mates = await service.LoadAsync(CharacterId); + MatePlacement.Arrange(4, 4, OpenGround(), mates); + + Assert.AreEqual(2, mates.Select(s => (s.PositionX, s.PositionY)).Distinct().Count()); + } + + [TestMethod] + public async Task AMateIsNeverPlacedInsideAWallAsync() + { + var walled = new GameObject.Map.Map + { + MapId = 1, + NameI18NKey = "walled", + // Four by four, everything solid but the two squares on the top row. + Data = [4, 0, 4, 0, + 0, 0, 1, 1, + 1, 1, 1, 1, + 1, 1, 1, 1, + 1, 1, 1, 1] + }; + var service = Build(new[] + { + new MateDto { MateId = 1, CharacterId = CharacterId, VNum = ChickenVNum, MateType = MateType.Pet, IsTeamMember = true } + }, Creature(ChickenVNum, "Chicken", 157, 10)); + + var mates = await service.LoadAsync(CharacterId); + MatePlacement.Arrange(0, 0, walled, mates); + + var mate = mates[0]; + Assert.IsTrue(walled.IsWalkable(mate.PositionX, mate.PositionY), + $"placed at {mate.PositionX},{mate.PositionY}, which is not walkable"); + } + } +} diff --git a/test/NosCore.PacketHandlers.Tests/CharacterScreen/SelectPacketHandlerTests.cs b/test/NosCore.PacketHandlers.Tests/CharacterScreen/SelectPacketHandlerTests.cs index 536b8d186..2d0b068be 100644 --- a/test/NosCore.PacketHandlers.Tests/CharacterScreen/SelectPacketHandlerTests.cs +++ b/test/NosCore.PacketHandlers.Tests/CharacterScreen/SelectPacketHandlerTests.cs @@ -70,7 +70,8 @@ public async Task SetupAsync() new SpeedService(), new Mock().Object, new CharacterInitializationService(), - new Mock().Object); + new Mock().Object, + new Mock().Object); } [TestMethod] diff --git a/test/NosCore.Tests.Shared/TestHelpers.cs b/test/NosCore.Tests.Shared/TestHelpers.cs index a4f320d3f..afe414a3e 100644 --- a/test/NosCore.Tests.Shared/TestHelpers.cs +++ b/test/NosCore.Tests.Shared/TestHelpers.cs @@ -308,7 +308,11 @@ public async Task GenerateSessionAsync(List? pack new SelectPacketHandler(CharacterDao, NullLogger.Instance, NullLoggerFactory.Instance, new Mock().Object, MapInstanceAccessorService, ItemInstanceDao, InventoryItemInstanceDao, StaticBonusDao, new Mock>().Object, new Mock>().Object, new Mock>().Object, new Mock>().Object, - new Mock>().Object, new Mock>().Object, new List(), new List(),WorldConfiguration, Instance.LogLanguageLocalizer, Instance.PubSubHub.Object, Instance.Clock, ItemList, new HpService(), new MpService(), new SpeedService(), SessionGroupFactory, new CharacterInitializationService(), new Mock().Object), + new Mock>().Object, new Mock>().Object, new List(), new List(),WorldConfiguration, Instance.LogLanguageLocalizer, Instance.PubSubHub.Object, Instance.Clock, ItemList, new HpService(), new MpService(), new SpeedService(), SessionGroupFactory, new CharacterInitializationService(), new Mock().Object, + new NosCore.GameObject.Services.MateService.MateService(new Mock>().Object, new List(), + new NosCore.Core.Services.IdService.IdService(2000000), + new NosCore.Algorithm.MateExperienceService.MateExperienceService(), + NullLogger.Instance)), new CSkillPacketHandler(Instance.Clock), new CBuyPacketHandler(new Mock().Object, new Mock().Object, NullLogger.Instance, ItemInstanceDao, Instance.LogLanguageLocalizer), new CRegPacketHandler(WorldConfiguration, new Mock().Object, ItemInstanceDao, InventoryItemInstanceDao), @@ -424,6 +428,8 @@ public async Task GenerateSessionAsync(List? pack mapInstance.EcsWorld.AddComponent(playerEntity, new GameObject.Ecs.Components.PlayerSocialComponent( new ConcurrentDictionary(), null)); + mapInstance.EcsWorld.AddComponent(playerEntity, new GameObject.Ecs.Components.PlayerMatesComponent( + new ConcurrentDictionary())); mapInstance.EcsWorld.AddComponent(playerEntity, new GameObject.Ecs.Components.PlayerRequestsComponent( new Dictionary> {