Files
AdamuSw/tests/MUnique.OpenMU.Tests/GuildAllianceTest.cs
2026-07-14 19:00:35 +03:00

602 lines
25 KiB
C#

// <copyright file="GuildAllianceTest.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
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;
/// <summary>
/// Unit tests for guild alliance and hostility logic in <see cref="MUnique.OpenMU.GuildServer.GuildServer"/>.
/// </summary>
[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;
/// <inheritdoc />
[SetUp]
public override async ValueTask SetupAsync()
{
await base.SetupAsync().ConfigureAwait(false);
var context = this.PersistenceContextProvider.CreateNewContext();
this._secondGuildMaster = context.CreateNew<Character>();
this._secondGuildMaster.Name = "SecondMaster";
this._thirdGuildMaster = context.CreateNew<Character>();
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
// -------------------------------------------------------------------------
/// <summary>
/// Two online guilds can successfully form an alliance.
/// </summary>
[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));
}
/// <summary>
/// A guild that is already in an alliance cannot join another one.
/// </summary>
[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));
}
/// <summary>
/// The guild server does not enforce a maximum alliance size — that limit
/// is now applied at the action layer (<c>GuildRelationshipChangeAction</c>)
/// using the <c>Stats.MaximumAllianceSize</c> player attribute.
/// This test verifies that the server allows building an alliance larger than 5
/// (the old hard-coded constant).
/// </summary>
[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<Character>();
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
// -------------------------------------------------------------------------
/// <summary>
/// The guild that initiated the alliance is identified as the alliance master.
/// </summary>
[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);
}
/// <summary>
/// A member guild is not identified as the alliance master.
/// </summary>
[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
// -------------------------------------------------------------------------
/// <summary>
/// GetAllianceGuildsAsync returns all guilds that are in the alliance.
/// </summary>
[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 }));
}
/// <summary>
/// GetAllianceGuildsAsync returns an empty list when the guild has no alliance.
/// </summary>
[Test]
public async ValueTask GetAllianceGuilds_NoAlliance_ReturnsEmpty()
{
var guilds = await this.GuildServer.GetAllianceGuildsAsync(this._firstGuildId).ConfigureAwait(false);
Assert.That(guilds, Is.Empty);
}
// -------------------------------------------------------------------------
// RemoveAllianceGuildAsync
// -------------------------------------------------------------------------
/// <summary>
/// The alliance master can successfully remove a member guild.
/// </summary>
[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));
}
/// <summary>
/// The alliance master can successfully remove a member guild.
/// </summary>
[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
// -------------------------------------------------------------------------
/// <summary>
/// Creating hostility between two guilds yields a Rival relationship.
/// </summary>
[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));
}
/// <summary>
/// Cancelling a hostility between two solo guilds (no alliances) yields no relationship.
/// </summary>
[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));
}
/// <summary>
/// Two guilds in the same alliance have a Union relationship.
/// </summary>
[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));
}
/// <summary>
/// Two guilds with no shared alliance and no hostility have no relationship.
/// </summary>
[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)
// -------------------------------------------------------------------------
/// <summary>
/// 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.
/// </summary>
[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<uint>(),
It.IsAny<IReadOnlyList<uint>>(),
It.IsAny<uint>(),
It.IsAny<IReadOnlyList<uint>>(),
false),
Times.Never);
}
/// <summary>
/// When the last remaining cross-alliance hostility is cancelled, game servers
/// ARE notified so they can remove the rival pairs from their caches.
/// </summary>
[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<uint>(),
It.IsAny<IReadOnlyList<uint>>(),
It.IsAny<uint>(),
It.IsAny<IReadOnlyList<uint>>(),
false),
Times.Once);
}
/// <inheritdoc />
protected override void SetupGameServer(Mock<IGameServer> gameServer)
{
base.SetupGameServer(gameServer);
gameServer.Setup(gs => gs.GuildHostilityChangedAsync(
It.IsAny<uint>(),
It.IsAny<IReadOnlyList<uint>>(),
It.IsAny<uint>(),
It.IsAny<IReadOnlyList<uint>>(),
It.IsAny<bool>()))
.Returns(ValueTask.CompletedTask);
}
}
/// <summary>
/// Unit tests for the rival guild cache in <see cref="GameServerContext"/>.
/// </summary>
[TestFixture]
public class RivalGuildCacheTest
{
private GameServerContext _gameServerContext = null!;
/// <summary>
/// Sets up a minimal <see cref="GameServerContext"/> for each test.
/// </summary>
[SetUp]
public void Setup()
{
this._gameServerContext = CreateMinimalGameServerContext();
}
/// <summary>
/// After adding a hostility the two guilds are reported as rivals.
/// </summary>
[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);
}
/// <summary>
/// After removing a hostility the two guilds are no longer rivals.
/// </summary>
[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);
}
/// <summary>
/// Guild ID order does not matter: (A, B) and (B, A) both return the same result.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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.");
}
}
}
/// <summary>
/// Guilds that are not in the rival cache are not considered rivals.
/// </summary>
[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<MapInitializer>(), NullDropGenerator.Instance, null);
var ctx = new GameServerContext(
new BasicModel.GameServerDefinition
{
GameConfiguration = gameConfiguration,
ServerConfiguration = new BasicModel.GameServerConfiguration(),
},
new Mock<IGuildServer>().Object,
new Mock<IEventPublisher>().Object,
new Mock<ILoginServer>().Object,
new Mock<IFriendServer>().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;
}
}
/// <summary>
/// Tests that rival guild members can fight each other without PK consequences and
/// without triggering self-defense.
/// </summary>
[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;
/// <summary>
/// Creates two players in a game server context for each test.
/// </summary>
[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<MapInitializer>(), NullDropGenerator.Instance, null);
this._gameServerContext = new GameServerContext(
new BasicModel.GameServerDefinition
{
GameConfiguration = gameConfiguration,
ServerConfiguration = new BasicModel.GameServerConfiguration(),
},
new Mock<IGuildServer>().Object,
new Mock<IEventPublisher>().Object,
new Mock<ILoginServer>().Object,
new Mock<IFriendServer>().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
// -------------------------------------------------------------------------
/// <summary>
/// Killing a rival guild member does not change the killer's hero state or PK count.
/// </summary>
[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.");
}
/// <summary>
/// Killing a non-rival guild member DOES increment the killer's hero state.
/// </summary>
[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
// -------------------------------------------------------------------------
/// <summary>
/// Hitting a rival guild member does not initiate a self-defense state on the victim.
/// </summary>
[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.");
}
/// <summary>
/// Hitting a non-rival guild member DOES initiate a self-defense state on the victim.
/// </summary>
[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.");
}
/// <summary>
/// Invokes <c>AfterKilledPlayerAsync</c> on <paramref name="killer"/>
/// passing <paramref name="killedPlayer"/> as the argument.
/// </summary>
private static async ValueTask InvokeAfterKilledPlayerAsync(Player killer, Player killedPlayer)
{
await killer.AfterKilledPlayerAsync(killedPlayer).ConfigureAwait(false);
}
}