Merge branch 'main' into feat/antibot-system

This commit is contained in:
Kamron Batman 2025-11-16 16:49:34 -08:00 committed by GitHub
commit 3eaacb4bed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 332 additions and 53 deletions

View file

@ -0,0 +1,242 @@
using System;
using System.Linq;
using Xunit;
namespace Server.Tests;
/// <summary>
/// Tests for the Html.EscapeHtml extension methods.
/// These tests verify correct HTML entity escaping and edge cases.
/// </summary>
public class HtmlEscapeTests
{
[Theory(DisplayName = "No escaping needed")]
[InlineData("")]
[InlineData("Hello World")]
[InlineData("Plain text without special characters")]
[InlineData("123456789")]
[InlineData("!@#$%^*()_+-=[]{}|;:,.?")]
public void EscapeHtml_NoSpecialCharacters_ReturnsUnchanged(string input)
{
var result = input.EscapeHtml();
Assert.Equal(input, result);
}
[Theory(DisplayName = "Single character escaping")]
[InlineData("<", "&lt;")]
[InlineData(">", "&gt;")]
[InlineData("&", "&amp;")]
[InlineData("\"", "&quot;")]
[InlineData("'", "&#39;")]
public void EscapeHtml_SingleSpecialCharacter_EscapesCorrectly(string input, string expected)
{
var result = input.EscapeHtml();
Assert.Equal(expected, result);
}
[Theory(DisplayName = "Multiple instances of same character")]
[InlineData("<><>", "&lt;&gt;&lt;&gt;")]
[InlineData("&&&&", "&amp;&amp;&amp;&amp;")]
[InlineData("\"\"\"", "&quot;&quot;&quot;")]
[InlineData("'''", "&#39;&#39;&#39;")]
[InlineData(">>>", "&gt;&gt;&gt;")]
public void EscapeHtml_MultipleSpecialCharacters_EscapesAll(string input, string expected)
{
var result = input.EscapeHtml();
Assert.Equal(expected, result);
}
[Theory(DisplayName = "Mixed content")]
[InlineData("Hello <world>", "Hello &lt;world&gt;")]
[InlineData("<div>Hello</div>", "&lt;div&gt;Hello&lt;/div&gt;")]
[InlineData("Tom & Jerry", "Tom &amp; Jerry")]
[InlineData("He said \"hello\"", "He said &quot;hello&quot;")]
[InlineData("It's a test", "It&#39;s a test")]
public void EscapeHtml_MixedContent_EscapesMixedSpecialCharacters(string input, string expected)
{
var result = input.EscapeHtml();
Assert.Equal(expected, result);
}
[Theory(DisplayName = "Starting with special character")]
[InlineData("<Hello", "&lt;Hello")]
[InlineData(">World", "&gt;World")]
[InlineData("&Start", "&amp;Start")]
[InlineData("\"Quote", "&quot;Quote")]
[InlineData("'Apostrophe", "&#39;Apostrophe")]
public void EscapeHtml_StartsWithSpecialCharacter_EscapesStart(string input, string expected)
{
var result = input.EscapeHtml();
Assert.Equal(expected, result);
}
[Theory(DisplayName = "Ending with special character")]
[InlineData("Hello<", "Hello&lt;")]
[InlineData("World>", "World&gt;")]
[InlineData("End&", "End&amp;")]
[InlineData("Quote\"", "Quote&quot;")]
[InlineData("Test'", "Test&#39;")]
public void EscapeHtml_EndsWithSpecialCharacter_EscapesEnd(string input, string expected)
{
var result = input.EscapeHtml();
Assert.Equal(expected, result);
}
[Theory(DisplayName = "Complex mixed scenarios")]
[InlineData("<p>Hello & goodbye</p>", "&lt;p&gt;Hello &amp; goodbye&lt;/p&gt;")]
[InlineData("&lt;already&gt;", "&amp;lt;already&amp;gt;")]
[InlineData("<tag attr=\"value\" data='test'>", "&lt;tag attr=&quot;value&quot; data=&#39;test&#39;&gt;")]
[InlineData("a<b>c&d\"e'f", "a&lt;b&gt;c&amp;d&quot;e&#39;f")]
[InlineData("&nbsp;", "&amp;nbsp;")]
public void EscapeHtml_ComplexScenarios_EscapesAllSpecialCharacters(string input, string expected)
{
var result = input.EscapeHtml();
Assert.Equal(expected, result);
}
[Fact(DisplayName = "Null string input")]
public void EscapeHtml_NullString_ReturnsEmpty()
{
string? input = null;
var result = input.EscapeHtml();
Assert.Empty(result);
}
[Fact(DisplayName = "Empty string input")]
public void EscapeHtml_EmptyString_ReturnsEmpty()
{
var result = "".EscapeHtml();
Assert.Empty(result);
}
[Fact(DisplayName = "Only special characters")]
public void EscapeHtml_OnlySpecialCharacters_EscapesAll()
{
const string input = "<>&\"'";
const string expected = "&lt;&gt;&amp;&quot;&#39;";
var result = input.EscapeHtml();
Assert.Equal(expected, result);
}
[Fact(DisplayName = "Ampersand must be escaped first")]
public void EscapeHtml_AmpersandFirst_PreventDoubleEscaping()
{
// This is critical: & must be escaped to &amp;
// If we're not careful, we could double-escape already-escaped content
const string input = "&lt;";
const string expected = "&amp;lt;";
var result = input.EscapeHtml();
Assert.Equal(expected, result);
}
[Theory(DisplayName = "ReadOnlySpan overload - no special characters")]
[InlineData("Hello World")]
[InlineData("Plain text")]
public void EscapeHtml_ReadOnlySpan_NoSpecialCharacters_ReturnsUnchanged(string input)
{
var result = input.AsSpan().EscapeHtml();
Assert.Equal(input, result);
}
[Theory(DisplayName = "ReadOnlySpan overload - with special characters")]
[InlineData("<div>", "&lt;div&gt;")]
[InlineData("Tom & Jerry", "Tom &amp; Jerry")]
public void EscapeHtml_ReadOnlySpan_WithSpecialCharacters_EscapesCorrectly(string input, string expected)
{
var result = input.AsSpan().EscapeHtml();
Assert.Equal(expected, result);
}
[Theory(DisplayName = "ReadOnlySpan overload - empty input")]
[InlineData("")]
public void EscapeHtml_ReadOnlySpan_Empty_ReturnsEmpty(string input)
{
var result = input.AsSpan().EscapeHtml();
Assert.Empty(result);
}
[Fact(DisplayName = "Consecutive special characters")]
public void EscapeHtml_ConsecutiveSpecialCharacters_EscapesAll()
{
const string input = "<<>>&&\"\"''";
const string expected = "&lt;&lt;&gt;&gt;&amp;&amp;&quot;&quot;&#39;&#39;";
var result = input.EscapeHtml();
Assert.Equal(expected, result);
}
[Fact(DisplayName = "Special characters with single normal character between")]
public void EscapeHtml_SpecialCharactersWithGaps_EscapesAll()
{
const string input = "<a>b&c\"d'e";
const string expected = "&lt;a&gt;b&amp;c&quot;d&#39;e";
var result = input.EscapeHtml();
Assert.Equal(expected, result);
}
[Fact(DisplayName = "HTML tags")]
public void EscapeHtml_HtmlTags_EscapesTagBrackets()
{
var input = "<html><body>Hello</body></html>";
var expected = "&lt;html&gt;&lt;body&gt;Hello&lt;/body&gt;&lt;/html&gt;";
var result = input.EscapeHtml();
Assert.Equal(expected, result);
}
[Fact(DisplayName = "HTML attributes with mixed quotes")]
public void EscapeHtml_HtmlAttributesWithQuotes_EscapesCorrectly()
{
var input = "<a href=\"test\" data='value'>";
var expected = "&lt;a href=&quot;test&quot; data=&#39;value&#39;&gt;";
var result = input.EscapeHtml();
Assert.Equal(expected, result);
}
[Theory(DisplayName = "Whitespace handling")]
[InlineData(" spaces ", " spaces ")]
[InlineData("\ttabs\t", "\ttabs\t")]
[InlineData("\nnewlines\n", "\nnewlines\n")]
public void EscapeHtml_Whitespace_PreservedAsIs(string input, string expected)
{
var result = input.EscapeHtml();
Assert.Equal(expected, result);
}
[Fact(DisplayName = "Performance: long string without special characters")]
public void EscapeHtml_LongStringNoSpecialCharacters_ReturnsQuickly()
{
var input = new string('a', 10000);
var result = input.EscapeHtml();
Assert.Equal(input, result);
}
[Fact(DisplayName = "Performance: long string with special characters")]
public void EscapeHtml_LongStringWithSpecialCharacters_HandlesCorrectly()
{
var input = $"Start{new string('<', 100)}End{new string('&', 100)}Final";
var expected =
$"Start{string.Join("", Enumerable.Repeat("&lt;", 100))}End{string.Join("", Enumerable.Repeat("&amp;", 100))}Final";
var result = input.EscapeHtml();
Assert.Equal(expected, result);
}
[Fact(DisplayName = "Unicode characters")]
public void EscapeHtml_UnicodeCharacters_PreservedWithSpecialCharsEscaped()
{
const string input = "Hello 世界 <test> & 🎉";
const string expected = "Hello 世界 &lt;test&gt; &amp; 🎉";
var result = input.EscapeHtml();
Assert.Equal(expected, result);
}
[Fact(DisplayName = "String overload matches ReadOnlySpan overload")]
public void EscapeHtml_StringVsReadOnlySpan_ProduceSameResult()
{
const string input = "<div>Tom & Jerry 'in' \"quotes\"</div>";
var resultString = input.EscapeHtml();
var resultSpan = input.AsSpan().EscapeHtml();
Assert.Equal(resultString, resultSpan);
}
}

View file

@ -14,9 +14,11 @@
*************************************************************************/
using System;
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Text;
using Server.Buffers;
using Server.Text;
namespace Server;
@ -237,13 +239,70 @@ public static class Html
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static RawInterpolatedStringHandler Right(this ReadOnlySpan<char> text) => text.Right(-1);
private static readonly SearchValues<char> _htmlSearchValues = SearchValues.Create('<', '>', '&', '"', '\'');
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static string EscapeHtml(this string input) =>
new StringBuilder(input.Length).Append(input)
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("&", "&amp;")
.Replace("\"", "&quot;")
.Replace("'", "&#39;")
.ToString();
public static string EscapeHtml(this string input)
{
if (string.IsNullOrEmpty(input))
{
return input ?? "";
}
return EscapeHtml(input.AsSpan());
}
public static string EscapeHtml(this ReadOnlySpan<char> input)
{
if (input.IsEmpty)
{
return string.Empty;
}
int indexOfAny = input.IndexOfAny(_htmlSearchValues);
if (indexOfAny < 0)
{
return input.ToString();
}
using var builder = ValueStringBuilder.Create(input.Length * 2);
int lastIndex = 0;
while (indexOfAny >= 0)
{
if (indexOfAny > lastIndex)
{
builder.Append(input[lastIndex..indexOfAny]);
}
char c = input[indexOfAny];
var replacement = c switch
{
'&' => "&amp;",
'<' => "&lt;",
'>' => "&gt;",
'"' => "&quot;",
'\'' => "&#39;"
};
builder.Append(replacement);
lastIndex = indexOfAny + 1;
indexOfAny = input[lastIndex..].IndexOfAny(_htmlSearchValues);
if (indexOfAny < 0)
{
break;
}
indexOfAny += lastIndex;
}
if (lastIndex < input.Length)
{
builder.Append(input[lastIndex..]);
}
var result = builder.ToString();
builder.Dispose();
return result;
}
}

View file

@ -4095,15 +4095,15 @@ namespace Server.Gumps
{
if (x is not KeyValuePair<IPAddress, List<Account>> a)
{
return -1;
return 1;
}
if (y is not KeyValuePair<IPAddress, List<Account>> b)
{
return 1;
return -1;
}
return a.Value.Count - b.Value.Count;
return b.Value.Count - a.Value.Count;
}
}

View file

@ -55,7 +55,7 @@ public class AnimalAI : BaseAI
return true;
}
if (!WalkMobileRange(combatant, 1, true, Mobile.RangeFight, Mobile.RangeFight))
if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight))
{
if (Mobile.GetDistanceToSqrt(combatant) > Mobile.RangePerception + 1)
{

View file

@ -41,13 +41,7 @@ public class ArcherAI : BaseAI
return true;
}
if (Core.TickCount - Mobile.LastMoveTime > 1000 && !WalkMobileRange(
combatant,
1,
true,
Mobile.RangeFight,
Mobile.Weapon.MaxRange
))
if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.Weapon.MaxRange))
{
this.DebugSayFormatted($"I am still not in range of {combatant.Name}");

View file

@ -38,7 +38,7 @@ public class BerserkAI : BaseAI
return true;
}
if (!WalkMobileRange(combatant, 1, true, Mobile.RangeFight, Mobile.RangeFight))
if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight))
{
this.DebugSayFormatted($"I am still not in range of {combatant.Name}");

View file

@ -168,7 +168,7 @@ public class MageAI : BaseAI
{
if (!SmartAI)
{
if (!MoveTo(m, true, Mobile.RangeFight))
if (!MoveTo(m, false, Mobile.RangeFight))
{
OnFailedMove();
}
@ -182,14 +182,14 @@ public class MageAI : BaseAI
{
RunFrom(m);
}
else if (!Mobile.InRange(m, Math.Max(Mobile.RangeFight, 2)) && !MoveTo(m, true, 1))
else if (!Mobile.InRange(m, Math.Max(Mobile.RangeFight, 2)) && !MoveTo(m, false, 1))
{
OnFailedMove();
}
}
else if (!Mobile.InRange(m, Mobile.RangeFight))
{
if (!MoveTo(m, true, 1))
if (!MoveTo(m, false, 1))
{
OnFailedMove();
}
@ -713,6 +713,14 @@ public class MageAI : BaseAI
}
else if (Mobile.Spell == null && Core.TickCount - _nextCastTime >= 0)
{
if (Mobile.Controlled && c == Mobile)
{
DebugSay("I should not attack myself!");
Mobile.Combatant = null;
Action = ActionType.Guard;
return true;
}
// We are ready to cast a spell
Spell spell;
var toDispel = FindDispelTarget(true);

View file

@ -67,7 +67,7 @@ public class MeleeAI : BaseAI
}
}
if (!MoveTo(combatant, true, Mobile.RangeFight))
if (!MoveTo(combatant, false, Mobile.RangeFight))
{
if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true))
{

View file

@ -41,7 +41,7 @@ public class PredatorAI : BaseAI
return true;
}
if (!WalkMobileRange(combatant, 1, true, Mobile.RangeFight, Mobile.RangeFight))
if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight))
{
if (Mobile.GetDistanceToSqrt(combatant) > Mobile.RangePerception + 1)
{

View file

@ -43,7 +43,7 @@ public class ThiefAI : BaseAI
return true;
}
if (!WalkMobileRange(combatant, 1, true, Mobile.RangeFight, Mobile.RangeFight))
if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight))
{
this.DebugSayFormatted($"I should be closer to {combatant.Name}");
}

View file

@ -2676,19 +2676,7 @@ namespace Server.Mobiles
if (Body.IsHuman)
{
switch (Utility.Random(2))
{
case 0:
{
CheckedAnimate(5, 5, 1, true, true, 1);
break;
}
case 1:
{
CheckedAnimate(6, 5, 1, true, false, 1);
break;
}
}
CheckedAnimate(Utility.RandomBool() ? 5 : 6, 5, 1, true, false, 1);
}
else if (Body.IsAnimal)
{
@ -2713,19 +2701,7 @@ namespace Server.Mobiles
}
else if (Body.IsMonster)
{
switch (Utility.Random(2))
{
case 0:
{
CheckedAnimate(17, 5, 1, true, false, 1);
break;
}
case 1:
{
CheckedAnimate(18, 5, 1, true, false, 1);
break;
}
}
CheckedAnimate(Utility.RandomBool() ? 17 : 18, 5, 1, true, false, 1);
}
PlaySound(GetIdleSound());

View file

@ -93,7 +93,7 @@ public abstract partial class BaseFamiliar : BaseCreature
Hidden = m_LastHidden = master.Hidden;
}
if (AIObject?.WalkMobileRange(master, 5, true, 1, 1) == true)
if (AIObject?.WalkMobileRange(master, 5, false, 1, 1) == true)
{
Warmode = master.Warmode;
Combatant = master.Combatant;