From 5c3cd599ac2180053f3063d6eba08f3ea7fcda5b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 18:01:34 +0200 Subject: [PATCH] Zone: wait for last tick's belt tasks before clearing the orbit cache Update cleared `_updatedOrbits` and only then waited on the previous tick's asteroid-belt tasks, which read the cache through GetOrbitPosition. A task still running could see the set mid-clear ("concurrent update ... corrupted its state") or recompute an orbit into last tick's position. The wait now comes first. Test (Soul's orbit counter, committed): 3000 back-to-back 0.02 s ticks of a quiet arena, and no orbit stands still against its parent. Clearing first gave 500+ stalls or the corruption exception. Measured against the parent because a moon can genuinely pause in zone space: one such reading (tick 1321, a moon whose motion cancels its planet's) is real motion, not the race. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Zone.cs | 3 ++- tests/Aetheria.Shared.Tests/RunStartTests.cs | 24 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index ec8aa304c..b1d7239bd 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -172,10 +172,11 @@ public class Zone public void Update(float deltaTime) { _time += deltaTime; - _updatedOrbits.Clear(); + // Last tick's belt tasks read the orbit cache; they finish before it is cleared. foreach (var t in BeltUpdates) t.Wait(); BeltUpdates.Clear(); + _updatedOrbits.Clear(); foreach (var orbit in Orbits) { orbit.Value.PreviousPosition = orbit.Value.Position; diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index 152efd675..b9ec9a7a0 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -462,6 +462,30 @@ public sealed class RunStartTests : IDisposable Assert.Contains("heater", failure.Message); } + // Orbit positions are computed once per tick, before the asteroid belts' tasks start, and those tasks finish + // before the next tick clears the cache. Were the cache cleared under a running belt task, an orbit could be + // served last tick's position. Over 3000 back-to-back ticks no orbit stands still against its parent. (Measured + // against the parent because a moon can genuinely pause in zone space, where its motion cancels its planet's.) + [Fact] + public void NoOrbitStandsStillAgainstItsParent() + { + var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs + Assert.True(zone.AsteroidBelts.Count > 0, "the arena has an asteroid belt to race against"); + float2 Relative(Orbit orbit, bool previous) => + (previous ? orbit.PreviousPosition : orbit.Position) - + (zone.Orbits.TryGetValue(orbit.Data.Parent.Key, out var parent) ? previous ? parent.PreviousPosition : parent.Position : orbit.Data.FixedPosition); + zone.Update(.02f); + var stalls = new List(); + for (var tick = 0; tick < 3000; tick++) + { + zone.Update(.02f); + stalls.AddRange(zone.Orbits.Values + .Where(orbit => orbit.Period > .01f && orbit.Data.Distance > 0 && lengthsq(Relative(orbit, false) - Relative(orbit, true)) == 0) + .Select(orbit => $"tick {tick}: period {orbit.Period}, distance {orbit.Data.Distance}, at {orbit.Position}")); + } + Assert.True(stalls.Count == 0, $"{stalls.Count} readings of an orbit standing still; first {stalls.FirstOrDefault()}"); + } + // Q3, with the operator's 2026-09-30 ruling on the ten product-less designs: a design no product sells is a // scenario test design and nothing else. A test design is one a scenario places: its preset's hull or a slot of // it, or cargo it carries. These are the unsold designs no scenario places, by name.