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>
{