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

orion-ecs / keen-eye / 30120200337

24 Jul 2026 07:17PM UTC coverage: 64.788% (+0.08%) from 64.705%
30120200337

push

github

tyevco
test: Strengthen tautological/assertion-free tests to real assertions (#1236)

Closes #1236

8752 of 12780 branches covered (68.48%)

Branch coverage included in aggregate %.

51884 of 80812 relevant lines covered (64.2%)

1.0 hits per line

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

92.86
/src/KeenEyes.Core/Systems/AutoSaveSystem.cs
1
using KeenEyes.Capabilities;
2
using KeenEyes.Serialization;
3

4
namespace KeenEyes.Systems;
5

6
/// <summary>
7
/// A system that automatically saves the world state at configurable intervals.
8
/// </summary>
9
/// <remarks>
10
/// <para>
11
/// The AutoSaveSystem monitors elapsed time and optionally entity changes to trigger
12
/// automatic saves. It supports both full snapshots and delta (incremental) saves.
13
/// </para>
14
/// <para>
15
/// Delta saves are significantly smaller than full saves when few entities change
16
/// between saves. The system automatically creates a new baseline after a configurable
17
/// number of deltas to prevent long restoration chains.
18
/// </para>
19
/// <para>
20
/// This system requires the world to implement <see cref="ISaveLoadCapability"/>.
21
/// The standard <see cref="World"/> class provides this capability.
22
/// </para>
23
/// </remarks>
24
/// <example>
25
/// <code>
26
/// // Create auto-save system with default config
27
/// var autoSave = new AutoSaveSystem&lt;MySerializer&gt;(serializer);
28
/// world.AddSystem(autoSave, SystemPhase.PostRender);
29
///
30
/// // Configure for more frequent saves
31
/// autoSave.Config = AutoSaveConfig.Frequent;
32
///
33
/// // Manually trigger a save
34
/// autoSave.SaveNow();
35
/// </code>
36
/// </example>
37
/// <typeparam name="TSerializer">The serializer type that implements both serialization interfaces.</typeparam>
38
public sealed class AutoSaveSystem<TSerializer> : SystemBase
39
    where TSerializer : IComponentSerializer, IBinaryComponentSerializer
40
{
41
    // Extra delta slots scanned/cleaned beyond MaxDeltasBeforeBaseline. Provides a
42
    // safety margin so stragglers written just before a baseline rollover are not
43
    // missed by cleanup or highest-sequence discovery.
44
    private const int BaselineRetentionMargin = 5;
45

46
    private readonly TSerializer serializer;
47
    private AutoSaveConfig config;
48

49
    private float timeSinceLastSave;
50
    private int changesSinceLastSave;
51
    private int currentDeltaSequence;
52
    private WorldSnapshot? baselineSnapshot;
53
    private bool isInitialized;
54
    private EventSubscription? entityCreatedSubscription;
55
    private EventSubscription? entityDestroyedSubscription;
56

57
    /// <summary>
58
    /// Creates a new auto-save system with the specified serializer.
59
    /// </summary>
60
    /// <param name="serializer">The component serializer for AOT-compatible serialization.</param>
61
    /// <param name="config">Optional configuration. Uses default if not specified.</param>
62
    public AutoSaveSystem(TSerializer serializer, AutoSaveConfig? config = null)
1✔
63
    {
64
        ArgumentNullException.ThrowIfNull(serializer);
1✔
65
        this.serializer = serializer;
1✔
66
        this.config = config ?? AutoSaveConfig.Default;
1✔
67
    }
1✔
68

69
    /// <summary>
70
    /// Gets or sets the auto-save configuration.
71
    /// </summary>
72
    public AutoSaveConfig Config
73
    {
74
        get => config;
1✔
75
        set
76
        {
77
            ArgumentNullException.ThrowIfNull(value);
1✔
78
            config = value;
1✔
79
        }
1✔
80
    }
81

82
    /// <summary>
83
    /// Gets the time elapsed since the last save.
84
    /// </summary>
85
    public float TimeSinceLastSave => timeSinceLastSave;
1✔
86

87
    /// <summary>
88
    /// Gets the number of tracked entity changes (creations and destructions) since the last save.
89
    /// </summary>
90
    /// <remarks>
91
    /// This is the value compared against <see cref="AutoSaveConfig.ChangeThreshold"/> to drive
92
    /// the change-based auto-save trigger. It resets to zero after each successful save.
93
    /// </remarks>
94
    public int ChangesSinceLastSave => changesSinceLastSave;
1✔
95

96
    /// <summary>
97
    /// Gets the current delta sequence number (0 if no saves have occurred).
98
    /// </summary>
99
    public int CurrentDeltaSequence => currentDeltaSequence;
1✔
100

101
    /// <summary>
102
    /// Gets whether a baseline snapshot exists.
103
    /// </summary>
104
    public bool HasBaseline => baselineSnapshot is not null;
1✔
105

106
    /// <summary>
107
    /// Event raised when an auto-save occurs.
108
    /// </summary>
109
    public event Action<SaveSlotInfo>? OnAutoSave;
110

111
    /// <summary>
112
    /// Event raised when an auto-save fails.
113
    /// </summary>
114
    public event Action<Exception>? OnAutoSaveError;
115

116
    /// <inheritdoc />
117
    protected override void OnInitialize()
118
    {
119
        isInitialized = true;
1✔
120

121
        // Subscribe to entity lifecycle events to drive the change-based trigger. Entity
122
        // creations and destructions since the last save are counted and compared against
123
        // AutoSaveConfig.ChangeThreshold in Update.
124
        entityCreatedSubscription = World.OnEntityCreated((_, _) => changesSinceLastSave++);
1✔
125
        entityDestroyedSubscription = World.OnEntityDestroyed(_ => changesSinceLastSave++);
1✔
126

127
        // Try to load existing baseline if it exists
128
        if (World is ISaveLoadCapability saveLoad && saveLoad.SaveSlotExists(config.BaselineSlotName))
1✔
129
        {
130
            try
131
            {
132
                var info = saveLoad.GetSaveSlotInfo(config.BaselineSlotName);
1✔
133
                if (info is not null)
1✔
134
                {
135
                    // Read the baseline snapshot for delta comparison WITHOUT mutating the
136
                    // live world. Using LoadFromSlot here would clear the current scene and
137
                    // replace it with the baseline's entities (see issue #1131).
138
                    baselineSnapshot = saveLoad.ReadSnapshotFromSlot(config.BaselineSlotName, serializer);
1✔
139

140
                    // Find the highest existing delta sequence
141
                    currentDeltaSequence = FindHighestDeltaSequence(saveLoad);
1✔
142
                }
143
            }
1✔
144
            catch
×
145
            {
146
                // If loading fails, we'll create a new baseline on first save
147
                baselineSnapshot = null;
×
148
                currentDeltaSequence = 0;
×
149
            }
×
150
        }
151
    }
1✔
152

153
    /// <inheritdoc />
154
    public override void Update(float deltaTime)
155
    {
156
        if (!config.Enabled || !isInitialized || World is not ISaveLoadCapability saveLoad)
1✔
157
        {
158
            return;
1✔
159
        }
160

161
        timeSinceLastSave += deltaTime;
1✔
162

163
        // Check if we should save
164
        bool shouldSave = false;
1✔
165

166
        // Time-based trigger
167
        if (config.AutoSaveIntervalSeconds > 0 && timeSinceLastSave >= config.AutoSaveIntervalSeconds)
1✔
168
        {
169
            shouldSave = true;
1✔
170
        }
171

172
        // Change-based trigger
173
        if (config.ChangeThreshold > 0 && changesSinceLastSave >= config.ChangeThreshold)
1✔
174
        {
175
            shouldSave = true;
1✔
176
        }
177

178
        if (shouldSave)
1✔
179
        {
180
            PerformAutoSave(saveLoad);
1✔
181
        }
182
    }
1✔
183

184
    /// <summary>
185
    /// Manually triggers an auto-save immediately.
186
    /// </summary>
187
    /// <returns>The save slot info, or null if save failed.</returns>
188
    public SaveSlotInfo? SaveNow()
189
    {
190
        if (!isInitialized || World is not ISaveLoadCapability saveLoad)
1✔
191
        {
192
            return null;
1✔
193
        }
194

195
        return PerformAutoSave(saveLoad);
1✔
196
    }
197

198
    /// <summary>
199
    /// Creates a new baseline snapshot, resetting the delta chain.
200
    /// </summary>
201
    /// <returns>The save slot info for the new baseline.</returns>
202
    public SaveSlotInfo? CreateNewBaseline()
203
    {
204
        if (!isInitialized || World is not ISaveLoadCapability saveLoad)
1✔
205
        {
206
            return null;
1✔
207
        }
208

209
        return SaveBaseline(saveLoad);
1✔
210
    }
211

212
    /// <summary>
213
    /// Resets the auto-save state, clearing any existing baseline.
214
    /// </summary>
215
    public void Reset()
216
    {
217
        timeSinceLastSave = 0;
1✔
218
        changesSinceLastSave = 0;
1✔
219
        currentDeltaSequence = 0;
1✔
220
        baselineSnapshot = null;
1✔
221
    }
1✔
222

223
    private SaveSlotInfo? PerformAutoSave(ISaveLoadCapability saveLoad)
224
    {
225
        try
226
        {
227
            SaveSlotInfo info;
228

229
            if (config.UseDeltaSaves && baselineSnapshot is not null)
1✔
230
            {
231
                // Check if we should create a new baseline
232
                if (currentDeltaSequence >= config.MaxDeltasBeforeBaseline)
1✔
233
                {
234
                    info = SaveBaseline(saveLoad);
1✔
235
                }
236
                else
237
                {
238
                    info = SaveDelta(saveLoad);
1✔
239
                }
240
            }
241
            else
242
            {
243
                // Create baseline (either first save or full saves mode)
244
                info = SaveBaseline(saveLoad);
1✔
245
            }
246

247
            // Reset trigger accumulators
248
            timeSinceLastSave = 0;
1✔
249
            changesSinceLastSave = 0;
1✔
250

251
            // Clear dirty flags if configured
252
            if (config.ClearDirtyFlagsAfterSave)
1✔
253
            {
254
                saveLoad.ClearAllDirtyFlags();
1✔
255
            }
256

257
            // Raise event
258
            OnAutoSave?.Invoke(info);
1✔
259

260
            return info;
1✔
261
        }
262
        catch (Exception ex)
×
263
        {
264
            OnAutoSaveError?.Invoke(ex);
×
265
            return null;
×
266
        }
267
    }
1✔
268

269
    private SaveSlotInfo SaveBaseline(ISaveLoadCapability saveLoad)
270
    {
271
        // Create snapshot
272
        var snapshot = saveLoad.CreateSnapshot(serializer);
1✔
273

274
        // Save as baseline
275
        var info = saveLoad.SaveToSlot(config.BaselineSlotName, serializer, config.SaveOptions);
1✔
276

277
        // Update state
278
        baselineSnapshot = snapshot;
1✔
279
        currentDeltaSequence = 0;
1✔
280

281
        // Clean up old deltas
282
        CleanupOldDeltas(saveLoad);
1✔
283

284
        return info;
1✔
285
    }
286

287
    private SaveSlotInfo SaveDelta(ISaveLoadCapability saveLoad)
288
    {
289
        // Increment sequence
290
        currentDeltaSequence++;
1✔
291

292
        // Create true delta by comparing current state to baseline
293
        var delta = saveLoad.CreateDelta(
1✔
294
            baselineSnapshot!,
1✔
295
            serializer,
1✔
296
            config.BaselineSlotName,
1✔
297
            currentDeltaSequence);
1✔
298

299
        // If delta is empty, skip saving
300
        if (delta.IsEmpty)
1✔
301
        {
302
            currentDeltaSequence--;
1✔
303
            // Return a placeholder info for the baseline
304
            return saveLoad.GetSaveSlotInfo(config.BaselineSlotName)
1✔
305
                ?? throw new InvalidOperationException("Baseline slot not found");
1✔
306
        }
307

308
        // Save delta to slot
309
        var slotName = config.GetDeltaSlotName(currentDeltaSequence);
1✔
310
        var info = saveLoad.SaveDeltaToSlot(slotName, delta, serializer, config.SaveOptions with
1✔
311
        {
1✔
312
            DisplayName = $"Auto-save (Delta #{currentDeltaSequence})"
1✔
313
        });
1✔
314

315
        return info;
1✔
316
    }
317

318
    private void CleanupOldDeltas(ISaveLoadCapability saveLoad)
319
    {
320
        // Remove old delta files when a new baseline is created
321
        for (int i = 1; i <= config.MaxDeltasBeforeBaseline + BaselineRetentionMargin; i++)
1✔
322
        {
323
            var slotName = config.GetDeltaSlotName(i);
1✔
324
            if (saveLoad.SaveSlotExists(slotName))
1✔
325
            {
326
                saveLoad.DeleteSaveSlot(slotName);
1✔
327
            }
328
        }
329
    }
1✔
330

331
    private int FindHighestDeltaSequence(ISaveLoadCapability saveLoad)
332
    {
333
        int highest = 0;
1✔
334
        for (int i = 1; i <= config.MaxDeltasBeforeBaseline + BaselineRetentionMargin; i++)
1✔
335
        {
336
            if (saveLoad.SaveSlotExists(config.GetDeltaSlotName(i)))
1✔
337
            {
338
                highest = i;
×
339
            }
340
        }
341
        return highest;
1✔
342
    }
343

344
    /// <inheritdoc />
345
    protected override void Dispose(bool disposing)
346
    {
347
        if (disposing)
1✔
348
        {
349
            entityCreatedSubscription?.Dispose();
1✔
350
            entityDestroyedSubscription?.Dispose();
1✔
351
            entityCreatedSubscription = null;
1✔
352
            entityDestroyedSubscription = null;
1✔
353
        }
354

355
        base.Dispose(disposing);
1✔
356
    }
1✔
357
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc