## 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
}
```
385 lines
13 KiB
C#
385 lines
13 KiB
C#
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");
|
|
}
|
|
}
|