feat: Adds robust speed hack detection and movement throttling (#2266)

## Summary

   Server-side movement throttle that prevents speed hacking while accurately identifying cheaters with detection of lagging connections.

   **Key features:**
   - Credit buffer (200ms) absorbs timing jitter from legitimate players
   - Movement queue handles larger bursts, draining at proper game-tick intervals
   - RTT measurement distinguishes network lag from speed hacks
   - Queue depth detection catches ACK-throttled speed hacks (going straight)

   ## How It Works

   **Throttle** (prevention): Movements arriving too early either consume credit or get queued. The queue drains at
   correct intervals, so speed hackers can't move faster regardless of what they send.

   **Detection** (identification): Combines multiple signals to identify cheaters:
   | Signal | What it catches |
   |--------|-----------------|
   | Queue depth ≥4 sustained | ACK-throttled speed hacks (client limits unacked moves to 5) |
   | Movement rate >1.05x | Direction-change speed hacks where timing is visible |
   | Stable RTT + high queue | Eliminates false positives from laggy players |

   **RTT-Aware Logic:**
   - Probes only sent to players actively moving (event-driven, not global loop)
   - Stable low-latency + problems = suspicious
   - Unstable/high-latency + problems = probably just lag, throttle handles it

   ## Configuration

   ```json
   {
     "movementThrottle.maxCredit": 200,
     "movementThrottle.softQueueLimit": 6,
     "movementThrottle.hardQueueLimit": 10,
     "movementThrottle.debugLogging": false
   }
   ```
This commit is contained in:
Kamron Batman 2026-03-07 11:44:37 -08:00 • committed by GitHub
parent 6745cf2075
commit 04d438239d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 2474 additions and 74 deletions

View file

@ -0,0 +1,430 @@
using Server.Network;
using Xunit;
namespace Server.Tests.Network;
/// <summary>
/// Manual tests for MovementThrottle latency scenarios.
/// These tests simulate specific timing scenarios and should NOT run in CI/CD.
///
/// To run these tests manually:
/// dotnet test --filter "Category=Manual"
///
/// To exclude from CI/CD, add to your test command:
/// dotnet test --filter "Category!=Manual"
/// </summary>
[Collection("Sequential Server Tests")]
[Trait("Category", "Manual")]
public class MovementThrottleLatencyTests
{
/// <summary>
/// Sets the simulated tick count for testing.
/// </summary>
private static void SetTickCount(long ticks)
{
Core._tickCount = ticks;
}
/// <summary>
/// Simulates recording movements at specific intervals.
/// </summary>
private static void SimulateMovements(NetState ns, params (int delayMs, int costMs)[] movements)
{
ns._movementHistory ??= new NetState.MovementRecord[20];
ns._movementHistoryIndex = 0;
ns._movementHistoryFull = false;
ns._lastMovementRecordTime = 0;
var currentTime = 0L;
foreach (var (delayMs, costMs) in movements)
{
currentTime += delayMs;
SetTickCount(currentTime);
if (ns._lastMovementRecordTime > 0)
{
var interval = (int)(currentTime - ns._lastMovementRecordTime);
// Skip chain-breaking gaps (> 2000ms) like the real code does
if (interval > 2000)
{
ns._lastGapDuration = interval;
ns._lastMovementRecordTime = currentTime;
continue;
}
ns._movementHistory[ns._movementHistoryIndex] = new NetState.MovementRecord
{
Interval = (short)interval,
TargetSpeed = (ushort)costMs,
QueueDepth = 0,
Flags = 0
};
ns._movementHistoryIndex++;
if (ns._movementHistoryIndex >= 20)
{
ns._movementHistoryIndex = 0;
ns._movementHistoryFull = true;
}
}
ns._lastMovementRecordTime = currentTime;
}
}
/// <summary>
/// Scenario: Player experiences a 3-second network lag spike, then all buffered
/// packets arrive at once. This should NOT trigger false positive detection.
///
/// Timeline:
/// - T=0-1000ms: Normal movement (10 packets at 100ms intervals)
/// - T=1000-4000ms: Network lag (client buffers packets)
/// - T=4000ms: Network recovers, 30 buffered packets arrive simultaneously
/// </summary>
[Fact]
public void LagSpike_BurstRecovery_NoFalsePositive()
{
var ns = PacketTestUtilities.CreateTestNetState();
SetTickCount(0);
// Phase 1: Normal movement for 1 second
var movements = new (int delayMs, int costMs)[10];
for (var i = 0; i < 10; i++)
{
movements[i] = (100, 100); // Normal mounted running
}
SimulateMovements(ns, movements);
// Verify normal rate before lag
var rateBeforeLag = MovementThrottle.CalculateMovementRate(ns, out var samples);
Assert.True(samples >= 8, $"Expected >= 8 samples, got {samples}");
Assert.True(rateBeforeLag >= 0.95f && rateBeforeLag <= 1.05f,
$"Expected normal rate before lag, got {rateBeforeLag}");
// Phase 2: Simulate 3 second lag followed by burst
// The gap (3000ms) is tracked, then burst packets arrive
var currentTime = Core.TickCount;
// Record the lag gap
SetTickCount(currentTime + 3000);
ns._lastGapDuration = 3000; // Gap > maxChainGap triggers this
// Burst packets arriving (5 packets with tiny intervals)
for (var i = 0; i < 5; i++)
{
SetTickCount(Core.TickCount + 2); // 2ms between packets (simultaneous arrival)
if (ns._lastMovementRecordTime > 0)
{
var interval = (int)(Core.TickCount - ns._lastMovementRecordTime);
ns._movementHistory[ns._movementHistoryIndex] = new NetState.MovementRecord
{
Interval = (short)interval,
TargetSpeed = 100,
QueueDepth = 0,
Flags = 0
};
ns._movementHistoryIndex++;
if (ns._movementHistoryIndex >= 20)
{
ns._movementHistoryIndex = 0;
ns._movementHistoryFull = true;
}
}
ns._lastMovementRecordTime = Core.TickCount;
}
// Analyze - should apply burst forgiveness due to large gap
var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out _, out var confidence);
// The burst will have high rate due to tiny intervals, but:
// 1. _lastGapDuration > 1000 (maxChainGap/2) triggers burst forgiveness
// 2. Confidence should be reduced by 0.3f multiplier
// 3. Should NOT be Definite unless truly extreme
Assert.True(
verdict != MovementThrottle.DetectionVerdict.Definite,
$"Lag recovery should not be Definite. Rate={rate}, Confidence={confidence}, Verdict={verdict}"
);
// Gap duration should be cleared after analysis
Assert.Equal(0, ns._lastGapDuration);
}
/// <summary>
/// Scenario: Player with consistently high latency (400ms RTT) moves normally.
/// Their connection is consistent but slow - should NOT be considered "stable"
/// for the purpose of tightening detection thresholds.
/// </summary>
[Fact]
public void HighLatencyConsistent_NotConsideredStable()
{
var ns = PacketTestUtilities.CreateTestNetState();
// Set up high but consistent RTT
ns._rttHistory = new long[] { 400, 405, 398, 402, 401, 399, 403, 400 };
ns._rttSampleCount = 8;
// Calculate variance (should be low since values are consistent)
long sum = 0, sumSq = 0;
for (var i = 0; i < 8; i++)
{
sum += ns._rttHistory[i];
sumSq += ns._rttHistory[i] * ns._rttHistory[i];
}
var mean = sum / 8;
ns._rttVariance = sumSq / 8 - mean * mean;
// Variance is low (consistent), but should NOT be "stable" due to high latency
Assert.False(ns.HasStableConnection,
$"400ms RTT should not be stable. AvgRTT={ns.AverageRtt}, Variance={ns._rttVariance}");
// Now verify detection gives more leniency for high-latency connections
SimulateMovements(ns,
(105, 100), (105, 100), (105, 100), (105, 100), (105, 100),
(105, 100), (105, 100), (105, 100), (105, 100), (105, 100)
);
var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out _, out var confidence);
// Slightly slow but high-latency player should get benefit of doubt
Assert.True(
verdict == MovementThrottle.DetectionVerdict.Normal ||
verdict == MovementThrottle.DetectionVerdict.Possible,
$"High latency player should get leniency. Verdict={verdict}"
);
}
/// <summary>
/// Scenario: RTT probing and measurement.
/// Verifies that RTT variance is calculated correctly and stability
/// is properly determined.
/// </summary>
[Fact]
public void RttMeasurement_VarianceCalculation()
{
var ns = PacketTestUtilities.CreateTestNetState();
SetTickCount(1000);
// Simulate stable low-latency RTT measurements
var rttValues = new long[] { 50, 48, 52, 49, 51, 50, 48, 52 };
ns._rttHistory = new long[8];
for (var i = 0; i < rttValues.Length; i++)
{
ns._rttHistory[i] = rttValues[i];
}
ns._rttSampleCount = 8;
// Calculate expected variance
long sum = 0, sumSq = 0;
foreach (var rtt in rttValues)
{
sum += rtt;
sumSq += rtt * rtt;
}
var expectedMean = sum / 8;
var expectedVariance = sumSq / 8 - expectedMean * expectedMean;
// Clamp negative variance (safety check)
if (expectedVariance < 0)
{
expectedVariance = 0;
}
ns._rttVariance = expectedVariance;
Assert.True(ns._rttVariance >= 0, "Variance should never be negative");
Assert.True(ns._rttVariance < 2500, "Low-variance samples should have variance < 2500");
Assert.True(ns.AverageRtt > 0 && ns.AverageRtt < 200, "Average RTT should be ~50ms");
Assert.True(ns.HasStableConnection, "Should be considered stable connection");
}
/// <summary>
/// Scenario: Actual speed hacker with stable connection.
/// Should be detected with high confidence.
/// </summary>
[Fact]
public void SpeedHack_StableConnection_DetectedDefinite()
{
var ns = PacketTestUtilities.CreateTestNetState();
// Set up stable, low-latency RTT
ns._rttHistory = new long[] { 30, 32, 28, 31, 29, 30, 31, 30 };
ns._rttSampleCount = 8;
long sum = 0, sumSq = 0;
for (var i = 0; i < 8; i++)
{
sum += ns._rttHistory[i];
sumSq += ns._rttHistory[i] * ns._rttHistory[i];
}
ns._rttVariance = sumSq / 8 - (sum / 8) * (sum / 8);
if (ns._rttVariance < 0)
{
ns._rttVariance = 0;
}
Assert.True(ns.HasStableConnection, "Should be stable for this test");
// Simulate speed hack: 50% faster movement
SimulateMovements(ns,
(67, 100), (67, 100), (67, 100), (67, 100), (67, 100),
(67, 100), (67, 100), (67, 100), (67, 100), (67, 100),
(67, 100), (67, 100), (67, 100), (67, 100), (67, 100)
);
var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out _, out var confidence);
Assert.True(rate > 1.4f, $"Expected rate > 1.4 (50% speed hack), got {rate}");
Assert.Equal(MovementThrottle.DetectionVerdict.Definite, verdict);
Assert.True(confidence > 0.5f, $"Expected high confidence, got {confidence}");
}
/// <summary>
/// Scenario: Borderline speed hack (7% faster) with unstable connection.
/// Should give benefit of doubt (not Definite).
/// </summary>
[Fact]
public void BorderlineSpeed_UnstableConnection_BenefitOfDoubt()
{
var ns = PacketTestUtilities.CreateTestNetState();
// Set up unstable connection
ns._rttHistory = new long[] { 50, 300, 80, 250, 100, 400, 60, 350 };
ns._rttSampleCount = 8;
long sum = 0, sumSq = 0;
for (var i = 0; i < 8; i++)
{
sum += ns._rttHistory[i];
sumSq += ns._rttHistory[i] * ns._rttHistory[i];
}
ns._rttVariance = sumSq / 8 - (sum / 8) * (sum / 8);
Assert.False(ns.HasStableConnection, "Should be unstable due to high variance");
// Simulate borderline speed (7% faster)
SimulateMovements(ns,
(93, 100), (93, 100), (93, 100), (93, 100), (93, 100),
(93, 100), (93, 100), (93, 100), (93, 100), (93, 100)
);
var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out _, out var confidence);
Assert.True(rate > 1.05f && rate < 1.10f, $"Expected rate ~1.07, got {rate}");
// Unstable connection + borderline rate = should NOT be Definite
Assert.True(
verdict != MovementThrottle.DetectionVerdict.Definite,
$"Borderline case with unstable connection should not be Definite. Verdict={verdict}, Confidence={confidence}"
);
}
/// <summary>
/// Scenario: Verifies that burst forgiveness triggers correctly and clears gap duration.
/// </summary>
[Fact]
public void MultipleLagSpikes_NoAccumulatedSuspicion()
{
var ns = PacketTestUtilities.CreateTestNetState();
SetTickCount(0);
// Set up history buffer with mostly normal movements and a burst at the end
ns._movementHistory = new NetState.MovementRecord[20];
// Fill with 17 normal movements (100ms intervals)
for (var i = 0; i < 17; i++)
{
ns._movementHistory[i] = new NetState.MovementRecord
{
Interval = 100,
TargetSpeed = 100,
QueueDepth = 0,
Flags = 0
};
}
// Add 3 burst movements at the end (10ms intervals = burst)
for (var i = 17; i < 20; i++)
{
ns._movementHistory[i] = new NetState.MovementRecord
{
Interval = 10,
TargetSpeed = 100,
QueueDepth = 0,
Flags = 0
};
}
ns._movementHistoryIndex = 0; // Next write position (wrapped)
ns._movementHistoryFull = true; // Buffer is full
// Set gap duration (simulating a lag spike before the burst)
ns._lastGapDuration = 2500;
// Analyze - burst forgiveness should trigger
var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out var samples, out var confidence);
// Key assertions:
// 1. Gap duration should be cleared (burst forgiveness triggered)
Assert.Equal(0, ns._lastGapDuration);
// 2. Confidence should be reduced by 0.3x multiplier
Assert.True(
confidence < 0.5f,
$"Burst after gap should have low confidence. Confidence={confidence}, Rate={rate}"
);
// 3. Should NOT be Definite despite the burst (forgiveness applied)
Assert.True(
verdict != MovementThrottle.DetectionVerdict.Definite,
$"Should not be Definite after burst forgiveness. Verdict={verdict}"
);
}
/// <summary>
/// Scenario: Walking vs running speed differences.
/// Walking (200ms interval) vs mounted running (100ms interval) should both
/// calculate correct rates.
/// </summary>
[Fact]
public void WalkingVsRunning_CorrectRateCalculation()
{
// Test walking (200ms expected interval)
var nsWalking = PacketTestUtilities.CreateTestNetState();
SimulateMovements(nsWalking,
(200, 200), (200, 200), (200, 200), (200, 200), (200, 200),
(200, 200), (200, 200), (200, 200), (200, 200), (200, 200)
);
var walkingRate = MovementThrottle.CalculateMovementRate(nsWalking, out var walkingSamples);
Assert.True(walkingSamples >= 8, "Should have enough walking samples");
Assert.True(walkingRate >= 0.95f && walkingRate <= 1.05f,
$"Walking rate should be ~1.0, got {walkingRate}");
// Test mounted running (100ms expected interval)
var nsRunning = PacketTestUtilities.CreateTestNetState();
SimulateMovements(nsRunning,
(100, 100), (100, 100), (100, 100), (100, 100), (100, 100),
(100, 100), (100, 100), (100, 100), (100, 100), (100, 100)
);
var runningRate = MovementThrottle.CalculateMovementRate(nsRunning, out var runningSamples);
Assert.True(runningSamples >= 8, "Should have enough running samples");
Assert.True(runningRate >= 0.95f && runningRate <= 1.05f,
$"Running rate should be ~1.0, got {runningRate}");
// Test speed hack while walking
var nsWalkingHack = PacketTestUtilities.CreateTestNetState();
SimulateMovements(nsWalkingHack,
(100, 200), (100, 200), (100, 200), (100, 200), (100, 200),
(100, 200), (100, 200), (100, 200), (100, 200), (100, 200)
);
var walkingHackRate = MovementThrottle.CalculateMovementRate(nsWalkingHack, out _);
Assert.True(walkingHackRate >= 1.9f && walkingHackRate <= 2.1f,
$"Walking at running speed should be rate ~2.0, got {walkingHackRate}");
}
}

View file

@ -0,0 +1,385 @@
using Server.Network;
using Xunit;
namespace Server.Tests.Network;
/// <summary>
/// Unit tests for MovementThrottle rate calculation and detection logic.
/// These tests are CI/CD safe - they don't depend on real timing.
/// </summary>
[Collection("Sequential Server Tests")]
public class MovementThrottleTests
{
/// <summary>
/// Creates a test NetState with movement history pre-populated.
/// </summary>
private static NetState CreateNetStateWithHistory(params (short interval, ushort targetSpeed)[] records)
{
var ns = PacketTestUtilities.CreateTestNetState();
if (records.Length == 0)
{
return ns;
}
// Initialize history buffer
ns._movementHistory = new NetState.MovementRecord[20];
ns._movementHistoryIndex = 0;
ns._movementHistoryFull = false;
foreach (var (interval, targetSpeed) in records)
{
ns._movementHistory[ns._movementHistoryIndex] = new NetState.MovementRecord
{
Interval = interval,
TargetSpeed = targetSpeed,
QueueDepth = 0,
Flags = 0
};
ns._movementHistoryIndex++;
if (ns._movementHistoryIndex >= 20)
{
ns._movementHistoryIndex = 0;
ns._movementHistoryFull = true;
}
}
return ns;
}
[Fact]
public void CalculateMovementRate_NoHistory_ReturnsOne()
{
var ns = CreateNetStateWithHistory();
var rate = MovementThrottle.CalculateMovementRate(ns, out var sampleCount);
Assert.Equal(1.0f, rate);
Assert.Equal(0, sampleCount);
}
[Fact]
public void CalculateMovementRate_InsufficientSamples_ReturnsOne()
{
// Less than 8 samples (minSamplesForRate default)
var ns = CreateNetStateWithHistory(
(100, 100),
(100, 100),
(100, 100)
);
var rate = MovementThrottle.CalculateMovementRate(ns, out var sampleCount);
Assert.Equal(1.0f, rate);
// When insufficient samples, method returns early with sampleCount = 0
Assert.Equal(0, sampleCount);
}
[Fact]
public void CalculateMovementRate_NormalSpeed_ReturnsOne()
{
// 10 samples at exactly expected speed (100ms interval, 100ms target)
var ns = CreateNetStateWithHistory(
(100, 100), (100, 100), (100, 100), (100, 100), (100, 100),
(100, 100), (100, 100), (100, 100), (100, 100), (100, 100)
);
var rate = MovementThrottle.CalculateMovementRate(ns, out var sampleCount);
Assert.Equal(1.0f, rate);
Assert.Equal(10, sampleCount);
}
[Fact]
public void CalculateMovementRate_SpeedHack_ReturnsHighRate()
{
// Moving at 50ms intervals when 100ms is expected = 2x speed
var ns = CreateNetStateWithHistory(
(50, 100), (50, 100), (50, 100), (50, 100), (50, 100),
(50, 100), (50, 100), (50, 100), (50, 100), (50, 100)
);
var rate = MovementThrottle.CalculateMovementRate(ns, out var sampleCount);
Assert.Equal(2.0f, rate);
Assert.Equal(10, sampleCount);
}
[Fact]
public void CalculateMovementRate_SlowMovement_ReturnsLowRate()
{
// Moving at 200ms intervals when 100ms is expected = 0.5x speed
var ns = CreateNetStateWithHistory(
(200, 100), (200, 100), (200, 100), (200, 100), (200, 100),
(200, 100), (200, 100), (200, 100), (200, 100), (200, 100)
);
var rate = MovementThrottle.CalculateMovementRate(ns, out var sampleCount);
Assert.Equal(0.5f, rate);
Assert.Equal(10, sampleCount);
}
[Fact]
public void CalculateMovementRate_MixedSpeeds_ReturnsAverageRate()
{
// Mix of mounted running (100ms target) at varying speeds
// Total target: 1000ms, Total actual: 900ms = 1.11 rate
var ns = CreateNetStateWithHistory(
(80, 100), (100, 100), (90, 100), (100, 100), (80, 100),
(90, 100), (100, 100), (80, 100), (90, 100), (90, 100)
);
var rate = MovementThrottle.CalculateMovementRate(ns, out var sampleCount);
// 1000 / 900 = 1.111...
Assert.True(rate > 1.10f && rate < 1.12f, $"Expected rate ~1.11, got {rate}");
Assert.Equal(10, sampleCount);
}
[Fact]
public void DetectRecentBurst_NoBurst_ReturnsZero()
{
// Normal intervals, no burst
var ns = CreateNetStateWithHistory(
(100, 100), (100, 100), (100, 100), (100, 100), (100, 100),
(100, 100), (100, 100), (100, 100), (100, 100), (100, 100)
);
var (burstSize, precedingGap) = MovementThrottle.DetectRecentBurst(ns);
Assert.Equal(0, burstSize);
}
[Fact]
public void DetectRecentBurst_BurstDetected_ReturnsBurstSizeAndGap()
{
// Last 4 packets arrived in burst (< 15ms intervals), preceded by 500ms gap
var ns = CreateNetStateWithHistory(
(100, 100), (100, 100), (100, 100), (100, 100), (100, 100),
(500, 100), (5, 100), (5, 100), (5, 100), (5, 100)
);
var (burstSize, precedingGap) = MovementThrottle.DetectRecentBurst(ns);
Assert.Equal(4, burstSize);
Assert.Equal(500, precedingGap);
}
[Fact]
public void DetectRecentBurst_SmallBurst_DetectsCorrectly()
{
// 2 packets in burst
var ns = CreateNetStateWithHistory(
(100, 100), (100, 100), (100, 100), (100, 100), (100, 100),
(100, 100), (100, 100), (100, 100), (200, 100), (10, 100)
);
var (burstSize, precedingGap) = MovementThrottle.DetectRecentBurst(ns);
Assert.Equal(1, burstSize);
Assert.Equal(200, precedingGap);
}
[Fact]
public void AnalyzeMovement_NormalMovement_ReturnsNormal()
{
var ns = CreateNetStateWithHistory(
(100, 100), (100, 100), (100, 100), (100, 100), (100, 100),
(100, 100), (100, 100), (100, 100), (100, 100), (100, 100)
);
var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence);
Assert.Equal(MovementThrottle.DetectionVerdict.Normal, verdict);
Assert.Equal(1.0f, rate);
Assert.Equal(10, sampleCount);
}
[Fact]
public void AnalyzeMovement_ClearSpeedHack_ReturnsDefinite()
{
// 30% faster than expected - clear speed hack
var ns = CreateNetStateWithHistory(
(77, 100), (77, 100), (77, 100), (77, 100), (77, 100),
(77, 100), (77, 100), (77, 100), (77, 100), (77, 100)
);
var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence);
Assert.Equal(MovementThrottle.DetectionVerdict.Definite, verdict);
Assert.True(rate > 1.25f, $"Expected rate > 1.25, got {rate}");
}
[Fact]
public void AnalyzeMovement_ModerateSpeedHack_ReturnsPossibleOrLikely()
{
// 8% faster than expected - moderate, should be Possible or Likely
var ns = CreateNetStateWithHistory(
(93, 100), (93, 100), (93, 100), (93, 100), (93, 100),
(93, 100), (93, 100), (93, 100), (93, 100), (93, 100)
);
// Set up stable, low-latency RTT (required for Likely verdict path)
ns._rttHistory = [50, 52, 48, 51, 49, 50, 51, 50];
ns._rttSampleCount = 8;
ns._rttVariance = 4; // Low variance
var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence);
Assert.True(
verdict == MovementThrottle.DetectionVerdict.Possible ||
verdict == MovementThrottle.DetectionVerdict.Likely,
$"Expected Possible or Likely, got {verdict}. Rate={rate}, Confidence={confidence}"
);
Assert.True(rate > 1.05f && rate < 1.15f, $"Expected rate ~1.07, got {rate}");
}
[Fact]
public void AnalyzeMovement_BurstAfterLargeGap_ReducesConfidence()
{
// Simulate lag recovery: large gap followed by burst of packets
var ns = CreateNetStateWithHistory(
(100, 100), (100, 100), (100, 100), (100, 100), (100, 100),
(5, 100), (5, 100), (5, 100), (5, 100), (5, 100)
);
// Set up the gap duration (simulates what RecordMovement does)
ns._lastGapDuration = 1500; // Gap > maxChainGap/2 (1000)
var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence);
// Even though rate is high, confidence should be reduced due to burst forgiveness
// The gap duration should trigger the forgiveness path
Assert.True(confidence < 0.5f, $"Expected low confidence due to burst forgiveness, got {confidence}");
// After analysis, gap duration should be reset
Assert.Equal(0, ns._lastGapDuration);
}
[Fact]
public void AnalyzeMovement_HighQueue_IncreasesConfidence()
{
var ns = CreateNetStateWithHistory(
(95, 100), (95, 100), (95, 100), (95, 100), (95, 100),
(95, 100), (95, 100), (95, 100), (95, 100), (95, 100)
);
// Simulate high queue depth (modified client indicator)
ns._movementQueue = new System.Collections.Generic.Queue<NetState.QueuedMovement>();
for (var i = 0; i < 5; i++)
{
ns._movementQueue.Enqueue(new NetState.QueuedMovement());
}
var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence);
// Queue > 4 indicates modified client - should be Definite
Assert.Equal(MovementThrottle.DetectionVerdict.Definite, verdict);
}
[Fact]
public void AnalyzeMovement_StableConnectionLowLatency_IncreasesConfidence()
{
var ns = CreateNetStateWithHistory(
(95, 100), (95, 100), (95, 100), (95, 100), (95, 100),
(95, 100), (95, 100), (95, 100), (95, 100), (95, 100)
);
// Set up stable, low-latency RTT
ns._rttHistory = [50, 52, 48, 51, 49, 50, 51, 50];
ns._rttSampleCount = 8;
ns._rttVariance = 4; // Low variance
var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence);
// With stable connection, even small anomalies are more suspicious
Assert.True(confidence > 0.2f, $"Expected higher confidence for stable connection, got {confidence}");
}
[Fact]
public void AnalyzeMovement_UnstableConnection_ReducesConfidence()
{
var ns = CreateNetStateWithHistory(
(90, 100), (90, 100), (90, 100), (90, 100), (90, 100),
(90, 100), (90, 100), (90, 100), (90, 100), (90, 100)
);
// Set up unstable connection (high variance)
ns._rttHistory = [50, 200, 80, 350, 100, 250, 90, 300];
ns._rttSampleCount = 8;
ns._rttVariance = 15000; // High variance
var verdictUnstable = MovementThrottle.AnalyzeMovement(ns, out _, out _, out var confidenceUnstable);
// Reset and test with stable connection for comparison
var nsStable = CreateNetStateWithHistory(
(90, 100), (90, 100), (90, 100), (90, 100), (90, 100),
(90, 100), (90, 100), (90, 100), (90, 100), (90, 100)
);
nsStable._rttHistory = [50, 52, 48, 51, 49, 50, 51, 50];
nsStable._rttSampleCount = 8;
nsStable._rttVariance = 4;
var verdictStable = MovementThrottle.AnalyzeMovement(nsStable, out _, out _, out var confidenceStable);
// Unstable connection should have lower confidence
Assert.True(
confidenceUnstable < confidenceStable,
$"Expected unstable ({confidenceUnstable}) < stable ({confidenceStable})"
);
}
[Fact]
public void HasStableConnection_LowLatencyLowVariance_ReturnsTrue()
{
var ns = PacketTestUtilities.CreateTestNetState();
// Stable: low variance, enough samples, low latency
ns._rttHistory = [50, 52, 48, 51, 49, 50, 51, 50];
ns._rttSampleCount = 8;
ns._rttVariance = 4; // Low variance < 2500
Assert.True(ns.HasStableConnection);
}
[Fact]
public void HasStableConnection_HighLatency_ReturnsFalse()
{
var ns = PacketTestUtilities.CreateTestNetState();
// High but consistent latency (500ms) - should NOT be considered stable
ns._rttHistory = [500, 502, 498, 501, 499, 500, 501, 500];
ns._rttSampleCount = 8;
ns._rttVariance = 4; // Low variance, but latency > 200ms
Assert.False(ns.HasStableConnection, "High latency connection should not be considered stable");
}
[Fact]
public void HasStableConnection_HighVariance_ReturnsFalse()
{
var ns = PacketTestUtilities.CreateTestNetState();
// Low average latency but high variance
ns._rttHistory = [50, 200, 30, 180, 40, 150, 60, 170];
ns._rttSampleCount = 8;
ns._rttVariance = 5000; // High variance > 2500
Assert.False(ns.HasStableConnection);
}
[Fact]
public void HasStableConnection_InsufficientSamples_ReturnsFalse()
{
var ns = PacketTestUtilities.CreateTestNetState();
// Only 2 samples
ns._rttHistory = [50, 50, 0, 0, 0, 0, 0, 0];
ns._rttSampleCount = 2;
ns._rttVariance = 0;
Assert.False(ns.HasStableConnection, "Needs at least 3 samples");
}
}

View file

@ -1,18 +1,3 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: VendorSellPackets.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 <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System.Collections.Generic;
namespace Server.Network;