diff --git a/src/NosCore.Data/Enumerations/I18N/LanguageKey.cs b/src/NosCore.Data/Enumerations/I18N/LanguageKey.cs index db4195236..3077e85d7 100644 --- a/src/NosCore.Data/Enumerations/I18N/LanguageKey.cs +++ b/src/NosCore.Data/Enumerations/I18N/LanguageKey.cs @@ -189,6 +189,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 00027c8b6..de3bb46a6 100644 --- a/src/NosCore.Data/Resource/LocalizedResources.cs.resx +++ b/src/NosCore.Data/Resource/LocalizedResources.cs.resx @@ -530,4 +530,16 @@ Neznámý typ vylepšení: {0} + + Hlava rodiny + + + Asistent + + + 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 841cf7ae7..fc50e175c 100644 --- a/src/NosCore.Data/Resource/LocalizedResources.de.resx +++ b/src/NosCore.Data/Resource/LocalizedResources.de.resx @@ -553,4 +553,16 @@ Unbekannter Aufrüstungstyp: {0} + + Familienoberhaupt + + + Assistent + + + 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 6f761c154..93ec342f6 100644 --- a/src/NosCore.Data/Resource/LocalizedResources.es.resx +++ b/src/NosCore.Data/Resource/LocalizedResources.es.resx @@ -457,4 +457,16 @@ Tipo de mejora no manejado: {0} + + Cabeza de familia + + + Asistente + + + Gerente + + + 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 f06f3fbf4..1747e88ef 100644 --- a/src/NosCore.Data/Resource/LocalizedResources.fr.resx +++ b/src/NosCore.Data/Resource/LocalizedResources.fr.resx @@ -528,4 +528,16 @@ Type d'amélioration non géré: {0} + + Chef de famille + + + 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 cc13b8d36..694dfa4f2 100644 --- a/src/NosCore.Data/Resource/LocalizedResources.it.resx +++ b/src/NosCore.Data/Resource/LocalizedResources.it.resx @@ -574,4 +574,16 @@ Tipo di potenziamento non gestito: {0} + + Capofamiglia + + + Assistente + + + Gestore + + + Membro + diff --git a/src/NosCore.Data/Resource/LocalizedResources.pl.resx b/src/NosCore.Data/Resource/LocalizedResources.pl.resx index 13c4dbcac..b1ec665e1 100644 --- a/src/NosCore.Data/Resource/LocalizedResources.pl.resx +++ b/src/NosCore.Data/Resource/LocalizedResources.pl.resx @@ -480,4 +480,16 @@ Nieobsługiwany typ ulepszenia: {0} + + Głowa rodziny + + + Asystent + + + 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 d8dc46311..d5e6a604a 100644 --- a/src/NosCore.Data/Resource/LocalizedResources.resx +++ b/src/NosCore.Data/Resource/LocalizedResources.resx @@ -604,4 +604,16 @@ Unhandled upgrade type: {0} + + Head + + + Assistant + + + Manager + + + 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 c62a7e857..02c07bdd1 100644 --- a/src/NosCore.Data/Resource/LocalizedResources.ru.resx +++ b/src/NosCore.Data/Resource/LocalizedResources.ru.resx @@ -555,4 +555,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 9b86778b2..7576e6d3a 100644 --- a/src/NosCore.Data/Resource/LocalizedResources.tr.resx +++ b/src/NosCore.Data/Resource/LocalizedResources.tr.resx @@ -546,4 +546,16 @@ Bilinmeyen yükseltme türü: {0} + + Aile reisi + + + Yardımcı + + + 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..a78dc420d --- /dev/null +++ b/src/NosCore.GameObject/Ecs/Extensions/FamilyExtensions.cs @@ -0,0 +1,99 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | 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 +{ + /// + /// The family tag over a character's head, in the reader's language. + /// gidx 1 521919 5083 [NDM](Gardien) 3 + /// gidx 1 741328 -1 - 0 + /// + 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 + }; + } + + /// + /// The family window, field by field from a capture: + /// ginfo -Nemesis- Yzigor 0 7 130000 640000 68 70 3 1 1 1 1 2 1 2 coin^afk^go^rush + /// + 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, + FamilyMessage = family.FamilyMessage + }; + } + + /// "Name(Rank)", e.g. [NDM](Gardien) - the brackets belong to the name. + 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]})"; + } +} diff --git a/src/NosCore.GameObject/Ecs/Extensions/PlayerBundleExtensions.cs b/src/NosCore.GameObject/Ecs/Extensions/PlayerBundleExtensions.cs index bcd0b6e64..b305b50dc 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 6e989835a..d35742779 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.MinilandService; @@ -59,6 +60,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..9430ca8b7 --- /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).ConfigureAwait(false); + if (membership == null) + { + return null; + } + + var familyDto = await familyDao + .FirstOrDefaultAsync(s => s.FamilyId == membership.FamilyId).ConfigureAwait(false); + 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).ConfigureAwait(false); + 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 75908a932..25d37e49e 100644 --- a/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs +++ b/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs @@ -225,6 +225,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)); var shop = otherCharacter.Shop; if (shop != null) @@ -246,6 +247,14 @@ await Task.WhenAll(mapSessions.Select(async s => ? $"[{gameLanguageLocalizer[LanguageKey.SUPPORT, accountLanguage]}]" : string.Empty; await newMapInstance.SendPacketAsync(character.GenerateIn(prefix), new EveryoneBut(channelId)); + + // The tag is in the reader's language, so it cannot be built once and + // broadcast - one packet each. + 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 705acf6e7..da7f5edb3 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.QuestService; using NosCore.Networking.SessionGroup; @@ -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, + IFamilyService familyService) : PacketHandler, IWorldPacketHandler { public override async Task ExecuteAsync(SelectPacket packet, ClientSession clientSession) @@ -191,6 +193,7 @@ await pubSubHub.SubscribeAsync(new Subscriber new List())); mapInstance.EcsWorld.AddComponent(playerEntity, new PlayerSocialComponent( new ConcurrentDictionary(), + null, null)); mapInstance.EcsWorld.AddComponent(playerEntity, new PlayerRequestsComponent( new Dictionary> @@ -244,6 +247,9 @@ await pubSubHub.SubscribeAsync(new Subscriber character.Respawns = respawnDao .Where(s => s.CharacterId == characterId)?.ToList() ?? new List(); + character.Family = await familyService + .GetFamilyAsync(characterId).ConfigureAwait(false); + 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..3e2910cd8 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.QuestService; using NosCore.GameObject.Services.SkillService; @@ -35,7 +37,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) @@ -58,7 +61,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)); @@ -154,6 +156,12 @@ await session.SendPacketAsync(new TwkPacket(session.Account.Name, session.Charac // Session.SendPackets(Session.Character.GenerateScN()); // 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..38d7c8a06 --- /dev/null +++ b/test/NosCore.GameObject.Tests/Services/FamilyService/FamilyPacketTests.cs @@ -0,0 +1,198 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | 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, + "the caret substitution is the serializer's job, not ours"); + } + + [TestMethod] + public void NoFamilyMeansNoWindow() + { + _session.Character.Family = null; + + Assert.IsNull(_session.Character.GenerateGInfo(new FamilyExperienceService())); + } + + [TestMethod] + public void TheTagGoesOutOnTheWireExactlyAsTheCaptureHasIt() + { + // The object being right is not the line being right, and the line is what the + // client reads: + // + // gidx 1 521919 5083 [NDM](Gardien) 3 + // gidx 1 741328 -1 - 0 + // + // The second is the one worth pinning: a null id has to serialise as -1 and a null + // name as -, which is the serializer's job and not this code's. Asserting on the + // object would pass whatever the serializer did with it. + // + // The trailing space is trimmed on purpose: GidxPacket ends in a FamilyIcons list, + // and an empty one still emits its leading separator here while the captured server + // sends nothing at all - 670 gidx lines, not one with a trailing space. It is an + // empty last token either way, so it is recorded rather than worked around. + 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 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]); + } + + // The same list the world server hands its serializer, so a test cannot pass on a + // registration the real thing does not have. + 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() + { + // Two players standing next to each other see the same family in their own words, + // so the tag cannot be built once from the owner's account language. + _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; + + // The words themselves live in the resources — the capture shows Gardien for a + // Manager on a French session. What this guards is that the tag is built per + // reader at all, rather than once from the owner's account language. + 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..6dc295524 --- /dev/null +++ b/test/NosCore.GameObject.Tests/Services/FamilyService/FamilyTests.cs @@ -0,0 +1,173 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | 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() + { + // The window hands out permissions off this value, and reading somebody else's row + // would grant them without throwing. + 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() + { + // One enum lives in the database layer and one in the packet library, and the tag + // and the window are built from different ones. If they ever diverge every player's + // rank silently shifts by one. + 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 536b8d186..5a26b4547 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.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 a4f320d3f..1cb309433 100644 --- a/test/NosCore.Tests.Shared/TestHelpers.cs +++ b/test/NosCore.Tests.Shared/TestHelpers.cs @@ -308,7 +308,12 @@ 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.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), @@ -423,6 +428,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.PlayerRequestsComponent( new Dictionary>