diff --git a/src/GameLogic/PlugIns/SpeedHackDetectPlugIn.cs b/src/GameLogic/PlugIns/SpeedHackDetectPlugIn.cs index 1f5fc33..85b27bd 100644 --- a/src/GameLogic/PlugIns/SpeedHackDetectPlugIn.cs +++ b/src/GameLogic/PlugIns/SpeedHackDetectPlugIn.cs @@ -36,7 +36,7 @@ public class SpeedHackDetectPlugIn : IFeaturePlugIn, ISupportCustomConfiguration /// public async ValueTask WalkCheatCheckAsync(Player player, Memory steps, SpeedHackCheckEventArgs eventArgs) { - if (steps.IsEmpty) + if (steps.IsEmpty || IsServerControlled(player)) { return; } @@ -139,7 +139,7 @@ public class SpeedHackDetectPlugIn : IFeaturePlugIn, ISupportCustomConfiguration /// public async ValueTask AttackCheatCheckAsync(Player player, SpeedHackCheckEventArgs eventArgs) { - if (player.Attributes is not { } attributes) + if (player.Attributes is not { } attributes || IsServerControlled(player)) { return; } @@ -229,6 +229,19 @@ public class SpeedHackDetectPlugIn : IFeaturePlugIn, ISupportCustomConfiguration } } + /// + /// Determines whether the player's actions originate on the server instead of a game client. + /// These checks validate what a client claims about its own timing, so there is nothing to + /// validate for an offline player: the server itself paces its walks and attacks. Its MU Helper + /// tick performs the same work cycle as the original client helper (recover, then attack), which + /// draws two attack tokens from one 500 ms tick and eventually empties the bucket - so leaving + /// these players in would only ever produce false positives, and with the default configuration + /// those are answered with a persisted account ban. + /// + /// The player. + /// true if the player is controlled by the server; otherwise, false. + private static bool IsServerControlled(Player player) => player is Offline.OfflinePlayer; + private SpeedHackState GetState(Player player) { return this._playerStates.GetValue(player, p => new SpeedHackState(this.Configuration?.MaxAttackTokens ?? 5.0)); diff --git a/tests/MUnique.OpenMU.Tests/SpeedHackAntiCheatTests.cs b/tests/MUnique.OpenMU.Tests/SpeedHackAntiCheatTests.cs index 566af91..ed425ba 100644 --- a/tests/MUnique.OpenMU.Tests/SpeedHackAntiCheatTests.cs +++ b/tests/MUnique.OpenMU.Tests/SpeedHackAntiCheatTests.cs @@ -16,6 +16,7 @@ using MUnique.OpenMU.DataModel.Configuration.Items; using MUnique.OpenMU.DataModel.Entities; using MUnique.OpenMU.GameLogic; using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.Offline; using MUnique.OpenMU.GameLogic.Views; using MUnique.OpenMU.GameLogic.Views.World; using MUnique.OpenMU.Pathfinding; @@ -389,6 +390,70 @@ public class SpeedHackAntiCheatTests Assert.That(player.GameContext.FeaturePlugIns.GetPlugIn()!.GetWarningCount(player), Is.GreaterThan(5)); } + /// + /// Tests that an offline player - whose walks are issued by the server itself - is not checked at all. + /// + [Test] + public async Task TestOfflinePlayerIsNotCheckedOnWalkAsync() + { + var player = await CreatePlayerWithSpeedAttributesAsync().ConfigureAwait(false); + var offlinePlayer = await CreateOfflinePlayerAsync(player).ConfigureAwait(false); + var plugin = player.GameContext.FeaturePlugIns.GetPlugIn()!; + + // The same walk pattern which bans a regular player in TestWalkSpeedHackDetectionBansAccountAsync. + for (int i = 0; i < 12; i++) + { + var nextFrom = new Point((byte)(StartPoint.X + (i * 2)), StartPoint.Y); + var nextTo = new Point((byte)(StartPoint.X + (i * 2) + 2), StartPoint.Y); + + offlinePlayer.Position = nextFrom; + plugin.SetLastAlertTime(offlinePlayer, DateTime.MinValue); + + WalkingStep[] steps = [new() { From = nextFrom, To = nextTo, Direction = Direction.East }]; + + await offlinePlayer.WalkToAsync(nextTo, steps).ConfigureAwait(false); + } + + Assert.That(plugin.GetWarningCount(offlinePlayer), Is.EqualTo(0)); + Assert.That(offlinePlayer.Account?.State, Is.EqualTo(AccountState.Normal)); + } + + /// + /// Tests that an offline player is not checked on attacks. Its MU Helper tick performs the same work + /// cycle as the original client helper (recover, then attack), which draws more than one token from a + /// single tick and would eventually empty the bucket. + /// + [Test] + public async Task TestOfflinePlayerIsNotCheckedOnAttackAsync() + { + var player = await CreatePlayerWithSpeedAttributesAsync().ConfigureAwait(false); + var offlinePlayer = await CreateOfflinePlayerAsync(player).ConfigureAwait(false); + offlinePlayer.Attributes![Stats.AttackSpeed] = 20; + + var speedCheck = player.GameContext.PlugInManager.GetPlugInPoint()!; + + // The same burst which is detected for a regular player in TestAttackSpeedHackDetectionAsync. + for (int i = 0; i < 20; i++) + { + var args = new SpeedHackCheckEventArgs(); + await speedCheck.AttackCheatCheckAsync(offlinePlayer, args).ConfigureAwait(false); + Assert.That(args.IsCheatDetected, Is.False); + } + + Assert.That(player.GameContext.FeaturePlugIns.GetPlugIn()!.GetWarningCount(offlinePlayer), Is.EqualTo(0)); + Assert.That(offlinePlayer.Account?.State, Is.EqualTo(AccountState.Normal)); + } + + private static async ValueTask CreateOfflinePlayerAsync(Player regularPlayer) + { + var offlinePlayer = new OfflinePlayer(regularPlayer.GameContext) { Account = regularPlayer.Account }; + await offlinePlayer.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false); + await offlinePlayer.PlayerState.TryAdvanceToAsync(PlayerState.Authenticated).ConfigureAwait(false); + await offlinePlayer.PlayerState.TryAdvanceToAsync(PlayerState.CharacterSelection).ConfigureAwait(false); + await offlinePlayer.SetSelectedCharacterAsync(regularPlayer.SelectedCharacter!).ConfigureAwait(false); + return offlinePlayer; + } + private static async ValueTask CreatePlayerWithSpeedAttributesAsync(List? inventoryItems = null) { var gameConfig = new Mock();