• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

orion-ecs / keen-eye / 20587959317

30 Dec 2025 03:16AM UTC coverage: 69.652% (+0.3%) from 69.368%
20587959317

push

github

tyevco
Add delta sync/hierarchy tests and fix misprediction detection

- Add comprehensive tests for delta sync encoding/decoding round-trip
- Add hierarchy replication tests (set parent, clear parent, snapshot)
- Fix ValuesApproximatelyEqual to use serializer's GetDirtyMask() for
  proper float field comparison instead of always returning false
- This addresses test coverage gaps for 0x17 (ComponentDelta) and
  0x18 (HierarchyChange) message types

Test coverage: 28 new tests for delta sync and hierarchy replication

3516 of 4496 branches covered (78.2%)

Branch coverage included in aggregate %.

0 of 8 new or added lines in 1 file covered. (0.0%)

2 existing lines in 1 file now uncovered.

20264 of 29645 relevant lines covered (68.36%)

1.02 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

20.95
/src/KeenEyes.Network/Systems/ClientPredictionSystem.cs
1
using KeenEyes.Network.Components;
2
using KeenEyes.Network.Prediction;
3
using KeenEyes.Network.Serialization;
4

5
namespace KeenEyes.Network.Systems;
6

7
/// <summary>
8
/// System that handles client-side prediction, misprediction detection, and reconciliation.
9
/// </summary>
10
/// <remarks>
11
/// <para>
12
/// This system runs on the client and manages the prediction lifecycle:
13
/// 1. Saves predicted states after local simulation
14
/// 2. Compares server-confirmed states against predictions
15
/// 3. Triggers reconciliation (rollback + replay) on misprediction
16
/// </para>
17
/// </remarks>
18
/// <param name="plugin">The network client plugin.</param>
19
/// <param name="interpolator">Optional interpolator for smooth corrections.</param>
20
/// <param name="applyInput">Function to apply an input during replay.</param>
21
public sealed class ClientPredictionSystem(
1✔
22
    NetworkClientPlugin plugin,
1✔
23
    INetworkInterpolator? interpolator = null,
1✔
24
    Action<Entity, object>? applyInput = null) : SystemBase
1✔
25
{
26
    private readonly Dictionary<Entity, PredictionBuffer> predictionBuffers = [];
1✔
27
    private readonly INetworkInterpolator? smoothingInterpolator = interpolator;
1✔
28

29
    /// <summary>
30
    /// Gets the prediction buffer for an entity.
31
    /// </summary>
32
    /// <param name="entity">The entity.</param>
33
    /// <returns>The prediction buffer, or null if not found.</returns>
34
    public PredictionBuffer? GetPredictionBuffer(Entity entity)
35
    {
36
        return predictionBuffers.TryGetValue(entity, out var buffer) ? buffer : null;
×
37
    }
38

39
    /// <inheritdoc/>
40
    public override void Update(float deltaTime)
41
    {
42
        if (!plugin.Config.EnablePrediction)
1✔
43
        {
44
            return;
×
45
        }
46

47
        // Save predicted states for locally owned entities
48
        foreach (var entity in World.Query<LocallyOwned, Predicted, PredictionState>())
1✔
49
        {
50
            SavePredictedState(entity);
1✔
51
        }
52
    }
1✔
53

54
    /// <summary>
55
    /// Called when server state is received to check for misprediction.
56
    /// </summary>
57
    /// <param name="entity">The entity that received server state.</param>
58
    /// <param name="serverTick">The tick the server state is for.</param>
59
    /// <param name="serverStates">The server component states.</param>
60
    public void OnServerStateReceived(
61
        Entity entity,
62
        uint serverTick,
63
        IReadOnlyDictionary<Type, object> serverStates)
64
    {
65
        if (!World.Has<PredictionState>(entity))
×
66
        {
67
            return;
×
68
        }
69

70
        ref var predState = ref World.Get<PredictionState>(entity);
×
71

72
        // Check if we have a prediction for this tick
73
        if (!predictionBuffers.TryGetValue(entity, out var buffer))
×
74
        {
75
            return;
×
76
        }
77

78
        var predictedStates = buffer.GetStatesForTick(serverTick);
×
79
        if (predictedStates is null)
×
80
        {
81
            // No prediction for this tick, just apply server state
82
            ApplyServerState(entity, serverStates);
×
83
            predState.LastConfirmedTick = serverTick;
×
84
            return;
×
85
        }
86

87
        // Compare predicted vs server state
88
        var mispredicted = DetectMisprediction(predictedStates, serverStates);
×
89

90
        if (mispredicted)
×
91
        {
92
            predState.MispredictionDetected = true;
×
93
            Reconcile(entity, serverTick, serverStates);
×
94
        }
95
        else
96
        {
97
            predState.MispredictionDetected = false;
×
98
        }
99

100
        // Update confirmed tick and clean up old predictions
101
        predState.LastConfirmedTick = serverTick;
×
102
        buffer.RemoveOlderThan(serverTick);
×
103
    }
×
104

105
    private void SavePredictedState(Entity entity)
106
    {
107
        if (!predictionBuffers.TryGetValue(entity, out var buffer))
1✔
108
        {
109
            buffer = new PredictionBuffer(plugin.Config.InputBufferSize);
1✔
110
            predictionBuffers[entity] = buffer;
1✔
111
        }
112

113
        ref var predState = ref World.Get<PredictionState>(entity);
1✔
114
        var tick = plugin.CurrentTick;
1✔
115

116
        // Save all replicated component states
117
        var serializer = plugin.Config.Serializer;
1✔
118
        if (serializer is null)
1✔
119
        {
120
            return;
1✔
121
        }
122

123
        foreach (var (type, value) in World.GetComponents(entity))
×
124
        {
125
            if (serializer.IsNetworkSerializable(type))
×
126
            {
127
                // Clone the value to avoid storing a reference
128
                buffer.SaveState(tick, type, CloneValue(type, value));
×
129
            }
130
        }
131

132
        predState.LastPredictedTick = tick;
×
133
    }
×
134

135
    private bool DetectMisprediction(
136
        IReadOnlyDictionary<Type, object> predicted,
137
        IReadOnlyDictionary<Type, object> server)
138
    {
139
        var threshold = plugin.Config.MispredictionThreshold;
×
140

141
        foreach (var (type, serverValue) in server)
×
142
        {
143
            if (!predicted.TryGetValue(type, out var predictedValue))
×
144
            {
145
                // We don't have a prediction for this component, misprediction
146
                return true;
×
147
            }
148

149
            if (!ValuesApproximatelyEqual(predictedValue, serverValue, threshold))
×
150
            {
151
                return true;
×
152
            }
153
        }
154

155
        return false;
×
156
    }
×
157

158
    private bool ValuesApproximatelyEqual(object predicted, object server, float threshold)
159
    {
160
        // Fast path: exact equality
NEW
161
        if (predicted.Equals(server))
×
162
        {
163
            return true;
×
164
        }
165

166
        // Types must match
NEW
167
        var type = predicted.GetType();
×
NEW
168
        if (type != server.GetType())
×
169
        {
NEW
170
            return false;
×
171
        }
172

173
        // For threshold-based comparison, use the network serializer's delta detection
UNCOV
174
        if (threshold > 0)
×
175
        {
NEW
176
            var serializer = plugin.Config.Serializer;
×
NEW
177
            if (serializer is not null && serializer.SupportsDelta(type))
×
178
            {
179
                // If the dirty mask is 0, no fields have changed significantly
180
                // The dirty mask uses epsilon-based comparison for floats
NEW
181
                var dirtyMask = serializer.GetDirtyMask(type, predicted, server);
×
NEW
182
                return dirtyMask == 0;
×
183
            }
184
        }
185

186
        // Fallback: not approximately equal if not exactly equal
UNCOV
187
        return false;
×
188
    }
189

190
    private void Reconcile(
191
        Entity entity,
192
        uint serverTick,
193
        IReadOnlyDictionary<Type, object> serverStates)
194
    {
195
        // Step 1: Apply server state (rollback)
196
        ApplyServerState(entity, serverStates);
×
197

198
        // Step 2: Replay inputs from serverTick to current tick
199
        if (applyInput is not null)
×
200
        {
201
            var inputBuffer = plugin.GetInputBuffer(entity) as IInputBuffer;
×
202
            if (inputBuffer is not null)
×
203
            {
204
                foreach (var input in inputBuffer.GetInputsFromBoxed(serverTick + 1))
×
205
                {
206
                    applyInput(entity, input);
×
207
                }
208
            }
209
        }
210

211
        // Calculate correction magnitude for smoothing
212
        ref var predState = ref World.Get<PredictionState>(entity);
×
213

214
        // If we have an interpolator, we could use it for smooth corrections
215
        // For now, just set to a fixed value - a proper implementation would
216
        // calculate the actual distance between old and new positions
217
        predState.LastCorrectionMagnitude = 1.0f;
×
218

219
        // Store interpolator availability for potential future smoothing
220
        predState.SmoothingAvailable = smoothingInterpolator is not null;
×
221
    }
×
222

223
    private void ApplyServerState(Entity entity, IReadOnlyDictionary<Type, object> serverStates)
224
    {
225
        foreach (var (type, value) in serverStates)
×
226
        {
227
            World.SetComponent(entity, type, value);
×
228
        }
229
    }
×
230

231
    private static object CloneValue(Type type, object value)
232
    {
233
        // For value types (structs), boxing already creates a copy
234
        if (type.IsValueType)
×
235
        {
236
            return value;
×
237
        }
238

239
        // For reference types, we'd need deep cloning
240
        // Network components should be value types (structs)
241
        return value;
242
    }
243

244
    /// <summary>
245
    /// Registers an entity for prediction tracking.
246
    /// </summary>
247
    /// <param name="entity">The entity to track.</param>
248
    public void RegisterEntity(Entity entity)
249
    {
250
        if (!predictionBuffers.ContainsKey(entity))
×
251
        {
252
            predictionBuffers[entity] = new PredictionBuffer(plugin.Config.InputBufferSize);
×
253
        }
254
    }
×
255

256
    /// <summary>
257
    /// Unregisters an entity from prediction tracking.
258
    /// </summary>
259
    /// <param name="entity">The entity to unregister.</param>
260
    public void UnregisterEntity(Entity entity)
261
    {
262
        predictionBuffers.Remove(entity);
×
263
    }
×
264
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc