From 9429c93809fd07f959e341b14238fef83e81f91d Mon Sep 17 00:00:00 2001
From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com>
Date: Sun, 31 May 2026 22:12:11 -0300
Subject: [PATCH 1/3] Add auto accept party request from friends and guild
members
---
.../FriendServerController.cs | 11 +
src/Dapr/ServerClients/FriendServer.cs | 14 ++
src/FriendServer/FriendServer.cs | 16 ++
src/GameLogic/MuHelper/IMuHelperSettings.cs | 12 ++
src/GameLogic/MuHelper/PartyRequestHandler.cs | 98 +++++++++
src/GameLogic/Offline/CombatHandler.cs | 20 +-
.../Offline/OfflinePlayerMuHelper.cs | 2 +-
.../PlayerActions/Party/PartyRequestAction.cs | 31 ++-
.../MuHelperSaveDataRequestHandlerPlugin.cs | 5 +
.../RemoteView/MuHelper/MuHelperSettings.cs | 12 ++
.../MuHelper/MuHelperSettingsSerializer.cs | 19 +-
src/Interfaces/IFriendServer.cs | 8 +
.../EntityFramework/FriendServerContext.cs | 2 +-
.../InMemory/FriendServerInMemoryContext.cs | 2 +-
.../Offline/CombatHandlerTests.cs | 6 +-
tests/MUnique.OpenMU.Tests/Party/PartyTest.cs | 192 ++++++++++++++++++
16 files changed, 430 insertions(+), 20 deletions(-)
create mode 100644 src/GameLogic/MuHelper/PartyRequestHandler.cs
diff --git a/src/Dapr/FriendServer.Host/FriendServerController.cs b/src/Dapr/FriendServer.Host/FriendServerController.cs
index 0196f6f1d..5e5dfd438 100644
--- a/src/Dapr/FriendServer.Host/FriendServerController.cs
+++ b/src/Dapr/FriendServer.Host/FriendServerController.cs
@@ -77,6 +77,17 @@ public async Task FriendResponseAsync([FromBody] FriendResponseArguments data)
await this._friendServer.FriendResponseAsync(data.CharacterName, data.FriendName, data.Accepted).ConfigureAwait(false);
}
+ ///
+ /// Determines whether two players are friends.
+ ///
+ /// The data.
+ /// True if the two players are friends; otherwise false.
+ [HttpPost(nameof(IFriendServer.IsFriendAsync))]
+ public async Task IsFriendAsync([FromBody] RequestArguments data)
+ {
+ return await this._friendServer.IsFriendAsync(data.Requester, data.Receiver).ConfigureAwait(false);
+ }
+
///
/// Sends a friend request to the friend, and adds a new friend view item to the players friend list.
///
diff --git a/src/Dapr/ServerClients/FriendServer.cs b/src/Dapr/ServerClients/FriendServer.cs
index cd7172eea..7a721d42c 100644
--- a/src/Dapr/ServerClients/FriendServer.cs
+++ b/src/Dapr/ServerClients/FriendServer.cs
@@ -82,6 +82,20 @@ public async ValueTask SetPlayerVisibilityStateAsync(byte serverId, Guid charact
}
}
+ ///
+ public async ValueTask IsFriendAsync(string characterName, string friendName)
+ {
+ try
+ {
+ return await this._daprClient.InvokeMethodAsync(this._targetAppId, nameof(this.IsFriendAsync), new RequestArguments(characterName, friendName)).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ this._logger.LogError(ex, "Unexpected error when checking friendship.");
+ return false;
+ }
+ }
+
///
public async ValueTask FriendRequestAsync(string playerName, string friendName)
{
diff --git a/src/FriendServer/FriendServer.cs b/src/FriendServer/FriendServer.cs
index 3a1e06633..7eaee2320 100644
--- a/src/FriendServer/FriendServer.cs
+++ b/src/FriendServer/FriendServer.cs
@@ -83,16 +83,32 @@ public async ValueTask FriendRequestAsync(string playerName, string friend
return friendIsNew && saveSuccess;
}
+ ///
+ public async ValueTask IsFriendAsync(string characterName, string friendName)
+ {
+ if (this.OnlineFriends.TryGetValue(characterName, out var player)
+ && this.OnlineFriends.TryGetValue(friendName, out var friend))
+ {
+ return player.HasSubscriber(friend);
+ }
+
+ using var context = this._persistenceContextProvider.CreateNewFriendServerContext();
+ var friendEntry = await context.GetFriendByNamesAsync(characterName, friendName).ConfigureAwait(false);
+ return friendEntry?.Accepted == true;
+ }
+
///
public async ValueTask DeleteFriendAsync(string playerName, string friendName)
{
if (this.OnlineFriends.TryGetValue(playerName, out var player) && this.OnlineFriends.TryGetValue(friendName, out var friend))
{
player.RemoveSubscriber(friend);
+ friend.RemoveSubscriber(player);
}
using var context = this._persistenceContextProvider.CreateNewFriendServerContext();
await context.DeleteAsync(playerName, friendName).ConfigureAwait(false);
+ await context.DeleteAsync(friendName, playerName).ConfigureAwait(false);
await context.SaveChangesAsync().ConfigureAwait(false);
}
diff --git a/src/GameLogic/MuHelper/IMuHelperSettings.cs b/src/GameLogic/MuHelper/IMuHelperSettings.cs
index 006df0bdc..dfd6fddd4 100644
--- a/src/GameLogic/MuHelper/IMuHelperSettings.cs
+++ b/src/GameLogic/MuHelper/IMuHelperSettings.cs
@@ -140,4 +140,16 @@ public interface IMuHelperSettings
/// Gets a value indicating whether to repair items.
bool RepairItem { get; }
+
+ /// Gets a value indicating whether to automatically defend against nearby monsters attacking the character.
+ bool UseSelfDefense { get; }
+
+ /// Gets a value indicating whether to automatically accept friend requests.
+ bool AutoAcceptFriend { get; }
+
+ /// Gets a value indicating whether to automatically accept guild join requests.
+ bool AutoAcceptGuild { get; }
+
+ /// Gets a value indicating whether to use basic attack as fallback when the configured skill cannot be used.
+ bool FallbackBasicAttack { get; }
}
diff --git a/src/GameLogic/MuHelper/PartyRequestHandler.cs b/src/GameLogic/MuHelper/PartyRequestHandler.cs
new file mode 100644
index 000000000..fc9eeda8e
--- /dev/null
+++ b/src/GameLogic/MuHelper/PartyRequestHandler.cs
@@ -0,0 +1,98 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.MuHelper;
+
+using MUnique.OpenMU.Interfaces;
+
+///
+/// Handles auto-accepting incoming party requests from friends and guild members
+/// based on the player's MU Helper settings flags.
+///
+public static class PartyRequestHandler
+{
+ ///
+ /// Automatically accepts a party request if the receiver has the relevant flag enabled.
+ ///
+ /// The player receiving the party request.
+ /// The player who sent the party request.
+ /// True if the request was auto-accepted and the players are in a party; false otherwise.
+ public static async ValueTask TryAutoAcceptPartyRequestAsync(Player receiver, Player requester)
+ {
+ var settings = receiver.MuHelperSettings;
+ if (settings is null)
+ {
+ return false;
+ }
+
+ if (settings.AutoAcceptGuild && AreGuildMembers(receiver, requester))
+ {
+ return await AcceptPartyRequestAsync(receiver, requester).ConfigureAwait(false);
+ }
+
+ if (settings.AutoAcceptFriend && await AreFriendsAsync(receiver, requester).ConfigureAwait(false))
+ {
+ return await AcceptPartyRequestAsync(receiver, requester).ConfigureAwait(false);
+ }
+
+ return false;
+ }
+
+ private static bool AreGuildMembers(Player receiver, Player requester)
+ {
+ return receiver.GuildStatus?.GuildId != null
+ && receiver.GuildStatus.GuildId == requester.GuildStatus?.GuildId;
+ }
+
+ private static async ValueTask AreFriendsAsync(Player receiver, Player requester)
+ {
+ if (receiver.SelectedCharacter is null || requester.SelectedCharacter is null)
+ {
+ return false;
+ }
+
+ var friendServer = (receiver.GameContext as IGameServerContext)?.FriendServer;
+ if (friendServer is null)
+ {
+ return false;
+ }
+
+ return await friendServer.IsFriendAsync(receiver.SelectedCharacter.Name, requester.SelectedCharacter.Name).ConfigureAwait(false);
+ }
+
+ private static async ValueTask AcceptPartyRequestAsync(Player receiver, Player requester)
+ {
+ bool success = false;
+ try
+ {
+ if (receiver.Party != null)
+ {
+ if (requester.Party == null)
+ {
+ // Receiver is the offline party master; add the solo requester to the existing party.
+ success = await receiver.Party.AddAsync(requester).ConfigureAwait(false);
+ }
+ }
+ else if (requester.Party != null)
+ {
+ // Requester already has a party; add the offline receiver to it.
+ success = await requester.Party.AddAsync(receiver).ConfigureAwait(false);
+ }
+ else
+ {
+ // Neither side has a party; create a new one.
+ var party = receiver.GameContext.PartyManager.CreateParty();
+ success = await party.AddAsync(requester).ConfigureAwait(false)
+ && await party.AddAsync(receiver).ConfigureAwait(false);
+ }
+ }
+ finally
+ {
+ await receiver.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false);
+ receiver.LastPartyRequester = null;
+ }
+
+ return success;
+ }
+}
diff --git a/src/GameLogic/Offline/CombatHandler.cs b/src/GameLogic/Offline/CombatHandler.cs
index 330a22b06..cdcea3b54 100644
--- a/src/GameLogic/Offline/CombatHandler.cs
+++ b/src/GameLogic/Offline/CombatHandler.cs
@@ -31,7 +31,6 @@ public sealed class CombatHandler
private readonly OfflinePlayer _player;
private readonly IMuHelperSettings? _config;
private readonly MovementHandler _movementHandler;
- private readonly BuffHandler _buffHandler;
private readonly Point _originPosition;
private readonly ConditionalSkillSlot[] _conditionalSkillSlots;
@@ -46,14 +45,12 @@ public sealed class CombatHandler
/// The offline player.
/// The MU helper settings.
/// The movement handler.
- /// The buff handler.
/// The original position to hunt around.
- public CombatHandler(OfflinePlayer player, IMuHelperSettings? config, MovementHandler movementHandler, BuffHandler buffHandler, Point originPosition)
+ public CombatHandler(OfflinePlayer player, IMuHelperSettings? config, MovementHandler movementHandler, Point originPosition)
{
this._player = player;
this._config = config;
this._movementHandler = movementHandler;
- this._buffHandler = buffHandler;
this._originPosition = originPosition;
this._conditionalSkillSlots = config is null ? [] :
[
@@ -163,14 +160,9 @@ public async ValueTask PerformDrainLifeRecoveryAsync()
private async ValueTask ExecuteAttackAsync(IAttackable target)
{
var skill = this.SelectAttackSkill();
- if (skill == null)
+ if (skill == null && this._config?.FallbackBasicAttack != true)
{
- // Do not attack if there are buffs configured to handle buff-only classes.
- var buffs = this._buffHandler.ConfiguredBuffIds;
- if (buffs.Any(id => id > 0))
- {
- return;
- }
+ return;
}
await this.ExecuteAttackAsync(target, skill, false).ConfigureAwait(false);
@@ -522,6 +514,12 @@ private byte GetEffectiveAttackRange()
}
}
+ if (this._player.Attributes is { } attributes
+ && (attributes[Stats.IsBowEquipped] > 0 || attributes[Stats.IsCrossBowEquipped] > 0))
+ {
+ return 6;
+ }
+
return DefaultRange;
}
diff --git a/src/GameLogic/Offline/OfflinePlayerMuHelper.cs b/src/GameLogic/Offline/OfflinePlayerMuHelper.cs
index 90ea25fe4..2804e3958 100644
--- a/src/GameLogic/Offline/OfflinePlayerMuHelper.cs
+++ b/src/GameLogic/Offline/OfflinePlayerMuHelper.cs
@@ -54,7 +54,7 @@ public OfflinePlayerMuHelper(OfflinePlayer player)
this._healingHandler = new HealingHandler(player, config);
this._itemPickupHandler = new ItemPickupHandler(player, config);
this._movementHandler = new MovementHandler(player, config, originalPosition);
- this._combatHandler = new CombatHandler(player, config, this._movementHandler, this._buffHandler, originalPosition);
+ this._combatHandler = new CombatHandler(player, config, this._movementHandler, originalPosition);
this._repairHandler = new RepairHandler(player, config);
this._zenHandler = new ZenConsumptionHandler(player);
this._petHandler = new PetHandler(player, config);
diff --git a/src/GameLogic/PlayerActions/Party/PartyRequestAction.cs b/src/GameLogic/PlayerActions/Party/PartyRequestAction.cs
index df63f9246..c10bf92ef 100644
--- a/src/GameLogic/PlayerActions/Party/PartyRequestAction.cs
+++ b/src/GameLogic/PlayerActions/Party/PartyRequestAction.cs
@@ -1,9 +1,10 @@
-//
+//
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
namespace MUnique.OpenMU.GameLogic.PlayerActions.Party;
+using MUnique.OpenMU.GameLogic.MuHelper;
using MUnique.OpenMU.GameLogic.Views.Party;
///
@@ -27,6 +28,14 @@ public async ValueTask HandlePartyRequestAsync(Player player, Player toRequest)
if (toRequest.Party != null || toRequest.LastPartyRequester != null)
{
+ if (toRequest.Party != null && Equals(toRequest.Party.PartyMaster, toRequest))
+ {
+ if (await PartyRequestHandler.TryAutoAcceptPartyRequestAsync(toRequest, player).ConfigureAwait(false))
+ {
+ return;
+ }
+ }
+
await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.PlayerIsAlreadyInParty), toRequest.Name).ConfigureAwait(false);
return;
}
@@ -37,6 +46,17 @@ public async ValueTask HandlePartyRequestAsync(Player player, Player toRequest)
return;
}
+ if (toRequest is Offline.OfflinePlayer)
+ {
+ if (await PartyRequestHandler.TryAutoAcceptPartyRequestAsync(toRequest, player).ConfigureAwait(false))
+ {
+ return;
+ }
+
+ await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.PlayerIsAlreadyInParty), toRequest.Name).ConfigureAwait(false);
+ return;
+ }
+
if (await toRequest.PlayerState.TryAdvanceToAsync(PlayerState.PartyRequest).ConfigureAwait(false))
{
await this.SendPartyRequestAsync(toRequest, player).ConfigureAwait(false);
@@ -52,6 +72,15 @@ private async ValueTask SendPartyRequestAsync(IPartyMember toRequest, IPartyMemb
}
toRequest.LastPartyRequester = requester;
+
+ if (toRequest is Player receiver && requester is Player requesterPlayer)
+ {
+ if (await PartyRequestHandler.TryAutoAcceptPartyRequestAsync(receiver, requesterPlayer).ConfigureAwait(false))
+ {
+ return;
+ }
+ }
+
await toRequest.InvokeViewPlugInAsync(p => p.ShowPartyRequestAsync(requester)).ConfigureAwait(false);
}
}
\ No newline at end of file
diff --git a/src/GameServer/MessageHandler/MuHelper/MuHelperSaveDataRequestHandlerPlugin.cs b/src/GameServer/MessageHandler/MuHelper/MuHelperSaveDataRequestHandlerPlugin.cs
index 8a0b5d871..debf81ceb 100644
--- a/src/GameServer/MessageHandler/MuHelper/MuHelperSaveDataRequestHandlerPlugin.cs
+++ b/src/GameServer/MessageHandler/MuHelper/MuHelperSaveDataRequestHandlerPlugin.cs
@@ -8,6 +8,7 @@ namespace MUnique.OpenMU.GameServer.MessageHandler.MuHelper;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.GameLogic.PlayerActions.MuHelper;
+using MUnique.OpenMU.GameServer.RemoteView.MuHelper;
using MUnique.OpenMU.Network.Packets.ClientToServer;
using MUnique.OpenMU.PlugIns;
@@ -36,5 +37,9 @@ public async ValueTask HandlePacketAsync(Player player, Memory packet)
var memory = memoryOwner.Memory[..dataSize];
message.HelperData.CopyTo(memory.Span);
await this._updateMuBotConfigurationAction.SaveDataAsync(player, memory).ConfigureAwait(false);
+ if (player.SelectedCharacter?.MuHelperConfiguration is { } configuration)
+ {
+ player.MuHelperSettings = MuHelperSettingsSerializer.TryDeserialize(configuration);
+ }
}
}
\ No newline at end of file
diff --git a/src/GameServer/RemoteView/MuHelper/MuHelperSettings.cs b/src/GameServer/RemoteView/MuHelper/MuHelperSettings.cs
index d5f4c0412..63a76a247 100644
--- a/src/GameServer/RemoteView/MuHelper/MuHelperSettings.cs
+++ b/src/GameServer/RemoteView/MuHelper/MuHelperSettings.cs
@@ -143,6 +143,18 @@ public sealed class MuHelperSettings : IMuHelperSettings
/// Gets a value indicating whether to repair items.
public bool RepairItem { get; init; }
+ /// Gets a value indicating whether to automatically defend against nearby monsters attacking the character.
+ public bool UseSelfDefense { get; init; }
+
+ /// Gets a value indicating whether to automatically accept friend requests.
+ public bool AutoAcceptFriend { get; init; }
+
+ /// Gets a value indicating whether to automatically accept guild join requests.
+ public bool AutoAcceptGuild { get; init; }
+
+ /// Gets a value indicating whether to use basic attack as fallback when the configured skill cannot be used.
+ public bool FallbackBasicAttack { get; init; }
+
///
public override string ToString()
{
diff --git a/src/GameServer/RemoteView/MuHelper/MuHelperSettingsSerializer.cs b/src/GameServer/RemoteView/MuHelper/MuHelperSettingsSerializer.cs
index d9aa291f8..50e90b39a 100644
--- a/src/GameServer/RemoteView/MuHelper/MuHelperSettingsSerializer.cs
+++ b/src/GameServer/RemoteView/MuHelper/MuHelperSettingsSerializer.cs
@@ -34,7 +34,8 @@ namespace MUnique.OpenMU.GameServer.RemoteView.MuHelper;
/// 27 [Skill2Delay:1][Skill2Con:1][Skill2PreCon:1][Skill2SubCon:2]
/// [RepairItem:1][PickAllNearItems:1][PickSelectedItems:1]
/// 28 PetAttack (BYTE: 0=cease, 1=auto, 2=together)
-/// 29-64 _UnusedPadding[36]
+/// 29 [UseSelfDefense:1][AutoAcceptFriend:1][AutoAcceptGuild:1][FallbackBasicAttack:1][Unused:4]
+/// 30-64 _UnusedPadding[35]
/// 65-244 ExtraItems[12][15] (null-terminated ASCII item name filters)
/// 245-256 (trailing padding, ignored)
///
@@ -61,11 +62,17 @@ public static class MuHelperSettingsSerializer
private const int Skill1FlagsOffset = 26;
private const int Skill2FlagsOffset = 27;
private const int PetAttackOffset = 28;
+ private const int HelperFlagsOffset = 29;
private const int ExtraItemsOffset = 65;
private const int ExtraItemsEndOffset = 245;
private const int ExtraItemSlotCount = 12;
private const int ExtraItemSlotLength = 15;
+ private const int UseSelfDefenseFlag = 1 << 0;
+ private const int AutoAcceptFriendFlag = 1 << 1;
+ private const int AutoAcceptGuildFlag = 1 << 2;
+ private const int FallbackBasicAttackFlag = 1 << 3;
+
private const int PickJewelFlag = 1 << 3;
private const int PickSetItemFlag = 1 << 4;
private const int PickExcellentFlag = 1 << 5;
@@ -180,6 +187,12 @@ public static class MuHelperSettingsSerializer
int petAttack = blob[PetAttackOffset];
+ byte helperFlags = blob[HelperFlagsOffset];
+ bool useSelfDefense = (helperFlags & UseSelfDefenseFlag) != 0;
+ bool autoAcceptFriend = (helperFlags & AutoAcceptFriendFlag) != 0;
+ bool autoAcceptGuild = (helperFlags & AutoAcceptGuildFlag) != 0;
+ bool fallbackBasicAttack = (helperFlags & FallbackBasicAttackFlag) != 0;
+
var extraNames = new List();
if (blob.Length >= ExtraItemsEndOffset)
{
@@ -245,6 +258,10 @@ public static class MuHelperSettingsSerializer
PickExtraItems = extraItem,
ExtraItemNames = extraNames,
RepairItem = repairItem,
+ UseSelfDefense = useSelfDefense,
+ AutoAcceptFriend = autoAcceptFriend,
+ AutoAcceptGuild = autoAcceptGuild,
+ FallbackBasicAttack = fallbackBasicAttack,
};
}
diff --git a/src/Interfaces/IFriendServer.cs b/src/Interfaces/IFriendServer.cs
index 61ed348ef..849a93cb3 100644
--- a/src/Interfaces/IFriendServer.cs
+++ b/src/Interfaces/IFriendServer.cs
@@ -68,6 +68,14 @@ public interface IFriendServer
/// If set to true, the character is visible as online. Otherwise, it appears as offline for other players, but is still online
ValueTask SetPlayerVisibilityStateAsync(byte serverId, Guid characterId, string characterName, bool isVisible);
+ ///
+ /// Determines whether two players are friends (accepted friend relationship).
+ ///
+ /// The character name of the first player.
+ /// The character name of the second player.
+ /// True if the two players are friends; otherwise false.
+ ValueTask IsFriendAsync(string characterName, string friendName);
+
///
/// Sends a friend request to the friend, and adds a new friend view item to the players friend list.
///
diff --git a/src/Persistence/EntityFramework/FriendServerContext.cs b/src/Persistence/EntityFramework/FriendServerContext.cs
index 8de0c8114..384fe8eee 100644
--- a/src/Persistence/EntityFramework/FriendServerContext.cs
+++ b/src/Persistence/EntityFramework/FriendServerContext.cs
@@ -1,4 +1,4 @@
-//
+//
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
diff --git a/src/Persistence/InMemory/FriendServerInMemoryContext.cs b/src/Persistence/InMemory/FriendServerInMemoryContext.cs
index 2ffebe966..1b869d239 100644
--- a/src/Persistence/InMemory/FriendServerInMemoryContext.cs
+++ b/src/Persistence/InMemory/FriendServerInMemoryContext.cs
@@ -1,4 +1,4 @@
-//
+//
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
diff --git a/tests/MUnique.OpenMU.Tests/Offline/CombatHandlerTests.cs b/tests/MUnique.OpenMU.Tests/Offline/CombatHandlerTests.cs
index 0ebe30098..1f5ef007a 100644
--- a/tests/MUnique.OpenMU.Tests/Offline/CombatHandlerTests.cs
+++ b/tests/MUnique.OpenMU.Tests/Offline/CombatHandlerTests.cs
@@ -51,9 +51,8 @@ public async ValueTask PerformAttackAsync_MovesCloserToTargetAsync()
var config = new MuHelperSettings { HuntingRange = 10 };
var movementHandler = new MovementHandler(player, config, this._origin);
- var buffHandler = new BuffHandler(player, config);
- var handler = new CombatHandler(player, config, movementHandler, buffHandler, this._origin);
+ var handler = new CombatHandler(player, config, movementHandler, this._origin);
// Act
await handler.PerformAttackAsync().ConfigureAwait(false);
@@ -95,9 +94,8 @@ public async ValueTask PerformDrainLifeRecoveryAsync_UsesDrainLifeWhenLowHpAsync
await player.SkillList!.AddLearnedSkillAsync(drainSkill).ConfigureAwait(false);
var movementHandler = new MovementHandler(player, config, this._origin);
- var buffHandler = new BuffHandler(player, config);
- var handler = new CombatHandler(player, config, movementHandler, buffHandler, this._origin);
+ var handler = new CombatHandler(player, config, movementHandler, this._origin);
// Act
await handler.PerformDrainLifeRecoveryAsync().ConfigureAwait(false);
diff --git a/tests/MUnique.OpenMU.Tests/Party/PartyTest.cs b/tests/MUnique.OpenMU.Tests/Party/PartyTest.cs
index 786351347..00c28b3aa 100644
--- a/tests/MUnique.OpenMU.Tests/Party/PartyTest.cs
+++ b/tests/MUnique.OpenMU.Tests/Party/PartyTest.cs
@@ -8,8 +8,12 @@ namespace MUnique.OpenMU.Tests;
using Moq;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.MuHelper;
using MUnique.OpenMU.GameLogic.PlayerActions.Party;
using MUnique.OpenMU.GameLogic.Views.Party;
+using MUnique.OpenMU.GameServer;
+using MUnique.OpenMU.Interfaces;
+using MUnique.OpenMU.Pathfinding;
using MUnique.OpenMU.Persistence.InMemory;
using MUnique.OpenMU.PlugIns;
@@ -182,6 +186,194 @@ public async ValueTask PartyResponseAcceptExistingPartyAsync()
Mock.Get(player.ViewPlugIns.GetPlugIn()!).Verify(v => v!.ShowPartyRequestAsync(requester), Times.Never);
}
+ ///
+ /// Tests if a party request is auto-accepted when is true
+ /// and the requester is a friend.
+ ///
+ [Test]
+ public async ValueTask PartyRequestAutoAcceptByFriendAsync()
+ {
+ var friendServer = new Mock();
+ friendServer.Setup(f => f.IsFriendAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true);
+ var gameContext = PartyTest.CreateGameServerContext(friendServer.Object);
+ var player = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false);
+ var toRequest = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false);
+ player.SelectedCharacter!.Name = "Requester";
+ toRequest.SelectedCharacter!.Name = "Receiver";
+ player.Observers.Add(toRequest);
+
+ var settingsMock = new Mock();
+ settingsMock.Setup(s => s.AutoAcceptFriend).Returns(true);
+ toRequest.MuHelperSettings = settingsMock.Object;
+
+ var handler = new PartyRequestAction();
+ await handler.HandlePartyRequestAsync(player, toRequest).ConfigureAwait(false);
+
+ Assert.That(player.Party, Is.Not.Null);
+ Assert.That(toRequest.Party, Is.SameAs(player.Party));
+ Mock.Get(toRequest.ViewPlugIns.GetPlugIn()!).Verify(v => v!.ShowPartyRequestAsync(player), Times.Never);
+ }
+
+ ///
+ /// Tests if a party request still shows the dialog when
+ /// is true but the requester is not a friend.
+ ///
+ [Test]
+ public async ValueTask PartyRequestAutoAcceptByFriendNotFriendAsync()
+ {
+ var friendServer = new Mock();
+ friendServer.Setup(f => f.IsFriendAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false);
+ var gameContext = PartyTest.CreateGameServerContext(friendServer.Object);
+ var player = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false);
+ var toRequest = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false);
+ player.SelectedCharacter!.Name = "Requester";
+ toRequest.SelectedCharacter!.Name = "Receiver";
+ player.Observers.Add(toRequest);
+
+ var settingsMock = new Mock();
+ settingsMock.Setup(s => s.AutoAcceptFriend).Returns(true);
+ toRequest.MuHelperSettings = settingsMock.Object;
+
+ var handler = new PartyRequestAction();
+ await handler.HandlePartyRequestAsync(player, toRequest).ConfigureAwait(false);
+
+ Assert.That(player.Party, Is.Null);
+ Mock.Get(toRequest.ViewPlugIns.GetPlugIn()!).Verify(v => v!.ShowPartyRequestAsync(player), Times.Once);
+ }
+
+ ///
+ /// Tests if a party request is auto-accepted when is true
+ /// and both players are members of the same guild.
+ ///
+ [Test]
+ public async ValueTask PartyRequestAutoAcceptByGuildAsync()
+ {
+ var gameContext = GameContextTestHelper.CreateGameContext();
+ var player = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false);
+ var toRequest = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false);
+ player.Observers.Add(toRequest);
+
+ var guildId = 1u;
+ player.GuildStatus = new GuildMemberStatus(guildId, GuildPosition.GuildMaster);
+ toRequest.GuildStatus = new GuildMemberStatus(guildId, GuildPosition.NormalMember);
+
+ var settingsMock = new Mock();
+ settingsMock.Setup(s => s.AutoAcceptGuild).Returns(true);
+ toRequest.MuHelperSettings = settingsMock.Object;
+
+ var handler = new PartyRequestAction();
+ await handler.HandlePartyRequestAsync(player, toRequest).ConfigureAwait(false);
+
+ Assert.That(player.Party, Is.Not.Null);
+ Assert.That(toRequest.Party, Is.SameAs(player.Party));
+ Mock.Get(toRequest.ViewPlugIns.GetPlugIn()!).Verify(v => v!.ShowPartyRequestAsync(player), Times.Never);
+ }
+
+ ///
+ /// Tests if a party request still shows the dialog when
+ /// is true but the players are in different guilds.
+ ///
+ [Test]
+ public async ValueTask PartyRequestAutoAcceptByGuildDifferentGuildAsync()
+ {
+ var gameContext = GameContextTestHelper.CreateGameContext();
+ var player = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false);
+ var toRequest = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false);
+ player.Observers.Add(toRequest);
+
+ player.GuildStatus = new GuildMemberStatus(1u, GuildPosition.GuildMaster);
+ toRequest.GuildStatus = new GuildMemberStatus(2u, GuildPosition.NormalMember);
+
+ var settingsMock = new Mock();
+ settingsMock.Setup(s => s.AutoAcceptGuild).Returns(true);
+ toRequest.MuHelperSettings = settingsMock.Object;
+
+ var handler = new PartyRequestAction();
+ await handler.HandlePartyRequestAsync(player, toRequest).ConfigureAwait(false);
+
+ Assert.That(player.Party, Is.Null);
+ Mock.Get(toRequest.ViewPlugIns.GetPlugIn()!).Verify(v => v!.ShowPartyRequestAsync(player), Times.Once);
+ }
+
+ ///
+ /// Tests that a party request is not auto-accepted when no relevant flags are set.
+ ///
+ [Test]
+ public async ValueTask PartyRequestAutoAcceptNoFlagsAsync()
+ {
+ var friendServer = new Mock();
+ var gameContext = PartyTest.CreateGameServerContext(friendServer.Object);
+ var player = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false);
+ var toRequest = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false);
+ player.SelectedCharacter!.Name = "Requester";
+ toRequest.SelectedCharacter!.Name = "Receiver";
+ player.Observers.Add(toRequest);
+
+ var settingsMock = new Mock();
+ toRequest.MuHelperSettings = settingsMock.Object;
+
+ var handler = new PartyRequestAction();
+ await handler.HandlePartyRequestAsync(player, toRequest).ConfigureAwait(false);
+
+ Assert.That(player.Party, Is.Null);
+ Mock.Get(toRequest.ViewPlugIns.GetPlugIn()!).Verify(v => v!.ShowPartyRequestAsync(player), Times.Once);
+ }
+
+ ///
+ /// Tests that a party request is not auto-accepted when MuHelperSettings is null.
+ ///
+ [Test]
+ public async ValueTask PartyRequestAutoAcceptNoSettingsAsync()
+ {
+ var friendServer = new Mock();
+ var gameContext = PartyTest.CreateGameServerContext(friendServer.Object);
+ var player = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false);
+ var toRequest = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false);
+ player.Observers.Add(toRequest);
+
+ var handler = new PartyRequestAction();
+ await handler.HandlePartyRequestAsync(player, toRequest).ConfigureAwait(false);
+
+ Assert.That(player.Party, Is.Null);
+ Mock.Get(toRequest.ViewPlugIns.GetPlugIn()!).Verify(v => v!.ShowPartyRequestAsync(player), Times.Once);
+ }
+
+ private static IGameServerContext CreateGameServerContext(IFriendServer friendServer)
+ {
+ var contextProvider = new InMemoryPersistenceContextProvider();
+ var context = contextProvider.CreateNewContext();
+ var gameConfiguration = context.CreateNew();
+ gameConfiguration.MaximumPartySize = 5;
+ gameConfiguration.RecoveryInterval = int.MaxValue;
+ gameConfiguration.MaximumInventoryMoney = int.MaxValue;
+ var mapDef = context.CreateNew();
+ mapDef.Number = 0;
+ mapDef.TerrainData = new byte[ushort.MaxValue + 3];
+ gameConfiguration.Maps.Add(mapDef);
+
+ var mapInitializer = new MapInitializer(gameConfiguration, new NullLogger(), NullDropGenerator.Instance, null);
+
+ var gameServer = new GameServerContext(
+ new GameServerDefinition
+ {
+ GameConfiguration = gameConfiguration,
+ ServerConfiguration = new GameServerConfiguration(),
+ },
+ new Mock().Object,
+ new Mock().Object,
+ new Mock().Object,
+ friendServer,
+ new InMemoryPersistenceContextProvider(),
+ mapInitializer,
+ new NullLoggerFactory(),
+ new PlugInManager(new List(), new NullLoggerFactory(), null, null),
+ NullDropGenerator.Instance,
+ new ConfigurationChangeMediator());
+ mapInitializer.PlugInManager = gameServer.PlugInManager;
+ mapInitializer.PathFinderPool = gameServer.PathFinderPool;
+ return gameServer;
+ }
+
private async ValueTask CreatePartyMemberAsync()
{
var result = await PlayerTestHelper.CreatePlayerAsync(GameContextTestHelper.CreateGameContext()).ConfigureAwait(false);
From 6816fd2cbb4361d1472910ebf152a7dbaaeae2e3 Mon Sep 17 00:00:00 2001
From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com>
Date: Mon, 1 Jun 2026 00:23:06 -0300
Subject: [PATCH 2/3] fix: don't fall through to manual party dialog when
auto-accept fails after state cleanup
---
src/GameLogic/MuHelper/PartyRequestHandler.cs | 8 ++--
.../RemoteView/MuHelper/MuHelperSettings.cs | 42 +++++++++----------
2 files changed, 26 insertions(+), 24 deletions(-)
diff --git a/src/GameLogic/MuHelper/PartyRequestHandler.cs b/src/GameLogic/MuHelper/PartyRequestHandler.cs
index fc9eeda8e..ff190b024 100644
--- a/src/GameLogic/MuHelper/PartyRequestHandler.cs
+++ b/src/GameLogic/MuHelper/PartyRequestHandler.cs
@@ -17,7 +17,7 @@ public static class PartyRequestHandler
///
/// The player receiving the party request.
/// The player who sent the party request.
- /// True if the request was auto-accepted and the players are in a party; false otherwise.
+ /// True if the criteria matched (auto-accept was attempted, regardless of success); false if no criteria matched.
public static async ValueTask TryAutoAcceptPartyRequestAsync(Player receiver, Player requester)
{
var settings = receiver.MuHelperSettings;
@@ -28,12 +28,14 @@ public static async ValueTask TryAutoAcceptPartyRequestAsync(Player receiv
if (settings.AutoAcceptGuild && AreGuildMembers(receiver, requester))
{
- return await AcceptPartyRequestAsync(receiver, requester).ConfigureAwait(false);
+ await AcceptPartyRequestAsync(receiver, requester).ConfigureAwait(false);
+ return true;
}
if (settings.AutoAcceptFriend && await AreFriendsAsync(receiver, requester).ConfigureAwait(false))
{
- return await AcceptPartyRequestAsync(receiver, requester).ConfigureAwait(false);
+ await AcceptPartyRequestAsync(receiver, requester).ConfigureAwait(false);
+ return true;
}
return false;
diff --git a/src/GameServer/RemoteView/MuHelper/MuHelperSettings.cs b/src/GameServer/RemoteView/MuHelper/MuHelperSettings.cs
index 63a76a247..7cd0cc3cf 100644
--- a/src/GameServer/RemoteView/MuHelper/MuHelperSettings.cs
+++ b/src/GameServer/RemoteView/MuHelper/MuHelperSettings.cs
@@ -26,10 +26,10 @@ public sealed class MuHelperSettings : IMuHelperSettings
/// Gets the timer interval (seconds) for ActivationSkill2 when Skill2Delay is set.
public int DelayMinSkill2 { get; init; }
- /// Gets a value indicating whether to use timer for the skill 1.
+ /// Gets a value indicating whether to use a timer for skill 1.
public bool Skill1UseTimer { get; init; }
- /// Gets a value indicating whether to use condition for the skill 1.
+ /// Gets a value indicating whether to use a condition for skill 1.
public bool Skill1UseCondition { get; init; }
/// Gets a value indicating whether to use the precondition for Skill1 (false = nearby, true = attacking).
@@ -38,10 +38,10 @@ public sealed class MuHelperSettings : IMuHelperSettings
/// Gets the mob count threshold for Skill1 condition: 0=2+, 1=3+, 2=4+, 3=5+.
public int Skill1SubCondition { get; init; }
- /// Gets a value indicating whether to use timer for the skill 2.
+ /// Gets a value indicating whether to use a timer for skill 2.
public bool Skill2UseTimer { get; init; }
- /// Gets a value indicating whether to use condition for the skill 2.
+ /// Gets a value indicating whether to use a condition for skill 2.
public bool Skill2UseCondition { get; init; }
/// Gets a value indicating whether to use the precondition for Skill2 (false = nearby, true = attacking).
@@ -56,13 +56,13 @@ public sealed class MuHelperSettings : IMuHelperSettings
/// Gets the hunting range nibble (0-15); multiply to get tile distance.
public int HuntingRange { get; init; }
- /// Gets the max seconds away from original position before regrouping.
+ /// Gets the max seconds away from the original position before regrouping.
public int MaxSecondsAway { get; init; }
/// Gets a value indicating whether to counter-attack enemies that attack from long range.
public bool LongRangeCounterAttack { get; init; }
- /// Gets a value indicating whether to return to original spawn position when away too long.
+ /// Gets a value indicating whether to return to the original spawn position when away too long.
public bool ReturnToOriginalPosition { get; init; }
/// Gets the Buff 0 skill id.
@@ -74,10 +74,10 @@ public sealed class MuHelperSettings : IMuHelperSettings
/// Gets the Buff 2 skill id.
public int BuffSkill2Id { get; init; }
- /// Gets a value indicating whether apply buffs based on duration (i.e. when the buff expires).
+ /// Gets a value indicating whether to apply buffs based on duration (i.e. when the buff expires).
public bool BuffOnDuration { get; init; }
- /// Gets a value indicating whether apply buff duration logic to party members too.
+ /// Gets a value indicating whether to apply buff duration logic to party members too.
public bool BuffDurationForParty { get; init; }
/// Gets the buff cast interval in seconds (0 = disabled).
@@ -92,13 +92,13 @@ public sealed class MuHelperSettings : IMuHelperSettings
/// Gets a value indicating whether to use drain life.
public bool UseDrainLife { get; init; }
- /// Gets a value indicating whether to use healing potion.
+ /// Gets a value indicating whether to use a healing potion.
public bool UseHealPotion { get; init; }
/// Gets the potion use threshold (% HP).
public int PotionThresholdPercent { get; init; }
- /// Gets a value indicating whether to support party.
+ /// Gets a value indicating whether to support a party.
public bool SupportParty { get; init; }
/// Gets a value indicating whether to auto heal party.
@@ -110,31 +110,31 @@ public sealed class MuHelperSettings : IMuHelperSettings
/// Gets a value indicating whether to use dark raven.
public bool UseDarkRaven { get; init; }
- /// Gets the dark raven mode 0 = cease, 1 = auto-attack, 2 = attack with owner.
+ /// Gets the dark raven mode 0 = cease, 1 = auto-attack, 2 = attack with an owner.
public int DarkRavenMode { get; init; }
- /// Gets the obtain range.
+ /// Gets the range to obtain.
public int ObtainRange { get; init; }
- /// Gets a value indicating whether pickup all items.
+ /// Gets a value indicating whether to pick up all items.
public bool PickAllItems { get; init; }
- /// Gets a value indicating whether pickup selected items.
+ /// Gets a value indicating whether to pick up selected items.
public bool PickSelectItems { get; init; }
- /// Gets a value indicating whether pickup jewels.
+ /// Gets a value indicating whether to pick up jewels.
public bool PickJewel { get; init; }
- /// Gets a value indicating whether pickup zen.
+ /// Gets a value indicating whether to pick up zen.
public bool PickZen { get; init; }
- /// Gets a value indicating whether pickup ancient items.
+ /// Gets a value indicating whether to pick up ancient items.
public bool PickAncient { get; init; }
- /// Gets a value indicating whether pickup excellent items.
+ /// Gets a value indicating whether to pick up excellent items.
public bool PickExcellent { get; init; }
- /// Gets a value indicating whether pickup extra items.
+ /// Gets a value indicating whether to pick up extra items.
public bool PickExtraItems { get; init; }
/// Gets the extra item names. Up to 12 item name substrings; pick any dropped item whose name contains one of these.
@@ -146,10 +146,10 @@ public sealed class MuHelperSettings : IMuHelperSettings
/// Gets a value indicating whether to automatically defend against nearby monsters attacking the character.
public bool UseSelfDefense { get; init; }
- /// Gets a value indicating whether to automatically accept friend requests.
+ /// Gets a value indicating whether to automatically accept requests from friends.
public bool AutoAcceptFriend { get; init; }
- /// Gets a value indicating whether to automatically accept guild join requests.
+ /// Gets a value indicating whether to automatically accept requests from guild.
public bool AutoAcceptGuild { get; init; }
/// Gets a value indicating whether to use basic attack as fallback when the configured skill cannot be used.
From a8297e78e3744310a8ff7f9377f4c153ed88cc57 Mon Sep 17 00:00:00 2001
From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com>
Date: Tue, 2 Jun 2026 11:40:34 -0300
Subject: [PATCH 3/3] add const for bow range in CombatHandler
---
src/GameLogic/Offline/CombatHandler.cs | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/src/GameLogic/Offline/CombatHandler.cs b/src/GameLogic/Offline/CombatHandler.cs
index cdcea3b54..a2caeca5b 100644
--- a/src/GameLogic/Offline/CombatHandler.cs
+++ b/src/GameLogic/Offline/CombatHandler.cs
@@ -18,16 +18,17 @@ namespace MUnique.OpenMU.GameLogic.Offline;
public sealed class CombatHandler
{
private const byte DefaultRange = 1;
+ private const byte BowRange = 6;
private const int ComboFinisherDelayTicks = 3;
private const int InterSkillDelayTicks = 1;
private const int MinComboSkillCount = 3;
- private static readonly TargetedSkillDefaultPlugin DefaultPlugin = new();
-
private const short DrainLifeBaseSkillId = 214;
private const short DrainLifeStrengthenerSkillId = 458;
private const short DrainLifeMasterySkillId = 462;
+ private static readonly TargetedSkillDefaultPlugin DefaultPlugin = new();
+
private readonly OfflinePlayer _player;
private readonly IMuHelperSettings? _config;
private readonly MovementHandler _movementHandler;
@@ -517,7 +518,7 @@ private byte GetEffectiveAttackRange()
if (this._player.Attributes is { } attributes
&& (attributes[Stats.IsBowEquipped] > 0 || attributes[Stats.IsCrossBowEquipped] > 0))
{
- return 6;
+ return BowRange;
}
return DefaultRange;