// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // namespace MUnique.OpenMU.Tests; using Microsoft.Extensions.Logging.Abstractions; using Moq; using MUnique.OpenMU.DataModel.Entities; using MUnique.OpenMU.GameLogic; using MUnique.OpenMU.GameLogic.PlugIns; using MUnique.OpenMU.GameServer; using MUnique.OpenMU.Interfaces; using MUnique.OpenMU.Persistence.InMemory; using MUnique.OpenMU.PlugIns; using BasicModel = MUnique.OpenMU.Persistence.BasicModel; /// /// Unit tests for guild alliance and hostility logic in . /// [TestFixture] public class GuildAllianceTest : GuildTestBase { private const string SecondGuildName = "SecondGuild"; private const string ThirdGuildName = "ThirdGuild"; private Character _secondGuildMaster = null!; private Character _thirdGuildMaster = null!; private uint _firstGuildId; private uint _secondGuildId; private uint _thirdGuildId; /// [SetUp] public override async ValueTask SetupAsync() { await base.SetupAsync().ConfigureAwait(false); var context = this.PersistenceContextProvider.CreateNewContext(); this._secondGuildMaster = context.CreateNew(); this._secondGuildMaster.Name = "SecondMaster"; this._thirdGuildMaster = context.CreateNew(); this._thirdGuildMaster.Name = "ThirdMaster"; await this.GuildServer.CreateGuildAsync(SecondGuildName, this._secondGuildMaster.Name, this._secondGuildMaster.Id, new byte[16], 0).ConfigureAwait(false); await this.GuildServer.CreateGuildAsync(ThirdGuildName, this._thirdGuildMaster.Name, this._thirdGuildMaster.Id, new byte[16], 0).ConfigureAwait(false); // Bring the first guild master back online (base.SetupAsync takes them offline) // so that the first guild is in the in-memory dictionary and GetGuildIdByNameAsync can find it. await this.GuildServer.PlayerEnteredGameAsync(this.GuildMaster.Id, this.GuildMaster.Name, 0).ConfigureAwait(false); this._firstGuildId = await this.GuildServer.GetGuildIdByNameAsync(GuildName).ConfigureAwait(false); this._secondGuildId = await this.GuildServer.GetGuildIdByNameAsync(SecondGuildName).ConfigureAwait(false); this._thirdGuildId = await this.GuildServer.GetGuildIdByNameAsync(ThirdGuildName).ConfigureAwait(false); } // ------------------------------------------------------------------------- // CreateAllianceAsync // ------------------------------------------------------------------------- /// /// Two online guilds can successfully form an alliance. /// [Test] public async ValueTask CreateAlliance_Success() { var result = await this.GuildServer.CreateAllianceAsync(this._firstGuildId, this._secondGuildId).ConfigureAwait(false); Assert.That(result, Is.EqualTo(AllianceCreationResult.Success)); } /// /// A guild that is already in an alliance cannot join another one. /// [Test] public async ValueTask CreateAlliance_TargetAlreadyInAlliance_Fails() { await this.GuildServer.CreateAllianceAsync(this._firstGuildId, this._secondGuildId).ConfigureAwait(false); // Try to add the second guild (already in an alliance) to the third guild var result = await this.GuildServer.CreateAllianceAsync(this._thirdGuildId, this._secondGuildId).ConfigureAwait(false); Assert.That(result, Is.EqualTo(AllianceCreationResult.TargetGuildAlreadyInAlliance)); } /// /// The guild server does not enforce a maximum alliance size — that limit /// is now applied at the action layer (GuildRelationshipChangeAction) /// using the Stats.MaximumAllianceSize player attribute. /// This test verifies that the server allows building an alliance larger than 5 /// (the old hard-coded constant). /// [Test] public async ValueTask CreateAlliance_ServerDoesNotEnforceMaxSize() { // Add 6 guilds beyond the old hard-coded limit of 5 to confirm no server limit const int beyondOldLimit = 6; for (var i = 0; i < beyondOldLimit - 1; i++) { var memberName = $"FillGuildMaster{i}"; var fillContext = this.PersistenceContextProvider.CreateNewContext(); var master = fillContext.CreateNew(); master.Name = memberName; var guildName = $"FillGuild{i}"; await this.GuildServer.CreateGuildAsync(guildName, memberName, master.Id, new byte[16], 0).ConfigureAwait(false); await this.GuildServer.PlayerEnteredGameAsync(master.Id, memberName, 0).ConfigureAwait(false); var fillGuildId = await this.GuildServer.GetGuildIdByNameAsync(guildName).ConfigureAwait(false); await this.GuildServer.CreateAllianceAsync(this._firstGuildId, fillGuildId).ConfigureAwait(false); } // Adding the sixth guild should still succeed — no server-side hard limit var result = await this.GuildServer.CreateAllianceAsync(this._firstGuildId, this._secondGuildId).ConfigureAwait(false); Assert.That(result, Is.EqualTo(AllianceCreationResult.Success)); } // ------------------------------------------------------------------------- // IsAllianceMasterAsync // ------------------------------------------------------------------------- /// /// The guild that initiated the alliance is identified as the alliance master. /// [Test] public async ValueTask IsAllianceMaster_MasterGuild_ReturnsTrue() { await this.GuildServer.CreateAllianceAsync(this._firstGuildId, this._secondGuildId).ConfigureAwait(false); var isMaster = await this.GuildServer.IsAllianceMasterAsync(this._firstGuildId).ConfigureAwait(false); Assert.That(isMaster, Is.True); } /// /// A member guild is not identified as the alliance master. /// [Test] public async ValueTask IsAllianceMaster_MemberGuild_ReturnsFalse() { await this.GuildServer.CreateAllianceAsync(this._firstGuildId, this._secondGuildId).ConfigureAwait(false); var isMaster = await this.GuildServer.IsAllianceMasterAsync(this._secondGuildId).ConfigureAwait(false); Assert.That(isMaster, Is.False); } // ------------------------------------------------------------------------- // GetAllianceGuildsAsync // ------------------------------------------------------------------------- /// /// GetAllianceGuildsAsync returns all guilds that are in the alliance. /// [Test] public async ValueTask GetAllianceGuilds_ReturnsAllMembers() { await this.GuildServer.CreateAllianceAsync(this._firstGuildId, this._secondGuildId).ConfigureAwait(false); await this.GuildServer.CreateAllianceAsync(this._firstGuildId, this._thirdGuildId).ConfigureAwait(false); var guilds = await this.GuildServer.GetAllianceGuildsAsync(this._firstGuildId).ConfigureAwait(false); Assert.That(guilds.Count, Is.EqualTo(3)); Assert.That(guilds.Select(g => g.Id), Is.EquivalentTo(new[] { this._firstGuildId, this._secondGuildId, this._thirdGuildId })); } /// /// GetAllianceGuildsAsync returns an empty list when the guild has no alliance. /// [Test] public async ValueTask GetAllianceGuilds_NoAlliance_ReturnsEmpty() { var guilds = await this.GuildServer.GetAllianceGuildsAsync(this._firstGuildId).ConfigureAwait(false); Assert.That(guilds, Is.Empty); } // ------------------------------------------------------------------------- // RemoveAllianceGuildAsync // ------------------------------------------------------------------------- /// /// The alliance master can successfully remove a member guild. /// [Test] public async ValueTask RemoveAllianceGuild_Member_Success() { await this.GuildServer.CreateAllianceAsync(this._firstGuildId, this._secondGuildId).ConfigureAwait(false); await this.GuildServer.CreateAllianceAsync(this._firstGuildId, this._thirdGuildId).ConfigureAwait(false); var removed = await this.GuildServer.RemoveAllianceAsync(this._secondGuildId).ConfigureAwait(false); var guilds = await this.GuildServer.GetAllianceGuildsAsync(this._firstGuildId).ConfigureAwait(false); Assert.That(removed, Is.True); Assert.That(guilds.Select(g => g.Id), Does.Not.Contain(this._secondGuildId)); } /// /// The alliance master can successfully remove a member guild. /// [Test] public async ValueTask RemoveAllianceGuild_Master_Disbands_Success() { await this.GuildServer.CreateAllianceAsync(this._firstGuildId, this._secondGuildId).ConfigureAwait(false); await this.GuildServer.CreateAllianceAsync(this._firstGuildId, this._thirdGuildId).ConfigureAwait(false); var removed = await this.GuildServer.RemoveAllianceAsync(this._firstGuildId).ConfigureAwait(false); var guilds = await this.GuildServer.GetAllianceGuildsAsync(this._firstGuildId).ConfigureAwait(false); Assert.That(removed, Is.True); Assert.That(guilds, Is.Empty); } // ------------------------------------------------------------------------- // SetHostilityAsync / GetGuildRelationshipAsync // ------------------------------------------------------------------------- /// /// Creating hostility between two guilds yields a Rival relationship. /// [Test] public async ValueTask SetHostility_Create_ReturnsRival() { var success = await this.GuildServer.SetHostilityAsync(this._firstGuildId, this._secondGuildId, true).ConfigureAwait(false); var relationship = await this.GuildServer.GetGuildRelationshipAsync(this._firstGuildId, this._secondGuildId).ConfigureAwait(false); Assert.That(success, Is.True); Assert.That(relationship, Is.EqualTo(GuildRelationship.Rival)); } /// /// Cancelling a hostility between two solo guilds (no alliances) yields no relationship. /// [Test] public async ValueTask SetHostility_Cancel_ReturnsNone() { await this.GuildServer.SetHostilityAsync(this._firstGuildId, this._secondGuildId, true).ConfigureAwait(false); await this.GuildServer.SetHostilityAsync(this._firstGuildId, this._secondGuildId, false).ConfigureAwait(false); var relationship = await this.GuildServer.GetGuildRelationshipAsync(this._firstGuildId, this._secondGuildId).ConfigureAwait(false); Assert.That(relationship, Is.EqualTo(GuildRelationship.None)); } /// /// Two guilds in the same alliance have a Union relationship. /// [Test] public async ValueTask GetGuildRelationship_SameAlliance_ReturnsUnion() { await this.GuildServer.CreateAllianceAsync(this._firstGuildId, this._secondGuildId).ConfigureAwait(false); var relationship = await this.GuildServer.GetGuildRelationshipAsync(this._firstGuildId, this._secondGuildId).ConfigureAwait(false); Assert.That(relationship, Is.EqualTo(GuildRelationship.Union)); } /// /// Two guilds with no shared alliance and no hostility have no relationship. /// [Test] public async ValueTask GetGuildRelationship_NoRelation_ReturnsNone() { var relationship = await this.GuildServer.GetGuildRelationshipAsync(this._firstGuildId, this._secondGuildId).ConfigureAwait(false); Assert.That(relationship, Is.EqualTo(GuildRelationship.None)); } // ------------------------------------------------------------------------- // SetHostilityAsync — AreAlliancesStillHostile guard (key bug-fix scenario) // ------------------------------------------------------------------------- /// /// When A↔X and B↔Y hostilities exist between two alliances, cancelling A↔X /// must NOT notify game servers to remove all rival pairs, because B↔Y still /// makes the alliances hostile. /// /// Alliance A: guilds 1 and 2. Alliance X: guild 3. /// After SetHostility(1, 3, false) the guild-2 ↔ guild-3 link still exists. /// [Test] public async ValueTask SetHostility_Cancel_WithRemainingCrossAllianceHostility_DoesNotNotifyRemoval() { // Build Alliance A: guilds 1 and 2 await this.GuildServer.CreateAllianceAsync(this._firstGuildId, this._secondGuildId).ConfigureAwait(false); // Hostility A↔X: guild 1 ↔ guild 3 await this.GuildServer.SetHostilityAsync(this._firstGuildId, this._thirdGuildId, true).ConfigureAwait(false); // Hostility B↔Y: guild 2 ↔ guild 3 await this.GuildServer.SetHostilityAsync(this._secondGuildId, this._thirdGuildId, true).ConfigureAwait(false); // Reset the call counts recorded during the two SetHostility(create) calls this.GameServer0.Invocations.Clear(); this.GameServer1.Invocations.Clear(); // Cancel only A↔X (guild 1 ↔ guild 3) await this.GuildServer.SetHostilityAsync(this._firstGuildId, this._thirdGuildId, false).ConfigureAwait(false); // Game servers must NOT receive a removal notification because B↔Y (guild 2 ↔ guild 3) // still makes all alliance members rivals. this.GameServer0.Verify( gs => gs.GuildHostilityChangedAsync( It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny>(), false), Times.Never); } /// /// When the last remaining cross-alliance hostility is cancelled, game servers /// ARE notified so they can remove the rival pairs from their caches. /// [Test] public async ValueTask SetHostility_Cancel_LastHostility_NotifiesRemoval() { // Single hostility between two standalone guilds await this.GuildServer.SetHostilityAsync(this._firstGuildId, this._secondGuildId, true).ConfigureAwait(false); this.GameServer0.Invocations.Clear(); this.GameServer1.Invocations.Clear(); await this.GuildServer.SetHostilityAsync(this._firstGuildId, this._secondGuildId, false).ConfigureAwait(false); // Game servers must be notified that all hostility ended this.GameServer0.Verify( gs => gs.GuildHostilityChangedAsync( It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny>(), false), Times.Once); } /// protected override void SetupGameServer(Mock gameServer) { base.SetupGameServer(gameServer); gameServer.Setup(gs => gs.GuildHostilityChangedAsync( It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny>(), It.IsAny())) .Returns(ValueTask.CompletedTask); } } /// /// Unit tests for the rival guild cache in . /// [TestFixture] public class RivalGuildCacheTest { private GameServerContext _gameServerContext = null!; /// /// Sets up a minimal for each test. /// [SetUp] public void Setup() { this._gameServerContext = CreateMinimalGameServerContext(); } /// /// After adding a hostility the two guilds are reported as rivals. /// [Test] public void UpdateGuildHostility_Create_GuildsAreRivals() { const uint guildA = 1; const uint guildB = 2; this._gameServerContext.UpdateGuildHostility(guildA, [guildA], guildB, [guildB], true); Assert.That(this._gameServerContext.AreGuildsRival(guildA, guildB), Is.True); } /// /// After removing a hostility the two guilds are no longer rivals. /// [Test] public void UpdateGuildHostility_Remove_GuildsAreNoLongerRivals() { const uint guildA = 1; const uint guildB = 2; this._gameServerContext.UpdateGuildHostility(guildA, [guildA], guildB, [guildB], true); this._gameServerContext.UpdateGuildHostility(guildA, [guildA], guildB, [guildB], false); Assert.That(this._gameServerContext.AreGuildsRival(guildA, guildB), Is.False); } /// /// Guild ID order does not matter: (A, B) and (B, A) both return the same result. /// [Test] public void AreGuildsRival_IdOrderIsNormalized() { const uint guildA = 5; const uint guildB = 3; // B < A intentionally this._gameServerContext.UpdateGuildHostility(guildA, [guildA], guildB, [guildB], true); Assert.That(this._gameServerContext.AreGuildsRival(guildA, guildB), Is.True); Assert.That(this._gameServerContext.AreGuildsRival(guildB, guildA), Is.True); } /// /// When alliances are expanded, every cross-alliance pair is cached. /// Alliance A = {1, 2}, Alliance X = {3, 4}. /// All four cross-pairs (1↔3, 1↔4, 2↔3, 2↔4) should be rivals. /// [Test] public void UpdateGuildHostility_AllianceExpansion_AllPairsAreCached() { uint[] allianceA = [1, 2]; uint[] allianceX = [3, 4]; this._gameServerContext.UpdateGuildHostility(1, allianceA, 3, allianceX, true); foreach (var idA in allianceA) { foreach (var idX in allianceX) { Assert.That(this._gameServerContext.AreGuildsRival(idA, idX), Is.True, $"Expected guilds {idA} and {idX} to be rivals."); } } } /// /// Guilds that are not in the rival cache are not considered rivals. /// [Test] public void AreGuildsRival_UnrelatedGuilds_ReturnsFalse() { Assert.That(this._gameServerContext.AreGuildsRival(100, 200), Is.False); } private static GameServerContext CreateMinimalGameServerContext() { var persistenceProvider = new InMemoryPersistenceContextProvider(); var gameConfiguration = new BasicModel.GameConfiguration(); gameConfiguration.Maps.Add(new BasicModel.GameMapDefinition()); var mapInitializer = new MapInitializer(gameConfiguration, new NullLogger(), NullDropGenerator.Instance, null); var ctx = new GameServerContext( new BasicModel.GameServerDefinition { GameConfiguration = gameConfiguration, ServerConfiguration = new BasicModel.GameServerConfiguration(), }, new Mock().Object, new Mock().Object, new Mock().Object, new Mock().Object, persistenceProvider, mapInitializer, new NullLoggerFactory(), new PlugInManager([], new NullLoggerFactory(), null, null), NullDropGenerator.Instance, new ConfigurationChangeMediator()); mapInitializer.PlugInManager = ctx.PlugInManager; mapInitializer.PathFinderPool = ctx.PathFinderPool; return ctx; } } /// /// Tests that rival guild members can fight each other without PK consequences and /// without triggering self-defense. /// [TestFixture] public class RivalGuildCombatTest { private GameServerContext _gameServerContext = null!; private Player _killer = null!; private Player _victim = null!; private const uint KillerGuildId = 10; private const uint VictimGuildId = 20; /// /// Creates two players in a game server context for each test. /// [SetUp] public async ValueTask SetupAsync() { var persistenceProvider = new InMemoryPersistenceContextProvider(); var gameConfiguration = new BasicModel.GameConfiguration(); gameConfiguration.Maps.Add(new BasicModel.GameMapDefinition()); var mapInitializer = new MapInitializer(gameConfiguration, new NullLogger(), NullDropGenerator.Instance, null); this._gameServerContext = new GameServerContext( new BasicModel.GameServerDefinition { GameConfiguration = gameConfiguration, ServerConfiguration = new BasicModel.GameServerConfiguration(), }, new Mock().Object, new Mock().Object, new Mock().Object, new Mock().Object, persistenceProvider, mapInitializer, new NullLoggerFactory(), new PlugInManager([], new NullLoggerFactory(), null, null), NullDropGenerator.Instance, new ConfigurationChangeMediator()); mapInitializer.PlugInManager = this._gameServerContext.PlugInManager; mapInitializer.PathFinderPool = this._gameServerContext.PathFinderPool; this._killer = await PlayerTestHelper.CreatePlayerAsync(this._gameServerContext).ConfigureAwait(false); this._victim = await PlayerTestHelper.CreatePlayerAsync(this._gameServerContext).ConfigureAwait(false); await this._killer.CurrentMap!.AddAsync(this._victim).ConfigureAwait(false); this._killer.GuildStatus = new GuildMemberStatus(KillerGuildId, GuildPosition.GuildMaster); this._victim.GuildStatus = new GuildMemberStatus(VictimGuildId, GuildPosition.GuildMaster); } // ------------------------------------------------------------------------- // PK state bypass // ------------------------------------------------------------------------- /// /// Killing a rival guild member does not change the killer's hero state or PK count. /// [Test] public async ValueTask KillRivalGuildMember_DoesNotIncrementPkCount() { this._gameServerContext.UpdateGuildHostility(KillerGuildId, [KillerGuildId], VictimGuildId, [VictimGuildId], true); var initialState = this._killer.SelectedCharacter!.State; await InvokeAfterKilledPlayerAsync(this._killer, this._victim).ConfigureAwait(false); Assert.That(this._killer.SelectedCharacter.State, Is.EqualTo(initialState), "Hero state should not change when killing a rival guild member."); Assert.That(this._killer.SelectedCharacter.PlayerKillCount, Is.Zero, "PK count should not increase when killing a rival guild member."); } /// /// Killing a non-rival guild member DOES increment the killer's hero state. /// [Test] public async ValueTask KillNonRivalGuildMember_IncrementsHeroState() { // guilds are NOT rivals — no UpdateGuildHostility call var initialState = this._killer.SelectedCharacter!.State; await InvokeAfterKilledPlayerAsync(this._killer, this._victim).ConfigureAwait(false); Assert.That(this._killer.SelectedCharacter.State, Is.GreaterThan(initialState), "Hero state should increase when killing a non-rival player."); } // ------------------------------------------------------------------------- // Self-defense bypass // ------------------------------------------------------------------------- /// /// Hitting a rival guild member does not initiate a self-defense state on the victim. /// [Test] public void HitRivalGuildMember_DoesNotInitiateSelfDefense() { this._gameServerContext.UpdateGuildHostility(KillerGuildId, [KillerGuildId], VictimGuildId, [VictimGuildId], true); var plugIn = new SelfDefensePlugIn(); plugIn.AttackableGotHit(this._victim, this._killer, new HitInfo(100, 0, DamageAttributes.Undefined)); Assert.That(this._gameServerContext.SelfDefenseState, Is.Empty, "No self-defense should be initiated between rival guild members."); } /// /// Hitting a non-rival guild member DOES initiate a self-defense state on the victim. /// [Test] public void HitNonRivalGuildMember_InitiatesSelfDefense() { // guilds are NOT rivals var plugIn = new SelfDefensePlugIn(); plugIn.AttackableGotHit(this._victim, this._killer, new HitInfo(100, 0, DamageAttributes.Undefined)); Assert.That(this._gameServerContext.SelfDefenseState, Is.Not.Empty, "Self-defense should be initiated when hit by a non-rival player."); } /// /// Invokes AfterKilledPlayerAsync on /// passing as the argument. /// private static async ValueTask InvokeAfterKilledPlayerAsync(Player killer, Player killedPlayer) { await killer.AfterKilledPlayerAsync(killedPlayer).ConfigureAwait(false); } }