diff --git a/Projects/UOContent/Engines/Harvest/Fishing.cs b/Projects/UOContent/Engines/Harvest/Fishing.cs
index 6b3ef49ae..f4c04bf9b 100644
--- a/Projects/UOContent/Engines/Harvest/Fishing.cs
+++ b/Projects/UOContent/Engines/Harvest/Fishing.cs
@@ -3,6 +3,7 @@ using Server.Engines.Quests.Collector;
using Server.Items;
using Server.Mobiles;
using Server.Spells;
+using Server.Engines.AntiBot;
namespace Server.Engines.Harvest
{
@@ -492,6 +493,17 @@ namespace Server.Engines.Harvest
return false;
}
+ if (Utility.Random(100) < 1)
+ {
+ if (!AntiBotSystem.CheckPlayer(from, () =>
+ {
+ from.Target = new HarvestTarget(tool, this);
+ }))
+ {
+ return false; // antibot challenge sent
+ }
+ }
+
from.SendLocalizedMessage(500974); // What water do you want to fish in?
return true;
}
diff --git a/Projects/UOContent/Engines/Harvest/Lumberjacking.cs b/Projects/UOContent/Engines/Harvest/Lumberjacking.cs
index 9640f68d8..b850d07dd 100644
--- a/Projects/UOContent/Engines/Harvest/Lumberjacking.cs
+++ b/Projects/UOContent/Engines/Harvest/Lumberjacking.cs
@@ -1,6 +1,7 @@
using System;
using Server.Items;
using Server.Targeting;
+using Server.Engines.AntiBot;
namespace Server.Engines.Harvest
{
@@ -185,6 +186,17 @@ namespace Server.Engines.Harvest
{
from.RevealingAction();
}
+
+ if (Utility.Random(100) < 1)
+ {
+ if (!AntiBotSystem.CheckPlayer(from, () =>
+ {
+ from.Target = new HarvestTarget(tool, this);
+ }))
+ {
+ // antibot challenge sent
+ }
+ }
}
public static void Initialize()
diff --git a/Projects/UOContent/Engines/Harvest/Mining.cs b/Projects/UOContent/Engines/Harvest/Mining.cs
index dd7f5c747..33970951d 100644
--- a/Projects/UOContent/Engines/Harvest/Mining.cs
+++ b/Projects/UOContent/Engines/Harvest/Mining.cs
@@ -2,6 +2,7 @@ using System;
using Server.Items;
using Server.Mobiles;
using Server.Targeting;
+using Server.Engines.AntiBot;
namespace Server.Engines.Harvest
{
@@ -444,6 +445,17 @@ namespace Server.Engines.Harvest
return false;
}
+ if (Utility.Random(100) < 1)
+ {
+ if (!AntiBotSystem.CheckPlayer(from, () =>
+ {
+ from.Target = new HarvestTarget(tool, this);
+ }))
+ {
+ // antibot challenge sent
+ }
+ }
+
from.SendLocalizedMessage(503033); // Where do you wish to dig?
return true;
}
diff --git a/Projects/UOContent/Systems/AntiBotSystem/AntiBotGump.cs b/Projects/UOContent/Systems/AntiBotSystem/AntiBotGump.cs
new file mode 100644
index 000000000..559692a39
--- /dev/null
+++ b/Projects/UOContent/Systems/AntiBotSystem/AntiBotGump.cs
@@ -0,0 +1,77 @@
+/*************************************************************************
+ * ModernUO *
+ * Copyright 2019-2025 - ModernUO Development Team *
+ * Email: hi@modernuo.com *
+ * File: AntiBotGump.cs *
+ * *
+ * This program is free software: you can redistribute it and/or modify *
+ * it under the terms of the GNU General Public License as published by *
+ * the Free Software Foundation, either version 3 of the License, or *
+ * (at your option) any later version. *
+ * *
+ * You should have received a copy of the GNU General Public License *
+ * along with this program. If not, see . *
+ *************************************************************************/
+
+using Server.Gumps;
+using Server.Network;
+
+namespace Server.Engines.AntiBot
+{
+ public class AntiBotGump : Gump
+ {
+ public readonly Mobile _mobile;
+ public readonly int _code;
+
+ public AntiBotGump(Mobile mobile, int code) : base(150, 150)
+ {
+ _mobile = mobile;
+ _code = code;
+
+ Closable = false;
+ Disposable = false;
+ Draggable = true;
+ Resizable = false;
+
+ AddPage(0);
+ AddBackground(0, 0, 350, 220, 9270);
+
+ AddHtml(20, 20, 310, 25, "
Anti-Bot Verification", false, false);
+ AddHtml(20, 50, 310, 40, "Please enter the following number:", false, false);
+ AddHtml(20, 55, 310, 40, $"
{code}", false, false);
+ AddHtml(20, 100, 310, 50, "You have 5 minutes to input the correct number.
Incorrect numbers, cancellations, or timeouts will disconnect you from the server.", false, false);
+
+ AddBackground(20, 160, 200, 25, 3000);
+ AddTextEntry(25, 165, 190, 20, 0, 0, "");
+
+ AddButton(230, 160, 4005, 4007, 1, GumpButtonType.Reply, 0); // Submit
+ AddHtml(230, 190, 40, 20, "Submit", false, false);
+
+ AddButton(290, 160, 4017, 4019, 0, GumpButtonType.Reply, 0); // Cancel
+ AddHtml(290, 190, 40, 20, "Cancel", false, false);
+ }
+
+ public override void OnResponse(NetState sender, in RelayInfo info)
+ {
+ var from = sender?.Mobile;
+ if (from == null)
+ {
+ return;
+ }
+
+ bool cancelled = info.ButtonID == 0;
+ int enteredCode = 0;
+
+ if (!cancelled)
+ {
+ var textEntry = info.GetTextEntry(0);
+ if (textEntry != null && !int.TryParse(textEntry.Trim(), out enteredCode))
+ {
+ enteredCode = -1;
+ }
+ }
+
+ AntiBotSystem.ProcessResponse(from, enteredCode, cancelled);
+ }
+ }
+}
diff --git a/Projects/UOContent/Systems/AntiBotSystem/AntiBotSystem.cs b/Projects/UOContent/Systems/AntiBotSystem/AntiBotSystem.cs
new file mode 100644
index 000000000..c2224d105
--- /dev/null
+++ b/Projects/UOContent/Systems/AntiBotSystem/AntiBotSystem.cs
@@ -0,0 +1,233 @@
+/*************************************************************************
+ * ModernUO *
+ * Copyright 2019-2025 - ModernUO Development Team *
+ * Email: hi@modernuo.com *
+ * File: AntiBotSystem.cs *
+ * *
+ * This program is free software: you can redistribute it and/or modify *
+ * it under the terms of the GNU General Public License as published by *
+ * the Free Software Foundation, either version 3 of the License, or *
+ * (at your option) any later version. *
+ * *
+ * You should have received a copy of the GNU General Public License *
+ * along with this program. If not, see . *
+ *************************************************************************/
+
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Server.Gumps;
+using Server.Mobiles;
+
+namespace Server.Engines.AntiBot
+{
+ public static class AntiBotSystem
+ {
+ private class AntiBotChallenge
+ {
+ public string ChallengeId { get; set; }
+ public DateTime ChallengeExpiry { get; set; }
+ public Action SuccessCallback { get; set; }
+ public Timer TimeoutTimer { get; set; }
+ public bool UseTurnstile { get; set; }
+ public int FallbackCode { get; set; }
+ }
+
+ private static readonly Dictionary _activeChallenges = new();
+ private static readonly HttpClient _httpClient = new();
+
+ // enable or disable the entire anti-bot verification system
+ public static bool Enabled { get; set; } = true;
+
+ // if set to false (default) = uses a number matching verification
+ // if set to true = uses Cloudflare's Turnstile verification
+ public static bool UseTurnstile { get; set; } = false;
+
+ // Cloudflare Turnstile
+ // secret key from your Cloudflare account (https://dash.cloudflare.com/login)
+ public static string TurnstileSecretKey { get; set; } = "YOUR_SECRET_KEY";
+
+ // the base URL where the widget is hosted (must support HTTPS)
+ // view the docs here: https://developers.cloudflare.com/turnstile/
+ public static string VerificationUrl { get; set; } = "https://yourwebserver.com/verify";
+
+ // timeout before disconnecting the user (applies to both Turnstile and number match verification)
+ public static TimeSpan ChallengeTimeout { get; set; } = TimeSpan.FromMinutes(5);
+
+ public static bool CheckPlayer(Mobile from, Action onSuccess)
+ {
+ if (!Enabled || from is not PlayerMobile)
+ {
+ return true;
+ }
+
+ CleanupExpiredChallenges();
+
+ if (_activeChallenges.ContainsKey(from))
+ {
+ return false;
+ }
+
+ var challengeId = Guid.NewGuid().ToString("N")[..8];
+ var challenge = new AntiBotChallenge
+ {
+ ChallengeId = challengeId,
+ ChallengeExpiry = Core.Now.Add(ChallengeTimeout),
+ SuccessCallback = onSuccess,
+ UseTurnstile = UseTurnstile,
+ FallbackCode = Utility.RandomMinMax(1000, 9999)
+ };
+
+ challenge.TimeoutTimer = Timer.DelayCall(ChallengeTimeout, () =>
+ {
+ if (_activeChallenges.ContainsKey(from))
+ {
+ _activeChallenges.Remove(from);
+ from.SendMessage("Anti-Bot: Verification timed out. Disconnecting...");
+ from.NetState?.Disconnect("Anti-Bot: Verification failed by timing out.");
+ }
+ });
+
+ _activeChallenges[from] = challenge;
+
+ if (UseTurnstile)
+ {
+ from.CloseGump();
+ from.SendGump(new AntiBotTurnstileGump(from, challengeId));
+ }
+ else
+ {
+ from.CloseGump();
+ from.SendGump(new AntiBotGump(from, challenge.FallbackCode));
+ }
+
+ return false;
+ }
+
+ public static async Task VerifyTurnstileToken(string token)
+ {
+ var formData = new List>
+ {
+ new("secret", TurnstileSecretKey),
+ new("response", token)
+ };
+
+ var response = await _httpClient.PostAsync(
+ "https://challenges.cloudflare.com/turnstile/v0/siteverify",
+ new FormUrlEncodedContent(formData)
+ );
+
+ var jsonResponse = await response.Content.ReadAsStringAsync();
+ var result = JsonSerializer.Deserialize(jsonResponse);
+
+ return result?.Success == true;
+ }
+
+ internal static void ProcessResponse(Mobile from, int enteredCode, bool cancelled)
+ {
+ if (!_activeChallenges.TryGetValue(from, out var challenge))
+ {
+ return;
+ }
+
+ challenge.TimeoutTimer?.Stop();
+ _activeChallenges.Remove(from);
+
+ if (cancelled)
+ {
+ from.SendMessage("Anti-Bot: Verification cancelled. Disconnecting...");
+ from.NetState?.Disconnect("Anti-Bot: Verification failed by cancellation.");
+ return;
+ }
+
+ if (enteredCode == challenge.FallbackCode)
+ {
+ from.SendMessage("Anti-Bot: Verification successful!");
+ challenge.SuccessCallback?.Invoke();
+ }
+ else
+ {
+ from.SendMessage("Anti-Bot: Incorrect number. Disconnecting...");
+ from.NetState?.Disconnect("Anti-Bot: Verification failed by incorrect number.");
+ }
+ }
+
+ internal static async void ProcessTurnstileResponse(Mobile from, string token)
+ {
+ if (!_activeChallenges.TryGetValue(from, out var challenge))
+ {
+ return;
+ }
+
+ challenge.TimeoutTimer?.Stop();
+ _activeChallenges.Remove(from);
+
+ var isValid = await VerifyTurnstileToken(token);
+
+ if (isValid)
+ {
+ from.SendMessage("Anti-Bot: Verification successful!");
+ challenge.SuccessCallback?.Invoke();
+ }
+ else
+ {
+ from.SendMessage("Anti-Bot: Verification failed. Disconnecting...");
+ from.NetState?.Disconnect("Anti-Bot: Verification failed.");
+ }
+ }
+
+ public static void ProcessTurnstileVerification(string challengeId, string token)
+ {
+ Mobile targetMobile = null;
+ foreach (var kvp in _activeChallenges)
+ {
+ if (kvp.Value.ChallengeId == challengeId)
+ {
+ targetMobile = kvp.Key;
+ break;
+ }
+ }
+
+ if (targetMobile != null)
+ {
+ ProcessTurnstileResponse(targetMobile, token);
+ }
+ }
+
+ private static void CleanupExpiredChallenges()
+ {
+ var now = Core.Now;
+ var toRemove = new List();
+
+ foreach (var kvp in _activeChallenges)
+ {
+ if (kvp.Value.ChallengeExpiry < now)
+ {
+ kvp.Value.TimeoutTimer?.Stop();
+ toRemove.Add(kvp.Key);
+ }
+ }
+
+ foreach (var mobile in toRemove)
+ {
+ _activeChallenges.Remove(mobile);
+ }
+ }
+
+ public static void CancelChallenge(Mobile from)
+ {
+ if (_activeChallenges.TryGetValue(from, out var challenge))
+ {
+ challenge.TimeoutTimer?.Stop();
+ _activeChallenges.Remove(from);
+ }
+ }
+
+ private class TurnstileResponse
+ {
+ public bool Success { get; set; }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Projects/UOContent/Systems/AntiBotSystem/AntiBotTurnstileGump.cs b/Projects/UOContent/Systems/AntiBotSystem/AntiBotTurnstileGump.cs
new file mode 100644
index 000000000..22cc4c3ab
--- /dev/null
+++ b/Projects/UOContent/Systems/AntiBotSystem/AntiBotTurnstileGump.cs
@@ -0,0 +1,85 @@
+/*************************************************************************
+ * ModernUO *
+ * Copyright 2019-2025 - ModernUO Development Team *
+ * Email: hi@modernuo.com *
+ * File: AntiBotTurnstileGump.cs *
+ * *
+ * This program is free software: you can redistribute it and/or modify *
+ * it under the terms of the GNU General Public License as published by *
+ * the Free Software Foundation, either version 3 of the License, or *
+ * (at your option) any later version. *
+ * *
+ * You should have received a copy of the GNU General Public License *
+ * along with this program. If not, see . *
+ *************************************************************************/
+
+using Server.Gumps;
+using Server.Network;
+using System.Diagnostics;
+
+namespace Server.Engines.AntiBot
+{
+ public class AntiBotTurnstileGump : Gump
+ {
+ private readonly Mobile _from;
+ private readonly string _challengeId;
+
+ public AntiBotTurnstileGump(Mobile from, string challengeId) : base(150, 150)
+ {
+ _from = from;
+ _challengeId = challengeId;
+
+ Closable = false;
+ Disposable = false;
+ Draggable = true;
+ Resizable = false;
+
+ AddPage(0);
+ AddBackground(0, 0, 350, 220, 9270);
+
+ AddHtml(20, 20, 310, 25, "Anti-Bot Verification", false, false);
+ AddHtml(20, 50, 310, 40, "Please verify by web browser:", false, false);
+ AddHtml(20, 75, 310, 40, $"{AntiBotSystem.VerificationUrl}?id={challengeId}", false, false);
+ AddHtml(20, 100, 310, 50, "You have 5 minutes to complete verification.
Cancelling or timing out will disconnect you from the server.", false, false);
+
+ AddButton(30, 170, 4005, 4007, 1, GumpButtonType.Reply, 0); // Open Browser
+ AddHtml(60, 173, 100, 20, "Open Browser", false, false);
+
+ AddButton(230, 170, 4017, 4019, 0, GumpButtonType.Reply, 0); // Cancel
+ AddHtml(260, 173, 60, 20, "Cancel", false, false);
+ }
+
+ public override void OnResponse(NetState sender, in RelayInfo info)
+ {
+ var from = sender?.Mobile;
+ if (from == null)
+ {
+ return;
+ }
+
+ switch (info.ButtonID)
+ {
+ case 1: // Open Browser
+ try
+ {
+ var url = $"{AntiBotSystem.VerificationUrl}?id={_challengeId}";
+ Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
+ _from.SendMessage("Browser opened. Complete verification and return to game.");
+ _from.SendGump(this); // Keep gump open
+ }
+ catch
+ {
+ _from.SendMessage("Could not open browser. Please visit the verification URL manually.");
+ _from.SendGump(this); // Keep gump open
+ }
+ break;
+
+ case 0: // Cancel
+ AntiBotSystem.CancelChallenge(_from);
+ _from.SendMessage("Anti-Bot: Verification cancelled. Disconnecting...");
+ _from.NetState?.Disconnect("Anti-Bot: Verification cancelled.");
+ break;
+ }
+ }
+ }
+}
\ No newline at end of file