diff --git a/src/NosCore.Data/Enumerations/I18N/LanguageKey.cs b/src/NosCore.Data/Enumerations/I18N/LanguageKey.cs index b7396ace2..3651d8b90 100644 --- a/src/NosCore.Data/Enumerations/I18N/LanguageKey.cs +++ b/src/NosCore.Data/Enumerations/I18N/LanguageKey.cs @@ -198,6 +198,10 @@ public enum LanguageKey OBJECT_IN_BAZAAR, ITEM_GIFTED, NOT_IN_FRIENDLIST, - SPEAKER + SPEAKER, + FAMILY_AUTHORITY_HEAD, + FAMILY_AUTHORITY_ASSISTANT, + FAMILY_AUTHORITY_MANAGER, + FAMILY_AUTHORITY_MEMBER } } diff --git a/src/NosCore.Data/Resource/LocalizedResources.cs.resx b/src/NosCore.Data/Resource/LocalizedResources.cs.resx index 29d4ffac8..ebe58d190 100644 --- a/src/NosCore.Data/Resource/LocalizedResources.cs.resx +++ b/src/NosCore.Data/Resource/LocalizedResources.cs.resx @@ -557,4 +557,16 @@ Načteno skriptovaných instancí: {0}! + + Hlava + + + Zástupce + + + Správce + + + Člen + \ No newline at end of file diff --git a/src/NosCore.Data/Resource/LocalizedResources.de.resx b/src/NosCore.Data/Resource/LocalizedResources.de.resx index 4aaf486bb..d84d58851 100644 --- a/src/NosCore.Data/Resource/LocalizedResources.de.resx +++ b/src/NosCore.Data/Resource/LocalizedResources.de.resx @@ -580,4 +580,16 @@ {0} Skript-Instanzen geladen! + + Oberhaupt + + + Vertreter + + + Verwalter + + + Mitglied + \ No newline at end of file diff --git a/src/NosCore.Data/Resource/LocalizedResources.es.resx b/src/NosCore.Data/Resource/LocalizedResources.es.resx index 7f9e36fbe..c2220c15d 100644 --- a/src/NosCore.Data/Resource/LocalizedResources.es.resx +++ b/src/NosCore.Data/Resource/LocalizedResources.es.resx @@ -484,4 +484,16 @@ ¡{0} instancias de script analizadas! + + Cabecilla + + + Representante + + + Administrador + + + Miembro + \ No newline at end of file diff --git a/src/NosCore.Data/Resource/LocalizedResources.fr.resx b/src/NosCore.Data/Resource/LocalizedResources.fr.resx index a8545c5a2..41c462f88 100644 --- a/src/NosCore.Data/Resource/LocalizedResources.fr.resx +++ b/src/NosCore.Data/Resource/LocalizedResources.fr.resx @@ -555,4 +555,16 @@ {0} instances scriptées analysées ! + + Tête + + + Assistant + + + Gardien + + + Membre + \ No newline at end of file diff --git a/src/NosCore.Data/Resource/LocalizedResources.it.resx b/src/NosCore.Data/Resource/LocalizedResources.it.resx index fbc84dfc6..f91b66326 100644 --- a/src/NosCore.Data/Resource/LocalizedResources.it.resx +++ b/src/NosCore.Data/Resource/LocalizedResources.it.resx @@ -601,4 +601,16 @@ {0} istanze scriptate caricate! + + Capo + + + Vicecapo + + + Guardiano + + + Membro + diff --git a/src/NosCore.Data/Resource/LocalizedResources.pl.resx b/src/NosCore.Data/Resource/LocalizedResources.pl.resx index 726fe7997..da87d0ba6 100644 --- a/src/NosCore.Data/Resource/LocalizedResources.pl.resx +++ b/src/NosCore.Data/Resource/LocalizedResources.pl.resx @@ -507,4 +507,16 @@ Wczytano {0} instancji skryptowanych! + + Szef + + + Reprezentant + + + Zarządca + + + Członek + \ No newline at end of file diff --git a/src/NosCore.Data/Resource/LocalizedResources.resx b/src/NosCore.Data/Resource/LocalizedResources.resx index 205567ea6..dd74aab18 100644 --- a/src/NosCore.Data/Resource/LocalizedResources.resx +++ b/src/NosCore.Data/Resource/LocalizedResources.resx @@ -631,4 +631,16 @@ {0} Scripted Instances Parsed! + + Familyhead + + + Familydeputy + + + Familykeeper + + + Member + \ No newline at end of file diff --git a/src/NosCore.Data/Resource/LocalizedResources.ru.resx b/src/NosCore.Data/Resource/LocalizedResources.ru.resx index 738eef0ec..df8340b37 100644 --- a/src/NosCore.Data/Resource/LocalizedResources.ru.resx +++ b/src/NosCore.Data/Resource/LocalizedResources.ru.resx @@ -582,4 +582,16 @@ Загружено скриптовых инстансов: {0}! + + Глава семьи + + + Помощник + + + Управляющий + + + Участник + \ No newline at end of file diff --git a/src/NosCore.Data/Resource/LocalizedResources.tr.resx b/src/NosCore.Data/Resource/LocalizedResources.tr.resx index 8b4809386..b44581382 100644 --- a/src/NosCore.Data/Resource/LocalizedResources.tr.resx +++ b/src/NosCore.Data/Resource/LocalizedResources.tr.resx @@ -573,4 +573,16 @@ {0} betikli örnek yüklendi! + + Ailereisi + + + Temsilci + + + Yönetici + + + Üye + \ No newline at end of file diff --git a/src/NosCore.GameObject/Ecs/Components/PlayerSocialComponent.cs b/src/NosCore.GameObject/Ecs/Components/PlayerSocialComponent.cs index a58ed6b34..6383cb6fe 100644 --- a/src/NosCore.GameObject/Ecs/Components/PlayerSocialComponent.cs +++ b/src/NosCore.GameObject/Ecs/Components/PlayerSocialComponent.cs @@ -5,10 +5,12 @@ // using NodaTime; +using NosCore.GameObject.Services.FamilyService; using System.Collections.Concurrent; namespace NosCore.GameObject.Ecs.Components; public record struct PlayerSocialComponent( ConcurrentDictionary GroupRequestCharacterIds, - Instant? LastGroupRequest); + Instant? LastGroupRequest, + Family? Family); diff --git a/src/NosCore.GameObject/Ecs/Extensions/FamilyExtensions.cs b/src/NosCore.GameObject/Ecs/Extensions/FamilyExtensions.cs new file mode 100644 index 000000000..0b0fef41e --- /dev/null +++ b/src/NosCore.GameObject/Ecs/Extensions/FamilyExtensions.cs @@ -0,0 +1,90 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | v / _| +// |_|\__|\__/ |___/ \__/\__/|_|_\___| +// + +using NosCore.Algorithm.FamilyExperienceService; +using NosCore.Core.I18N; +using NosCore.Data.Enumerations.Family; +using NosCore.Data.Enumerations.I18N; +using NosCore.Packets.ServerPackets.Families; +using NosCore.Shared.Enumerations; +using System.Linq; + +namespace NosCore.GameObject.Ecs.Extensions; + +public static class FamilyExtensions +{ + public static GidxPacket GenerateGidx(this PlayerComponentBundle player, + IGameLanguageLocalizer localizer, RegionType viewerLanguage) + { + var family = player.Family; + if (family == null) + { + return new GidxPacket + { + VisualType = VisualType.Player, + VisualId = player.VisualId, + FamilyId = null, + FamilyName = null, + FamilyLevel = 0 + }; + } + + return new GidxPacket + { + VisualType = VisualType.Player, + VisualId = player.VisualId, + FamilyId = family.FamilyId, + FamilyName = FamilyTag(family, player.CharacterId, localizer, viewerLanguage), + FamilyLevel = family.FamilyLevel + }; + } + + public static GInfoPacket? GenerateGInfo(this PlayerComponentBundle player, + IFamilyExperienceService familyExperienceService) + { + var family = player.Family; + if (family == null) + { + return null; + } + + return new GInfoPacket + { + FamilyName = family.Name, + CharacterName = family.HeadCharacterName, + FamilyHeadGenderType = family.FamilyHeadGender, + FamilyLevel = family.FamilyLevel, + FamilyXp = family.FamilyExperience, + MaxFamilyXp = familyExperienceService.GetFamilyExperience(family.FamilyLevel), + MembersCount = (ushort)family.Members.Count, + MembersCapacity = family.MaxSize, + CharacterFamilyAuthority = (Packets.Enumerations.FamilyAuthority)family.AuthorityOf(player.CharacterId), + FamilyManagerCanInvit = family.ManagerCanInvite, + FamilyManagerCanNotice = family.ManagerCanNotice, + FamilyManagerCanShout = family.ManagerCanShout, + FamilyManagerCanGetHistory = family.ManagerCanGetHistory, + FamilyManagerAuthorityType = family.ManagerAuthorityType, + FamilyMemberCanGetHistory = family.MemberCanGetHistory, + FamilyMemberAuthorityType = family.MemberAuthorityType, + // A serialized field keeps its own spaces, and the notice is free text. + FamilyMessage = family.FamilyMessage?.Replace(' ', '^') + }; + } + + private static string FamilyTag(Services.FamilyService.Family family, long characterId, + IGameLanguageLocalizer localizer, RegionType viewerLanguage) + { + var rank = family.AuthorityOf(characterId) switch + { + FamilyAuthority.Head => LanguageKey.FAMILY_AUTHORITY_HEAD, + FamilyAuthority.Assistant => LanguageKey.FAMILY_AUTHORITY_ASSISTANT, + FamilyAuthority.Manager => LanguageKey.FAMILY_AUTHORITY_MANAGER, + _ => LanguageKey.FAMILY_AUTHORITY_MEMBER + }; + + return $"{family.Name}({localizer[rank, viewerLanguage]})".Replace(' ', '^'); + } +} diff --git a/src/NosCore.GameObject/Ecs/Extensions/PlayerBundleExtensions.cs b/src/NosCore.GameObject/Ecs/Extensions/PlayerBundleExtensions.cs index 250a6603b..dd6164d10 100644 --- a/src/NosCore.GameObject/Ecs/Extensions/PlayerBundleExtensions.cs +++ b/src/NosCore.GameObject/Ecs/Extensions/PlayerBundleExtensions.cs @@ -214,7 +214,6 @@ await player.SendPacketAsync(new MsgiPacket }); } - public static InPacket GenerateIn(this PlayerComponentBundle player, string prefix) { return new InPacket @@ -434,8 +433,8 @@ public static CInfoPacket GenerateCInfo(this PlayerComponentBundle player, IGame ? $"[{localizer[LanguageKey.SUPPORT, player.Account.Language]}]" + player.Name : player.Name, Unknown1 = null, GroupId = -1, - FamilyId = -1, - FamilyName = null, + FamilyId = (int)(player.Family?.FamilyId ?? -1), + FamilyName = player.GenerateGidx(localizer, player.AccountLanguage).FamilyName, CharacterId = player.VisualId, Authority = player.Authority, Gender = player.Gender, diff --git a/src/NosCore.GameObject/Messaging/WolverineDependencyRegistrar.cs b/src/NosCore.GameObject/Messaging/WolverineDependencyRegistrar.cs index e2eed4c62..820489b5c 100644 --- a/src/NosCore.GameObject/Messaging/WolverineDependencyRegistrar.cs +++ b/src/NosCore.GameObject/Messaging/WolverineDependencyRegistrar.cs @@ -15,6 +15,7 @@ using NosCore.GameObject.Messaging.Handlers.Nrun; using NosCore.GameObject.Services.BroadcastService; using NosCore.GameObject.Services.ExchangeService; +using NosCore.GameObject.Services.FamilyService; using NosCore.GameObject.Services.GroupService; using NosCore.GameObject.Services.MapInstanceGenerationService; using NosCore.GameObject.Services.MateService; @@ -62,6 +63,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 diff --git a/src/NosCore.GameObject/Services/FamilyService/Family.cs b/src/NosCore.GameObject/Services/FamilyService/Family.cs new file mode 100644 index 000000000..0ead3894b --- /dev/null +++ b/src/NosCore.GameObject/Services/FamilyService/Family.cs @@ -0,0 +1,26 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | v / _| +// |_|\__|\__/ |___/ \__/\__/|_|_\___| +// + +using NosCore.Data.Dto; +using NosCore.Data.Enumerations.Family; +using System.Collections.Generic; +using System.Linq; + +namespace NosCore.GameObject.Services.FamilyService +{ + public class Family : FamilyDto + { + public IReadOnlyList Members { get; set; } = []; + + /// The head's name, kept here because the head is usually offline. + public string HeadCharacterName { get; set; } = string.Empty; + + /// Not in the member list means Member: the packet cannot say "none". + public FamilyAuthority AuthorityOf(long characterId) => + Members.FirstOrDefault(s => s.CharacterId == characterId)?.Authority + ?? FamilyAuthority.Member; + } +} diff --git a/src/NosCore.GameObject/Services/FamilyService/FamilyService.cs b/src/NosCore.GameObject/Services/FamilyService/FamilyService.cs new file mode 100644 index 000000000..d5c3c7b84 --- /dev/null +++ b/src/NosCore.GameObject/Services/FamilyService/FamilyService.cs @@ -0,0 +1,52 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | v / _| +// |_|\__|\__/ |___/ \__/\__/|_|_\___| +// + +using Mapster; +using NosCore.Dao.Interfaces; +using NosCore.Data.Dto; +using NosCore.Data.Enumerations.Family; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace NosCore.GameObject.Services.FamilyService +{ + public class FamilyService(IDao familyDao, + IDao familyCharacterDao, + IDao characterDao) : IFamilyService + { + public async Task GetFamilyAsync(long characterId) + { + var membership = await familyCharacterDao + .FirstOrDefaultAsync(s => s.CharacterId == characterId); + if (membership == null) + { + return null; + } + + var familyDto = await familyDao + .FirstOrDefaultAsync(s => s.FamilyId == membership.FamilyId); + if (familyDto == null) + { + return null; + } + + var family = familyDto.Adapt(); + family.Members = familyCharacterDao.Where(s => s.FamilyId == family.FamilyId)?.ToList() + ?? new List(); + + var head = family.Members.FirstOrDefault(s => s.Authority == FamilyAuthority.Head); + if (head != null) + { + var headCharacter = await characterDao + .FirstOrDefaultAsync(s => s.CharacterId == head.CharacterId); + family.HeadCharacterName = headCharacter?.Name ?? string.Empty; + } + + return family; + } + } +} diff --git a/src/NosCore.GameObject/Services/FamilyService/IFamilyService.cs b/src/NosCore.GameObject/Services/FamilyService/IFamilyService.cs new file mode 100644 index 000000000..7e51c1bf2 --- /dev/null +++ b/src/NosCore.GameObject/Services/FamilyService/IFamilyService.cs @@ -0,0 +1,15 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | v / _| +// |_|\__|\__/ |___/ \__/\__/|_|_\___| +// + +using System.Threading.Tasks; + +namespace NosCore.GameObject.Services.FamilyService +{ + public interface IFamilyService + { + Task GetFamilyAsync(long characterId); + } +} diff --git a/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs b/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs index ce31b1045..63c0fed62 100644 --- a/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs +++ b/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs @@ -228,6 +228,7 @@ await Task.WhenAll(mapSessions.Select(async s => ? $"[{gameLanguageLocalizer[LanguageKey.SUPPORT, otherCharacter.AccountLanguage]}]" : string.Empty; await session.SendPacketAsync(otherCharacter.GenerateIn(prefix)); + await session.SendPacketAsync(otherCharacter.GenerateGidx(gameLanguageLocalizer, accountLanguage)); if (!otherCharacter.Invisible) { @@ -256,6 +257,12 @@ await session.SendPacketsAsync(otherCharacter.Mates.Values ? $"[{gameLanguageLocalizer[LanguageKey.SUPPORT, accountLanguage]}]" : string.Empty; await newMapInstance.SendPacketAsync(character.GenerateIn(prefix), new EveryoneBut(channelId)); + + var watchers = sessionRegistry.GetSessions(s => + s.HasPlayerEntity && s != session && s.Character.MapInstanceId == mapInstanceId); + await Task.WhenAll(watchers.Select(watcher => + watcher.SendPacketAsync(character.GenerateGidx(gameLanguageLocalizer, + watcher.Character.AccountLanguage)))); } } diff --git a/src/NosCore.PacketHandlers/CharacterScreen/SelectPacketHandler.cs b/src/NosCore.PacketHandlers/CharacterScreen/SelectPacketHandler.cs index d01b6e1c4..c97a41e27 100644 --- a/src/NosCore.PacketHandlers/CharacterScreen/SelectPacketHandler.cs +++ b/src/NosCore.PacketHandlers/CharacterScreen/SelectPacketHandler.cs @@ -32,6 +32,7 @@ using NosCore.GameObject.Services.InventoryService; using NosCore.GameObject.Services.ItemGenerationService; using NosCore.GameObject.Messaging.Events; +using NosCore.GameObject.Services.FamilyService; using NosCore.GameObject.Services.MapInstanceAccessService; using NosCore.GameObject.Services.MateService; using NosCore.GameObject.Services.QuestService; @@ -67,7 +68,7 @@ public class SelectPacketHandler(IDao characterDao, ILogger< NosCore.GameObject.Services.BattleService.IVitalityService vitalityService, ISessionGroupFactory sessionGroupFactory, ICharacterInitializationService characterInitializationService, IMessageBus messageBus, - IMateService mateService) + IMateService mateService, IFamilyService familyService) : PacketHandler, IWorldPacketHandler { public override async Task ExecuteAsync(SelectPacket packet, ClientSession clientSession) @@ -194,6 +195,7 @@ await pubSubHub.SubscribeAsync(new Subscriber new List())); mapInstance.EcsWorld.AddComponent(playerEntity, new PlayerSocialComponent( new ConcurrentDictionary(), + null, null)); mapInstance.EcsWorld.AddComponent(playerEntity, new PlayerMatesComponent( new ConcurrentDictionary())); @@ -266,6 +268,9 @@ await pubSubHub.SubscribeAsync(new Subscriber character.Mates[mate.MateTransportId] = mate; } + character.Family = await familyService + .GetFamilyAsync(characterId); + 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 26a61d5ee..4d0003ddd 100644 --- a/src/NosCore.PacketHandlers/Game/GameStartPacketHandler.cs +++ b/src/NosCore.PacketHandlers/Game/GameStartPacketHandler.cs @@ -15,6 +15,8 @@ using NosCore.GameObject.InterChannelCommunication.Hubs.MailHub; using NosCore.GameObject.InterChannelCommunication.Hubs.PubSub; using NosCore.GameObject.Networking.ClientSession; +using NosCore.Algorithm.FamilyExperienceService; +using NosCore.GameObject.Services.FamilyService; using NosCore.GameObject.Services.MapChangeService; using NosCore.GameObject.Services.MateService; using NosCore.GameObject.Services.QuestService; @@ -37,7 +39,8 @@ public class GameStartPacketHandler(IOptions worldConfigurat IChannelHub channelHttpClient, IPubSubHub pubSubHub, IBlacklistHub blacklistHttpClient, ISerializer packetSerializer, IMailHub mailHttpClient, IQuestService questProvider, - IMapChangeService mapChangeService, ISkillService skillService) + IMapChangeService mapChangeService, ISkillService skillService, + IFamilyExperienceService familyExperienceService) : PacketHandler, IWorldPacketHandler { public override async Task ExecuteAsync(GameStartPacket packet, ClientSession session) @@ -155,6 +158,12 @@ await session.SendPacketsAsync(session.Character.Mates.Values .Where(s => s.IsTeamMember).Select(s => s.GeneratePst())); // Session.Character.GenerateStartupInventory(); + var familyInfo = session.Character.GenerateGInfo(familyExperienceService); + if (familyInfo != null) + { + await session.SendPacketAsync(familyInfo); + } + await session.SendPacketAsync(session.Character.GenerateGold()); await session.SendPacketAsync(session.Character.GenerateCond()); diff --git a/test/NosCore.GameObject.Tests/Services/FamilyService/FamilyPacketTests.cs b/test/NosCore.GameObject.Tests/Services/FamilyService/FamilyPacketTests.cs new file mode 100644 index 000000000..439e8470b --- /dev/null +++ b/test/NosCore.GameObject.Tests/Services/FamilyService/FamilyPacketTests.cs @@ -0,0 +1,192 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | v / _| +// |_|\__|\__/ |___/ \__/\__/|_|_\___| +// + +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NosCore.Algorithm.FamilyExperienceService; +using NosCore.Data.Dto; +using NosCore.Data.Enumerations.Family; +using NosCore.GameObject.Ecs.Extensions; +using NosCore.GameObject.Networking.ClientSession; +using NosCore.Packets; +using NosCore.Shared.Enumerations; +using NosCore.Packets.Interfaces; +using NosCore.Tests.Shared; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Family = NosCore.GameObject.Services.FamilyService.Family; +using GenderType = NosCore.Packets.Enumerations.GenderType; + +namespace NosCore.GameObject.Tests.Services.FamilyService +{ + [TestClass] + public class FamilyPacketTests + { + private ClientSession _session = null!; + + [TestInitialize] + public async Task SetupAsync() + { + await TestHelpers.ResetAsync(); + _session = await TestHelpers.Instance.GenerateSessionAsync(); + } + + private static Family Nemesis(long characterId, FamilyAuthority authority = FamilyAuthority.Member) + { + var family = new Family + { + FamilyId = 5052, + Name = "-Nemesis-", + FamilyLevel = 7, + FamilyExperience = 130000, + MaxSize = 70, + FamilyHeadGender = GenderType.Male, + ManagerCanInvite = true, + ManagerCanNotice = true, + ManagerCanShout = true, + ManagerCanGetHistory = true, + MemberCanGetHistory = true, + FamilyMessage = "coin afk go rush", + HeadCharacterName = "Yzigor" + }; + family.Members = new List + { + new() + { + FamilyCharacterId = 1, + FamilyId = family.FamilyId, + CharacterId = characterId, + Authority = authority + } + }; + return family; + } + + [TestMethod] + public void TheTagCarriesTheFamilyIdAndTheRank() + { + // gidx 1 521919 5052 -Nemesis-(Member) 7 + _session.Character.Family = Nemesis(_session.Character.CharacterId); + + var packet = _session.Character.GenerateGidx(TestHelpers.Instance.GameLanguageLocalizer, + _session.Character.AccountLanguage); + + Assert.AreEqual(5052, packet.FamilyId); + Assert.AreEqual(7, packet.FamilyLevel); + StringAssert.StartsWith(packet.FamilyName, "-Nemesis-("); + } + + [TestMethod] + public void ACharacterWithNoFamilyIsStillToldSo() + { + // gidx 1 741328 -1 - 0 - silence would leave the last tag on screen. + _session.Character.Family = null; + + var packet = _session.Character.GenerateGidx(TestHelpers.Instance.GameLanguageLocalizer, + _session.Character.AccountLanguage); + + Assert.IsNull(packet.FamilyId); + Assert.IsNull(packet.FamilyName, "the serializer writes a null string as -"); + Assert.AreEqual(0, packet.FamilyLevel); + } + + [TestMethod] + public void TheWindowMatchesTheCapturedLine() + { + // ginfo -Nemesis- Yzigor 0 7 130000 640000 68 70 3 1 1 1 1 2 1 2 coin^afk^go^rush + _session.Character.Family = Nemesis(_session.Character.CharacterId); + + var packet = _session.Character.GenerateGInfo(new FamilyExperienceService())!; + + Assert.AreEqual("-Nemesis-", packet.FamilyName); + Assert.AreEqual("Yzigor", packet.CharacterName); + Assert.AreEqual(GenderType.Male, packet.FamilyHeadGenderType); + Assert.AreEqual(7, packet.FamilyLevel); + Assert.AreEqual(130000, packet.FamilyXp); + Assert.AreEqual(70, packet.MembersCapacity); + Assert.IsTrue(packet.FamilyManagerCanInvit); + Assert.AreEqual("coin^afk^go^rush", packet.FamilyMessage, + "a serialized field keeps its own spaces, so the notice arrives as one"); + } + + [TestMethod] + public void NoFamilyMeansNoWindow() + { + _session.Character.Family = null; + + Assert.IsNull(_session.Character.GenerateGInfo(new FamilyExperienceService())); + } + + [TestMethod] + public void TheTagGoesOutOnTheWireExactlyAsTheCaptureHasIt() + { + var serializer = BuildSerializer(); + + _session.Character.Family = null; + var line = serializer.Serialize(new[] + { (IPacket)_session.Character.GenerateGidx(TestHelpers.Instance.GameLanguageLocalizer, + RegionType.EN) }).TrimEnd('\uFFFF', '\n', ' '); + + StringAssert.StartsWith(line, "gidx 1 "); + StringAssert.EndsWith(line, " -1 - 0", + "no family has to reach the client as -1 and -, not as empty fields"); + } + + [TestMethod] + public void ASpacedFamilyMessageAndRankTravelAsOneFieldEach() + { + // ginfo -Nemesis- Yzigor 0 7 130000 640000 68 70 3 1 1 1 1 2 1 2 coin^afk^go^rush^larene^30^min^=p + var serializer = BuildSerializer(); + _session.Character.Family = Nemesis(_session.Character.CharacterId, FamilyAuthority.Head); + + var ginfo = serializer.Serialize(new[] { (IPacket)_session.Character.GenerateGInfo( + new FamilyExperienceService())! }).TrimEnd(); + StringAssert.Contains(ginfo, "coin^afk^go^rush", $"the notice is one field: {ginfo}"); + + var gidx = serializer.Serialize(new[] { (IPacket)_session.Character.GenerateGidx( + TestHelpers.Instance.GameLanguageLocalizer, RegionType.EN) }).TrimEnd(); + Assert.AreEqual(6, gidx.Split(' ').Length, $"the tag is one field: {gidx}"); + } + [TestMethod] + public void AFamilyGoesOutWithItsIdNameAndLevel() + { + // gidx 1 521919 5052 -Nemesis-(...) 7 + var serializer = BuildSerializer(); + + _session.Character.Family = Nemesis(_session.Character.CharacterId); + var line = serializer.Serialize(new[] + { (IPacket)_session.Character.GenerateGidx(TestHelpers.Instance.GameLanguageLocalizer, + RegionType.EN) }).TrimEnd('\uFFFF', '\n', ' '); + + var fields = line.Split(' '); + Assert.AreEqual("gidx", fields[0]); + Assert.AreEqual("1", fields[1]); + Assert.AreEqual("5052", fields[3]); + StringAssert.StartsWith(fields[4], "-Nemesis-("); + Assert.AreEqual("7", fields[5]); + } + + private static Serializer BuildSerializer() => new(typeof(IPacket).Assembly.GetTypes() + .Where(p => p.GetInterfaces().Contains(typeof(IPacket)) && p.IsClass && !p.IsAbstract) + .ToList()); + + [TestMethod] + public void TheRankIsReadInTheLanguageOfWhoeverIsLooking() + { + _session.Character.Family = Nemesis(_session.Character.CharacterId, FamilyAuthority.Manager); + + var english = _session.Character + .GenerateGidx(TestHelpers.Instance.GameLanguageLocalizer, Shared.Enumerations.RegionType.EN) + .FamilyName; + var french = _session.Character + .GenerateGidx(TestHelpers.Instance.GameLanguageLocalizer, Shared.Enumerations.RegionType.FR) + .FamilyName; + + Assert.AreNotEqual(english, french); + } + } +} diff --git a/test/NosCore.GameObject.Tests/Services/FamilyService/FamilyTests.cs b/test/NosCore.GameObject.Tests/Services/FamilyService/FamilyTests.cs new file mode 100644 index 000000000..be62a6fec --- /dev/null +++ b/test/NosCore.GameObject.Tests/Services/FamilyService/FamilyTests.cs @@ -0,0 +1,168 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | v / _| +// |_|\__|\__/ |___/ \__/\__/|_|_\___| +// + +using Microsoft.Extensions.Localization; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using NosCore.Core.I18N; +using NosCore.Dao.Interfaces; +using NosCore.Data.Dto; +using NosCore.Data.Enumerations.Family; +using NosCore.Data.Enumerations.I18N; +using NosCore.Shared.Enumerations; +using GenderType = NosCore.Packets.Enumerations.GenderType; +using PacketFamilyAuthority = NosCore.Packets.Enumerations.FamilyAuthority; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using Family = NosCore.GameObject.Services.FamilyService.Family; +using FamilyServiceImpl = NosCore.GameObject.Services.FamilyService.FamilyService; + +namespace NosCore.GameObject.Tests.Services.FamilyService +{ + [TestClass] + public class FamilyTests + { + private const long FamilyId = 5052; + private const long HeadId = 100; + private const long MemberId = 626114; + + private static IGameLanguageLocalizer Localizer() + { + var localizer = new Mock(); + localizer.Setup(s => s[It.IsAny(), It.IsAny()]) + .Returns((LanguageKey key, RegionType _) => new LocalizedString(key.ToString(), key switch + { + LanguageKey.FAMILY_AUTHORITY_HEAD => "Head", + LanguageKey.FAMILY_AUTHORITY_ASSISTANT => "Assistant", + LanguageKey.FAMILY_AUTHORITY_MANAGER => "Manager", + _ => "Member" + })); + return localizer.Object; + } + + private static FamilyDto NemesisDto() + { + return new FamilyDto + { + FamilyId = FamilyId, + Name = "-Nemesis-", + FamilyLevel = 7, + FamilyExperience = 130000, + MaxSize = 70, + FamilyHeadGender = GenderType.Male, + ManagerCanInvite = true, + ManagerCanNotice = true, + ManagerCanShout = true, + ManagerCanGetHistory = true, + ManagerAuthorityType = NosCore.Packets.Enumerations.FamilyAuthorityType.ALL, + MemberCanGetHistory = true, + MemberAuthorityType = NosCore.Packets.Enumerations.FamilyAuthorityType.ALL, + FamilyMessage = "coin afk go rush" + }; + } + + private static FamilyServiceImpl Build(IEnumerable families, + IEnumerable memberships, IEnumerable characters) + { + var familyDao = new Mock>(); + familyDao.Setup(s => s.FirstOrDefaultAsync(It.IsAny>>())) + .ReturnsAsync((Expression> p) => families.FirstOrDefault(p.Compile())!); + + var membershipDao = new Mock>(); + membershipDao.Setup(s => s.FirstOrDefaultAsync(It.IsAny>>())) + .ReturnsAsync((Expression> p) => memberships.FirstOrDefault(p.Compile())!); + membershipDao.Setup(s => s.Where(It.IsAny>>())) + .Returns((Expression> p) => memberships.Where(p.Compile())); + + var characterDao = new Mock>(); + characterDao.Setup(s => s.Where(It.IsAny>>())) + .Returns((Expression> p) => characters.Where(p.Compile())); + characterDao.Setup(s => s.FirstOrDefaultAsync(It.IsAny>>())) + .ReturnsAsync((Expression> p) => characters.FirstOrDefault(p.Compile())!); + + return new FamilyServiceImpl(familyDao.Object, membershipDao.Object, characterDao.Object); + } + + private static FamilyServiceImpl Nemesis() + { + return Build( + [NemesisDto()], + [ + new FamilyCharacterDto { FamilyCharacterId = 1, FamilyId = FamilyId, CharacterId = HeadId, Authority = FamilyAuthority.Head }, + new FamilyCharacterDto { FamilyCharacterId = 2, FamilyId = FamilyId, CharacterId = MemberId, Authority = FamilyAuthority.Member } + ], + [ + new CharacterDto { CharacterId = HeadId, Name = "Yzigor" }, + new CharacterDto { CharacterId = MemberId, Name = "Uppermost" } + ]); + } + + [TestMethod] + public async Task ACharacterWithNoFamilyGetsNothingAsync() + { + Assert.IsNull(await Nemesis().GetFamilyAsync(999)); + } + + [TestMethod] + public async Task TheFamilyComesBackWithEveryMemberAsync() + { + var family = await Nemesis().GetFamilyAsync(MemberId); + + Assert.IsNotNull(family); + Assert.AreEqual("-Nemesis-", family.Name); + Assert.AreEqual(2, family.Members.Count); + } + + [TestMethod] + public async Task EachMemberIsReadAtTheirOwnRankAsync() + { + var family = await Nemesis().GetFamilyAsync(MemberId); + + Assert.AreEqual(FamilyAuthority.Member, family!.AuthorityOf(MemberId)); + Assert.AreEqual(FamilyAuthority.Head, family.AuthorityOf(HeadId)); + } + + [TestMethod] + public async Task ARankThatIsNotInTheListReadsAsMemberAsync() + { + var family = await Nemesis().GetFamilyAsync(MemberId); + + Assert.AreEqual(FamilyAuthority.Member, family!.AuthorityOf(999)); + } + + [TestMethod] + public async Task TheHeadsNameIsFetchedForTheWindowAsync() + { + var family = await Nemesis().GetFamilyAsync(MemberId); + + Assert.AreEqual("Yzigor", family!.HeadCharacterName); + } + + [TestMethod] + public async Task AMembershipPointingAtAFamilyThatIsGoneReadsAsNoFamilyAsync() + { + // Rather than a family packet naming something that cannot be opened. + var service = Build([], + [new FamilyCharacterDto { FamilyCharacterId = 1, FamilyId = FamilyId, CharacterId = MemberId }], + [new CharacterDto { CharacterId = MemberId, Name = "Uppermost" }]); + + Assert.IsNull(await service.GetFamilyAsync(MemberId)); + } + + [TestMethod] + public void TheTwoAuthorityEnumsAgreeValueForValue() + { + foreach (var authority in Enum.GetValues()) + { + Assert.AreEqual(authority.ToString(), + ((PacketFamilyAuthority)authority).ToString()); + } + } + } +} diff --git a/test/NosCore.PacketHandlers.Tests/CharacterScreen/SelectPacketHandlerTests.cs b/test/NosCore.PacketHandlers.Tests/CharacterScreen/SelectPacketHandlerTests.cs index 55814cef5..7bf540b1b 100644 --- a/test/NosCore.PacketHandlers.Tests/CharacterScreen/SelectPacketHandlerTests.cs +++ b/test/NosCore.PacketHandlers.Tests/CharacterScreen/SelectPacketHandlerTests.cs @@ -72,7 +72,8 @@ public async Task SetupAsync() new Mock().Object, new CharacterInitializationService(), new Mock().Object, - new Mock().Object); + new Mock().Object, + new Mock().Object); } [TestMethod] diff --git a/test/NosCore.PacketHandlers.Tests/Game/GameStartPacketHandlerTests.cs b/test/NosCore.PacketHandlers.Tests/Game/GameStartPacketHandlerTests.cs index 3450a2012..fec9566d4 100644 --- a/test/NosCore.PacketHandlers.Tests/Game/GameStartPacketHandlerTests.cs +++ b/test/NosCore.PacketHandlers.Tests/Game/GameStartPacketHandlerTests.cs @@ -73,7 +73,8 @@ public async Task SetupAsync() mailHub.Object, questService.Object, MapChangeService.Object, - SkillService.Object); + SkillService.Object, + new NosCore.Algorithm.FamilyExperienceService.FamilyExperienceService()); } [TestMethod] diff --git a/test/NosCore.Tests.Shared/TestHelpers.cs b/test/NosCore.Tests.Shared/TestHelpers.cs index 6d3be481b..ba3b414c2 100644 --- a/test/NosCore.Tests.Shared/TestHelpers.cs +++ b/test/NosCore.Tests.Shared/TestHelpers.cs @@ -313,8 +313,11 @@ public async Task GenerateSessionAsync(List? pack 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)), - + NullLogger.Instance), + new NosCore.GameObject.Services.FamilyService.FamilyService( + new Mock>().Object, + new Mock>().Object, + CharacterDao)), 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), @@ -429,6 +432,7 @@ public async Task GenerateSessionAsync(List? pack new List())); mapInstance.EcsWorld.AddComponent(playerEntity, new GameObject.Ecs.Components.PlayerSocialComponent( new ConcurrentDictionary(), + null, null)); mapInstance.EcsWorld.AddComponent(playerEntity, new GameObject.Ecs.Components.PlayerMatesComponent( new ConcurrentDictionary()));