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()));