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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 18:01:34 +02:00
commit 5c3cd599ac
2 changed files with 26 additions and 1 deletions

View file

@ -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;

View file

@ -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<string>();
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.