From aefd0c0a924863db05d72f6cf8da79a7379fc2b8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 19:31:03 +0200 Subject: [PATCH 1/4] Zone places every orbit when it is built; delete AddOrbit and CreateOrbit Orbit.Position and PreviousPosition are set from GetOrbitPosition at the pack's time in the constructor, so wells, lights and poses are valid before the first Update. AddOrbit and CreateOrbit had no caller. Tests not yet run. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Zone.cs | 31 +--- .../ZonePlacedAtBuildTests.cs | 150 ++++++++++++++++++ 2 files changed, 156 insertions(+), 25 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/ZonePlacedAtBuildTests.cs diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 16373f147..715d965bd 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -161,6 +161,12 @@ public class Zone Orbits.Add(orbit.Key, new Orbit(Settings, cache.Get(orbit))); } + // Aetheria gravity (target invariant zone-valid-before-tick): every orbit is placed at the pack's time + // when the zone is built, so each well, light and body pose read before the first Update is where the + // body is. Velocity stays zero until the first Update differences two placed positions. + foreach (var orbit in Orbits) + orbit.Value.PreviousPosition = GetOrbitPosition(orbit.Key); + foreach (var body in pack.Planets) { var planet = cache.Get(body); @@ -266,11 +272,6 @@ public class Zone private bool IsExpired(ChunkWear wear) => wear.Damage <= 0f && (!wear.BrokenUntil.HasValue || wear.BrokenUntil.Value <= _time); - public void AddOrbit(OrbitData orbit) - { - Orbits.Add(_itemManager.ItemData.RefOf(orbit).Key, new Orbit(Settings, orbit)); - } - public void Update(float deltaTime) { _time += deltaTime; @@ -591,26 +592,6 @@ public class Zone into[i] = beltInstance.Pose(i, _time, parentPosition, Settings, ChunkRadius(new ChunkId(belt, i))); } - public OrbitData CreateOrbit(CultRecordKey parent, float2 position) - { - var parentPosition = GetOrbitPosition(parent); - var delta = position - parentPosition; - var distance = length(delta); - var period = Settings.OrbitPeriod.Evaluate(distance); - var phase = atan2(delta.y, delta.x) / (PI * 2); - var currentPhase = frac(_time / period); - var storedPhase = (float) frac(phase - currentPhase); - - var orbit = new OrbitData - { - Distance = distance, - Parent = new CultRecordRef(parent), - Phase = storedPhase - }; - Orbits.Add(_itemManager.ItemData.Upsert(orbit).Key, new Orbit(Settings, orbit)); - return orbit; - } - public SecurityLevel GetSecurityLevel(float2 pos) { if (GalaxyZone.Owner==null) return SecurityLevel.Open; diff --git a/tests/Aetheria.Shared.Tests/ZonePlacedAtBuildTests.cs b/tests/Aetheria.Shared.Tests/ZonePlacedAtBuildTests.cs new file mode 100644 index 000000000..74cdc2003 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ZonePlacedAtBuildTests.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.IO; +using CultMath; +using GameCult.Caching; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// Aetheria gravity, target invariant zone-valid-before-tick: a zone's orbits are placed when it is built, so +// its wells, lights and body poses are right before the first Update (follow-up +// aetheria-release:follow_up:zone-orbits-unplaced-before-first-tick). The zone here is the production +// constructor reading a pack; nothing places an orbit but that constructor and Zone.Update. +public sealed class ZonePlacedAtBuildTests : IDisposable +{ + private static readonly float2 RootAt = new float2(300f, -200f); + private const float Distance = 400f; + private const float Period = 40f; + + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-zoneplaced-" + Guid.NewGuid().ToString("N")); + private readonly List _caches = new List(); + + public ZonePlacedAtBuildTests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + foreach (var cache in _caches) cache.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings GameSettings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp() + }; + + private static ExponentialCurve Flat(float v) => new ExponentialCurve { Constant = v }; + + // Flat space (no zone depth) with one planet whose well reaches 500 u, a fixed root orbit at RootAt and the + // planet orbiting it at Distance from `phase`. Every orbit period is Period seconds. + private static PlanetSettings Settings() => new PlanetSettings + { + OrbitPeriod = Flat(Period), + BodyRadius = Flat(20f), + GravityRadius = Flat(500f), + GravityDepth = Flat(100f), + GravityStrength = 1f, + WaveDepth = Flat(0f), WaveRadius = Flat(0f), WaveSpeed = Flat(0f), WaveFrequency = Flat(0f), + LightRadius = Flat(0f) + }; + + private sealed class Scene + { + public CultCache Cache; + public ItemManager Items; + public HullData Hull; + public Zone Zone; + public CultRecordKey PlanetOrbit; + } + + private Scene Build(float phase, double time) + { + var cache = AetheriaStores.Open(Path.Combine(_root, $"{_caches.Count}.cc"), catalogWritable: true); + _caches.Add(cache); + cache.Upsert(new VerseGrammar { Revision = 1 }); + var shape = new Shape(5, 5); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + var hull = new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = shape, Durability = 10, Mass = 1000 }; + cache.Upsert(hull); + var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }); + + var rootOrbit = cache.Upsert(new OrbitData { FixedPosition = RootAt }).Key; + var planetOrbit = cache.Upsert(new OrbitData + { + Parent = new CultRecordRef(rootOrbit), Distance = Distance, Phase = phase + }).Key; + var planet = cache.Upsert(new PlanetData { Orbit = new CultRecordRef(planetOrbit), Mass = 1f }).Key; + var pack = new ZonePack + { + Orbits = { new CultRecordRef(rootOrbit), new CultRecordRef(planetOrbit) }, + Planets = { new CultRecordRef(planet) }, + Radius = 5000f, + Mass = 10000f, + Time = time + }; + var zone = new Zone(items, Settings(), pack, new GalaxyZone { Name = "Placed", Owner = null }, null); + return new Scene { Cache = cache, Items = items, Hull = hull, Zone = zone, PlanetOrbit = planetOrbit }; + } + + [Theory] + [InlineData(.1f, 0.0)] + [InlineData(.35f, 0.0)] + [InlineData(.1f, 123.0)] + [InlineData(.35f, 123.0)] + public void ABodysWellSitsOnItsOrbitBeforeTheFirstTick(float phase, double time) + { + var s = Build(phase, time); + var orbit = s.Zone.Orbits[s.PlanetOrbit]; + var turns = (float) frac(time / orbit.Period); + var expected = RootAt + OrbitData.Evaluate(frac(turns + phase)) * Distance; + + Assert.InRange(length(orbit.Position - expected), 0f, .01f); + Assert.InRange(length(orbit.PreviousPosition - expected), 0f, .01f); + + // The well pulls toward the body's place: a point 30 u east of it is pushed west. + var force = s.Zone.GetForce(expected + new float2(30f, 0f)); + Assert.True(length(force) > 0f, "the well exerts a force"); + Assert.InRange(normalize(force).x, -1f, -.99f); + } + + [Fact] + public void TheFirstTickVelocityIsTheOrbitsSpeed() + { + var s = Build(.35f, 0.0); + const float dt = 1f / 60f; + s.Zone.Update(dt); + + var speed = length(s.Zone.Orbits[s.PlanetOrbit].Velocity); + var expected = 2f * PI * Distance / Period; + Assert.InRange(speed, expected * .99f, expected * 1.01f); + } + + [Fact] + public void AShipSteppedAloneFeelsTheBodyWhereItIs() + { + var s = Build(.1f, 0.0); + var planetAt = s.Zone.Orbits[s.PlanetOrbit].Position; + var ship = new Ship(s.Items, s.Zone, new EquippableItem + { + Data = s.Cache.RefOf(s.Hull), + Durability = s.Hull.Durability, + Lot = s.Items.Lots.Add(new Lot + { + Design = s.Cache.RefOf(s.Hull), Origin = new Attributed(), Quality = .5f, Roles = new List() + }) + }, new EntitySettings()); + s.Zone.Admit(ship, piloted: false); + var at = planetAt + new float2(0f, 60f); + ship.Position = float3(at.x, ship.Position.y, at.y); + ship.Velocity = float2.zero; + + ship.Update(1f / 60f); // the ship alone: no Zone.Update has run + + Assert.True(length(ship.Velocity) > 0f, "the ship is pulled"); + var toward = normalize(planetAt - at); + Assert.True(dot(normalize(ship.Velocity), toward) > .99f, "the pull is toward the planet, not toward the origin"); + } +} From a8876d7a335375469bfafe9f3b44163fa61da17c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 19:34:21 +0200 Subject: [PATCH 2/4] ZonePlacedAtBuildTests: open the catalog and run stores as the other zone fixtures do Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ZonePlacedAtBuildTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/ZonePlacedAtBuildTests.cs b/tests/Aetheria.Shared.Tests/ZonePlacedAtBuildTests.cs index 74cdc2003..87085b922 100644 --- a/tests/Aetheria.Shared.Tests/ZonePlacedAtBuildTests.cs +++ b/tests/Aetheria.Shared.Tests/ZonePlacedAtBuildTests.cs @@ -62,7 +62,9 @@ public sealed class ZonePlacedAtBuildTests : IDisposable private Scene Build(float phase, double time) { - var cache = AetheriaStores.Open(Path.Combine(_root, $"{_caches.Count}.cc"), catalogWritable: true); + var dir = Path.Combine(_root, $"zone{_caches.Count}"); + Directory.CreateDirectory(dir); + var cache = AetheriaStores.Open(Path.Combine(dir, "Aetheria.cc"), Path.Combine(dir, "run.cc"), catalogWritable: true); _caches.Add(cache); cache.Upsert(new VerseGrammar { Revision = 1 }); var shape = new Shape(5, 5); From 9fb5aad4541726554e00846c9ee7856cb8946332 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 19:50:08 +0200 Subject: [PATCH 3/4] EvSettle returns the ship to where it stood A ship stepped alone drifts under the zone's wells while it idles. Orbits are now placed at build, so those wells are where the bodies are and the drift is real; the scenarios (Crossing, FusedRounds) staged geometry the wrong wells happened to leave undisturbed. Settling is about stats and power, not place. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/EvasionTermTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs index 0b0688457..ffd99d16e 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs @@ -81,11 +81,15 @@ public sealed partial class RunStartTests // Idle long enough for stats and power to reach their idle state, then at rest, facing +y, // and, after three seconds of coasting, with nothing observed: the track forgets on its own. + // The ship is stepped alone, so the zone's wells pull it while it idles; settling is about stats and power, so + // it comes back to where it stood. private static void EvSettle(Ship ship) { + var stood = ship.Position.xz; ship.MovementDirection = float2(0, 0); EvFace(ship, float3(0, 0, 1)); for (var step = 0; step < 180; step++) ship.Update(EvDt); + ship.Position.xz = stood; ship.Velocity = float2(0, 0); ship.Direction = float2(0, 1); } From 205098ef93bd0f7fa59112abe4772d88c68fbc79 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 21:19:09 +0200 Subject: [PATCH 4/4] Zone.Update takes every previous position before placing any orbit GetOrbitPosition places a parent as a side effect of placing its child, so with a child listed first the parent's previous position was already its new one and its Velocity came out zero. The constructor's unread PreviousPosition write is dropped; the first Update takes it from the placed Position. The velocity test now runs moon-first and planet-first packs over varying dt. Builds not verified yet (verify follows). Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Zone.cs | 7 ++-- .../ZonePlacedAtBuildTests.cs | 32 ++++++++++++------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 715d965bd..eee1aa869 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -165,7 +165,7 @@ public class Zone // when the zone is built, so each well, light and body pose read before the first Update is where the // body is. Velocity stays zero until the first Update differences two placed positions. foreach (var orbit in Orbits) - orbit.Value.PreviousPosition = GetOrbitPosition(orbit.Key); + GetOrbitPosition(orbit.Key); foreach (var body in pack.Planets) { @@ -276,9 +276,12 @@ public class Zone { _time += deltaTime; _updatedOrbits.Clear(); + // Every previous position is taken before any orbit is placed: GetOrbitPosition places a parent as a + // side effect of placing its child, so differencing inside one loop would depend on dictionary order. + foreach (var orbit in Orbits) + orbit.Value.PreviousPosition = orbit.Value.Position; foreach (var orbit in Orbits) { - orbit.Value.PreviousPosition = orbit.Value.Position; orbit.Value.Position = GetOrbitPosition(orbit.Key); orbit.Value.Velocity = (orbit.Value.Position - orbit.Value.PreviousPosition) / deltaTime; } diff --git a/tests/Aetheria.Shared.Tests/ZonePlacedAtBuildTests.cs b/tests/Aetheria.Shared.Tests/ZonePlacedAtBuildTests.cs index 87085b922..de9015a57 100644 --- a/tests/Aetheria.Shared.Tests/ZonePlacedAtBuildTests.cs +++ b/tests/Aetheria.Shared.Tests/ZonePlacedAtBuildTests.cs @@ -60,7 +60,7 @@ public sealed class ZonePlacedAtBuildTests : IDisposable public CultRecordKey PlanetOrbit; } - private Scene Build(float phase, double time) + private Scene Build(float phase, double time, bool moonFirst = false) { var dir = Path.Combine(_root, $"zone{_caches.Count}"); Directory.CreateDirectory(dir); @@ -78,15 +78,21 @@ public sealed class ZonePlacedAtBuildTests : IDisposable { Parent = new CultRecordRef(rootOrbit), Distance = Distance, Phase = phase }).Key; + var moonOrbit = cache.Upsert(new OrbitData + { + Parent = new CultRecordRef(planetOrbit), Distance = 100f + }).Key; var planet = cache.Upsert(new PlanetData { Orbit = new CultRecordRef(planetOrbit), Mass = 1f }).Key; var pack = new ZonePack { - Orbits = { new CultRecordRef(rootOrbit), new CultRecordRef(planetOrbit) }, Planets = { new CultRecordRef(planet) }, Radius = 5000f, Mass = 10000f, Time = time }; + // The same three orbits either way; only the order the pack lists them in differs. + var listed = moonFirst ? new[] { moonOrbit, planetOrbit, rootOrbit } : new[] { rootOrbit, planetOrbit, moonOrbit }; + foreach (var key in listed) pack.Orbits.Add(new CultRecordRef(key)); var zone = new Zone(items, Settings(), pack, new GalaxyZone { Name = "Placed", Owner = null }, null); return new Scene { Cache = cache, Items = items, Hull = hull, Zone = zone, PlanetOrbit = planetOrbit }; } @@ -104,7 +110,6 @@ public sealed class ZonePlacedAtBuildTests : IDisposable var expected = RootAt + OrbitData.Evaluate(frac(turns + phase)) * Distance; Assert.InRange(length(orbit.Position - expected), 0f, .01f); - Assert.InRange(length(orbit.PreviousPosition - expected), 0f, .01f); // The well pulls toward the body's place: a point 30 u east of it is pushed west. var force = s.Zone.GetForce(expected + new float2(30f, 0f)); @@ -112,16 +117,21 @@ public sealed class ZonePlacedAtBuildTests : IDisposable Assert.InRange(normalize(force).x, -1f, -.99f); } - [Fact] - public void TheFirstTickVelocityIsTheOrbitsSpeed() + // A parent's velocity is its own orbital velocity on the first tick and every tick after, whichever order + // the pack lists a moon and its planet in. dt differs per tick so a velocity taken from a fixed step fails. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void TheFirstTickVelocityIsTheOrbitsSpeed(bool moonFirst) { - var s = Build(.35f, 0.0); - const float dt = 1f / 60f; - s.Zone.Update(dt); - - var speed = length(s.Zone.Orbits[s.PlanetOrbit].Velocity); + var s = Build(.35f, 0.0, moonFirst); var expected = 2f * PI * Distance / Period; - Assert.InRange(speed, expected * .99f, expected * 1.01f); + foreach (var dt in new[] { 1f / 60f, 1f / 30f, 1f / 60f }) + { + s.Zone.Update(dt); + var speed = length(s.Zone.Orbits[s.PlanetOrbit].Velocity); + Assert.InRange(speed, expected * .99f, expected * 1.01f); + } } [Fact]