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>