diff --git a/Assets/Resources/Settings.asset b/Assets/Resources/Settings.asset index df56f95ed..b007e4a33 100644 --- a/Assets/Resources/Settings.asset +++ b/Assets/Resources/Settings.asset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9d641fe942dfd45990a0609b3017a63cdc27f502ab12aa74ffbf984ea3e8d346 -size 12644 +oid sha256:f5fda2fa736e02d879a317152738e0a4c44c10b356a6dbd41cf31d2d7e7e6fd8 +size 12571 diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index 24078a658..f09a6b187 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -31,7 +31,6 @@ public class PlanetSettings public ExponentialLerp AsteroidRespawnTime; public float SecureAreaRadiusMultiplier = .45f; - public ExponentialCurve OrbitPeriod; // The global flow stardust and nebula follow and the sim knows (FlowField). Zero flow by default. public FlowSettings Flow = new FlowSettings(); diff --git a/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs b/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs index cba7ea628..3e12536c3 100644 --- a/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs +++ b/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs @@ -59,8 +59,8 @@ public sealed class BeltTargets : ITargetProvider { order[i] = i; distances[i] = data.Asteroids[i].Distance; - // Pose divides by the float period the curve returns; the keys must use the same one. - _periods[i] = settings.OrbitPeriod.Evaluate(distances[i]); + // The belt's own periods (Zone.PeriodAround), the very floats Pose divides by. + _periods[i] = zone.AsteroidBelts[field].Periods[i]; } Array.Sort(distances, order); _bands = new Band[(count + RocksPerBand - 1) / RocksPerBand]; diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index db4bf6b3d..d6981f30d 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -158,15 +158,9 @@ public class Zone foreach (var orbit in pack.Orbits) { - Orbits.Add(orbit.Key, new Orbit(Settings, cache.Get(orbit))); + Orbits.Add(orbit.Key, new Orbit(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) - GetOrbitPosition(orbit.Key); - foreach (var body in pack.Planets) { var planet = cache.Get(body); @@ -178,7 +172,6 @@ public class Zone // the rule generation uses, and keeps it. FieldKinds.Ensure(body.Key, belt, cache); AsteroidBelts[body.Key] = new AsteroidBelt(belt); - Targets.Add(new BeltTargets(this, body.Key, belt, settings, _time)); break; case SunData sun: var sunInstance = new Sun(settings, sun, Orbits[planet.Orbit.Key]); @@ -194,6 +187,40 @@ public class Zone } } + // Orbits, bodies and belts exist: every rail's period is derived once, from the field it orbits in. + foreach (var orbit in Orbits) + { + var parent = orbit.Value.Data.Parent; + if (!parent.IsSet()) continue; + if (!_childOrbits.TryGetValue(parent.Key, out var siblings)) + _childOrbits[parent.Key] = siblings = new List(); + siblings.Add(orbit.Key); + } + foreach (var body in PlanetInstances) + if (body.Value.WellStrength > 0) + _wellOn.TryAdd(Planets[body.Key].Orbit.Key, body.Value); + foreach (var orbit in Orbits) + orbit.Value.Period = orbit.Value.Data.Parent.IsSet() + ? PeriodAround(orbit.Value.Data.Parent.Key, orbit.Value.Data.Distance, orbit.Key) + : float.PositiveInfinity; + foreach (var belt in AsteroidBelts) + { + var beltOrbit = Planets[belt.Key].Orbit.Key; + var beltParent = Orbits[beltOrbit].Data.Parent.Key; + for (var i = 0; i < belt.Value.Periods.Length; i++) + belt.Value.Periods[i] = PeriodAround(beltParent, belt.Value.Data.Asteroids[i].Distance, beltOrbit); + } + + // 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) + GetOrbitPosition(orbit.Key); + + // BeltTargets copies the belts' periods, so it is built after them. + foreach (var belt in AsteroidBelts) + Targets.Add(new BeltTargets(this, belt.Key, belt.Value.Data, settings, _time)); + // Cut 2 (docs/mining-cut.md): read after belts are built so a later ChunkExists/ChunkRadius call has // Planets/AsteroidBelts populated. A pack from before key 6 existed has ChunkWear == null (nullable // persistence rule); that reads as no wear rather than throwing. @@ -481,6 +508,8 @@ public class Zone { var orbit = Orbits[orbitID]; float2 pos = float2.zero; + // An infinite period (a parentless orbit, or one with no field to circle in) leaves the orbit at its phase: + // frac(t / inf) is 0. if (orbit.Period > .01f) { var phase = (float) frac(_time / orbit.Period); @@ -503,6 +532,67 @@ public class Zone return Orbits[orbitID].Position; } + // orbit -> the well-bearing body on it, and orbit -> the orbits whose parent it is. Derived from the pack in the + // constructor, read only by PeriodAround and Strength. + private readonly Dictionary _wellOn = new Dictionary(); + private readonly Dictionary> _childOrbits = new Dictionary>(); + private readonly Dictionary _strengths = new Dictionary(); + + // The one owner of a rail's period (Orbit.Period, AsteroidBelt.Periods): 2 pi sqrt(d / a), a being the pull the + // field gives at distance d from the node being orbited. Round a body, that is the body's own well through + // GravityField. Round a bodyless node it is the ring law: the strength of the node's orbits inside d, plus the + // ring term for the orbits on d itself (within RingWindow of it), taken as a point at the node. A bodied self + // feels half the strength of its ring partners (as a binary does); a strengthless self (a station, a turret) + // takes the period its bodied ring partners have, the mean of what each of them feels. No pull gives an + // infinite period. + private const float RingWindow = 0.1f; + + private float PeriodAround(CultRecordKey parentOrbit, float distance, CultRecordKey self) + { + float a; + if (_wellOn.TryGetValue(parentOrbit, out var body)) + a = length(GravityField.Acceleration(body.WellStrength, body.RocheLimit, float2(distance, 0))); + else + { + var inside = 0f; + var ring = new List<(CultRecordKey Orbit, float Strength)>(); + if (_childOrbits.TryGetValue(parentOrbit, out var children)) + foreach (var child in children) + { + var childDistance = Orbits[child].Data.Distance; + var childStrength = Strength(child); + if (childDistance < distance - RingWindow) inside += childStrength; + else if (childDistance <= distance + RingWindow && childStrength > 0) ring.Add((child, childStrength)); + } + // Whose pull self feels: its own if it has strength, else each bodied member of the ring. + var ringSum = 0f; + foreach (var member in ring) ringSum += member.Strength; + var selfBodied = Strength(self) > 0; + var feelings = 0f; + var feelers = 0; + foreach (var member in ring) + if (!selfBodied || member.Orbit.Equals(self)) + { + feelings += (ringSum - member.Strength) / 2; + feelers++; + } + var strength = inside + (feelers > 0 ? feelings / feelers : 0f); + a = length(GravityField.Acceleration(strength, 0f, float2(distance, 0))); + } + return a > 0 && distance > 0 ? 2 * PI * sqrt(distance / a) : float.PositiveInfinity; + } + + // The well strength an orbit carries as a node: its own body's plus every orbit round it, recursively. + private float Strength(CultRecordKey orbit) + { + if (_strengths.TryGetValue(orbit, out var known)) return known; + var strength = _wellOn.TryGetValue(orbit, out var body) ? body.WellStrength : 0f; + if (_childOrbits.TryGetValue(orbit, out var children)) + foreach (var child in children) + strength += Strength(child); + return _strengths[orbit] = strength; + } + public float2 GetOrbitVelocity(CultRecordKey orbit) { if (Orbits.ContainsKey(orbit)) @@ -571,7 +661,7 @@ public class Zone var beltData = Planets[belt] as AsteroidBeltData; var parentPosition = GetOrbitPosition(Orbits[beltData.Orbit.Key].Data.Parent.Key); var size = ChunkRadius(new ChunkId(belt, index)); - return AsteroidBelts[belt].Pose(index, _time, parentPosition, Settings, size); + return AsteroidBelts[belt].Pose(index, _time, parentPosition, size); } // Mining Cut 3 (docs/mining-cut-refresh.md): how bright a chunk is -- Reflector's rule (cross-section × the light @@ -592,7 +682,7 @@ public class Zone var parentPosition = GetOrbitPosition(Orbits[beltData.Orbit.Key].Data.Parent.Key); var beltInstance = AsteroidBelts[belt]; for (var i = 0; i < beltData.Asteroids.Length; i++) - into[i] = beltInstance.Pose(i, _time, parentPosition, Settings, ChunkRadius(new ChunkId(belt, i))); + into[i] = beltInstance.Pose(i, _time, parentPosition, ChunkRadius(new ChunkId(belt, i))); } public SecurityLevel GetSecurityLevel(float2 pos) @@ -748,9 +838,14 @@ public class AsteroidBelt public AsteroidBeltData Data; public float Radius { get; } + // Each asteroid's orbital period, in seconds: derived once at zone build by Zone.PeriodAround (the field at the + // asteroid's own distance from the belt's parent), the only period Pose and BeltTargets divide by. + public float[] Periods { get; } + public AsteroidBelt(AsteroidBeltData data) { Data = data; + Periods = new float[data.Asteroids.Length]; Radius = data.Asteroids.Max(a => a.Distance); } @@ -758,12 +853,11 @@ public class AsteroidBelt // The only place a chunk pose is computed; no per-tick copy survives it. Cut 2: size is no longer read from // per-belt storage -- Zone owns wear now, so the caller (Zone.ChunkPose/EvaluateBelt) computes it through // Zone.ChunkRadius and passes it in. - public float4 Pose(int index, double time, float2 parentPosition, PlanetSettings settings, float size) + public float4 Pose(int index, double time, float2 parentPosition, float size) { var asteroid = Data.Asteroids[index]; var rot = (float) (time * asteroid.RotationSpeed % (PI * 2)); - var pos = OrbitData.Evaluate((float) frac(time / settings.OrbitPeriod.Evaluate(asteroid.Distance) + - asteroid.Phase)) * asteroid.Distance + parentPosition; + var pos = OrbitData.Evaluate((float) frac(time / Periods[index] + asteroid.Phase)) * asteroid.Distance + parentPosition; return float4(pos.x, pos.y, rot, size); } @@ -845,11 +939,13 @@ public class Orbit public float2 Velocity = float2.zero; public float2 Position = float2.zero; public float2 PreviousPosition = float2.zero; - public float Period; - public Orbit(PlanetSettings settings, OrbitData data) + // Seconds per turn, written only by the Zone constructor (Zone.PeriodAround); infinite when the orbit has no + // field to circle in. + public float Period { get; internal set; } + + public Orbit(OrbitData data) { Data = data; - Period = settings.OrbitPeriod.Evaluate(data.Distance); } } diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index 156deb803..56778a619 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -531,7 +531,6 @@ public class GeneratorPlanet public float Distance; public float Phase; public float Mass; - //public float Period; public float ChildDistanceMinimum; public float ChildDistanceMaximum; public bool Empty = false; @@ -586,7 +585,6 @@ public class GeneratorPlanet ChildDistanceMaximum = (i % 2 == 0 ? p0ChildDist : p1ChildDist) }; child.ChildDistanceMinimum = Settings.PlanetSafetyRadius.Evaluate(child.Mass) * 2; - //child.Period = Settings..Evaluate(child.Distance); Children.Add(child); } @@ -647,7 +645,6 @@ public class GeneratorPlanet Distance = distances[i], Phase = random.NextFloat() }; - //child.Period = Context.GlobalData.OrbitalPeriod(child.Distance); child.ChildDistanceMinimum = Settings.PlanetSafetyRadius.Evaluate(child.Mass) * 2; // Maximum child distance of child is the smallest distance to either of its neighbors child.ChildDistanceMaximum = min(i == 0 ? child.Distance - ChildDistanceMinimum : child.Distance - distances[i - 1], diff --git a/tests/Aetheria.Shared.Tests/GravitySubtractTests.cs b/tests/Aetheria.Shared.Tests/GravitySubtractTests.cs index d901b5a39..2b329ad5d 100644 --- a/tests/Aetheria.Shared.Tests/GravitySubtractTests.cs +++ b/tests/Aetheria.Shared.Tests/GravitySubtractTests.cs @@ -45,8 +45,7 @@ public sealed class GravitySubtractTests : IDisposable WellStrength = new ExponentialCurve { Exponent = .2f, Multiplier = 45.08f }, RocheLimit = new ExponentialCurve { Exponent = .25f, Multiplier = 7.32f }, BodyRadius = new ExponentialCurve { Exponent = .25f, Multiplier = 3f }, - LightRadius = new ExponentialCurve { Exponent = .25f, Multiplier = 300f }, - OrbitPeriod = new ExponentialCurve() + LightRadius = new ExponentialCurve { Exponent = .25f, Multiplier = 300f } }; private Zone Bare(float packRadius) => new Zone(_items, Planets(), new ZonePack { Radius = packRadius }, diff --git a/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs index 1467ae316..cea5467e1 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs @@ -33,11 +33,17 @@ public sealed class MiningCut1Tests : IDisposable // Authored-shape curves, not defaults: a default ExponentialLerp/ExponentialCurve evaluates to a flat // constant everywhere, which would hide a broken formula (every asteroid or every damage state would read - // alike). These give period, size and hitpoints real, distinct curvature over the fixture's distance and - // damage range. + // alike). These give size and hitpoints real, distinct curvature over the fixture's damage range. A body's + // well strength is its mass here (c = mass), with a small constant core, so the test spells every period from + // the masses it chose. + private const float SunStrength = 400f; + private const float PlanetStrength = 25f; + private static PlanetSettings Settings() => new PlanetSettings { - OrbitPeriod = new ExponentialCurve { Multiplier = 0.6f, Exponent = 1.3f, Constant = 40f }, + BodyRadius = new ExponentialCurve(), + WellStrength = new ExponentialCurve { Multiplier = 1f, Exponent = 1f }, + RocheLimit = new ExponentialCurve { Constant = 2f }, AsteroidSize = new ExponentialLerp { Minimum = 2f, Maximum = 9f, Exponent = 1.7f }, AsteroidHitpoints = new ExponentialLerp { Minimum = 10f, Maximum = 300f, Exponent = 2f }, AsteroidRespawnTime = new ExponentialLerp { Minimum = 5f, Maximum = 60f, Exponent = 1f } @@ -48,7 +54,7 @@ public sealed class MiningCut1Tests : IDisposable private const float RootPhase = 0.17f; // A belt of AsteroidCount asteroids at distinct distances, phases, size parameters and rotation speeds, - // orbiting a parent (RootDistance/RootPhase) that is itself moving -- so a test that reads the parent's + // orbiting a planet (RootDistance/RootPhase round a fixed sun) that is itself moving -- so a test that reads the parent's // motion through ChunkPose is exercising real recursion, not a fixed point. Each call opens its own // catalog, so two zones built from this fixture never share cache state. private (Zone zone, CultRecordKey beltKey, Asteroid[] asteroids, PlanetSettings settings) BuildFixture() @@ -63,8 +69,12 @@ public sealed class MiningCut1Tests : IDisposable var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }); var settings = Settings(); - var rootOrbit = new OrbitData { Distance = RootDistance, Phase = RootPhase }; + var sunOrbitKey = cache.Upsert(new OrbitData()).Key; + var sunKey = cache.Upsert(new PlanetData { Name = "Sun", Orbit = new CultRecordRef(sunOrbitKey), Mass = SunStrength }).Key; + + var rootOrbit = new OrbitData { Parent = new CultRecordRef(sunOrbitKey), Distance = RootDistance, Phase = RootPhase }; var rootKey = cache.Upsert(rootOrbit).Key; + var planetKey = cache.Upsert(new PlanetData { Name = "Planet", Orbit = new CultRecordRef(rootKey), Mass = PlanetStrength }).Key; var beltOrbit = new OrbitData { Parent = new CultRecordRef(rootKey), Distance = 900f, Phase = 0.42f }; var beltOrbitKey = cache.Upsert(beltOrbit).Key; @@ -86,8 +96,8 @@ public sealed class MiningCut1Tests : IDisposable var pack = new ZonePack { - Orbits = { new CultRecordRef(rootKey), new CultRecordRef(beltOrbitKey) }, - Planets = { new CultRecordRef(beltKey) }, + Orbits = { new CultRecordRef(sunOrbitKey), new CultRecordRef(rootKey), new CultRecordRef(beltOrbitKey) }, + Planets = { new CultRecordRef(sunKey), new CultRecordRef(planetKey), new CultRecordRef(beltKey) }, Radius = 5000f, Mass = 10000f, Time = 0 @@ -97,22 +107,22 @@ public sealed class MiningCut1Tests : IDisposable return (zone, beltKey, asteroids, settings); } - // The formula of Zone.GetOrbitPosition (unchanged by this cut), reimplemented here so the expected value - // never calls production orbit code. - private static float2 OrbitPosition(double time, float distance, float phase, PlanetSettings settings, float2 fixedPosition = default) + // Zone.GetOrbitPosition's placement round a body of well strength `strength`, with the period spelled here from + // the field law outside the core (a = c/d, so T = 2 pi d / sqrt(c)), so the expected value never calls + // production orbit code. + private static float2 OrbitPosition(double time, float distance, float phase, float strength, float2 fixedPosition = default) { - var period = settings.OrbitPeriod.Evaluate(distance); - if (period <= .01f) return fixedPosition; + var period = 2 * PI * distance / sqrt(strength); var wrapped = (float) frac(time / period); return OrbitData.Evaluate(frac(wrapped + phase)) * distance + fixedPosition; } - // The formula of AsteroidBelt.Pose (Zone.cs:269-271 before this cut), reimplemented independently. - private static float4 ExpectedPose(double time, Asteroid asteroid, float2 parentPosition, PlanetSettings settings, float size) + // The pose of an asteroid circling a planet of well strength `strength`, spelled independently of AsteroidBelt.Pose. + private static float4 ExpectedPose(double time, Asteroid asteroid, float2 parentPosition, float strength, float size) { var rot = (float) (time * asteroid.RotationSpeed % (PI * 2)); - var pos = OrbitData.Evaluate((float) frac(time / settings.OrbitPeriod.Evaluate(asteroid.Distance) + asteroid.Phase)) - * asteroid.Distance + parentPosition; + var period = 2 * PI * asteroid.Distance / sqrt(strength); + var pos = OrbitData.Evaluate((float) frac(time / period + asteroid.Phase)) * asteroid.Distance + parentPosition; return float4(pos.x, pos.y, rot, size); } @@ -136,11 +146,11 @@ public sealed class MiningCut1Tests : IDisposable time += dt; } - var parentPosition = OrbitPosition(time, RootDistance, RootPhase, settings); + var parentPosition = OrbitPosition(time, RootDistance, RootPhase, SunStrength); for (var i = 0; i < asteroids.Length; i++) { - var expected = ExpectedPose(time, asteroids[i], parentPosition, settings, settings.AsteroidSize.Evaluate(asteroids[i].Size)); + var expected = ExpectedPose(time, asteroids[i], parentPosition, PlanetStrength, settings.AsteroidSize.Evaluate(asteroids[i].Size)); var actual = zone.ChunkPose(beltKey, i); AssertClose(expected.x, actual.x, 0.01f, $"asteroid {i} x"); AssertClose(expected.y, actual.y, 0.01f, $"asteroid {i} y"); @@ -157,13 +167,13 @@ public sealed class MiningCut1Tests : IDisposable { var (zone, beltKey, asteroids, settings) = BuildFixture(); zone.Update(40f); - var parentPosition = OrbitPosition(40.0, RootDistance, RootPhase, settings); + var parentPosition = OrbitPosition(40.0, RootDistance, RootPhase, SunStrength); var wrapped = 0; for (var i = 0; i < asteroids.Length; i++) { if (40.0 * asteroids[i].RotationSpeed > PI * 2) wrapped++; - var expected = ExpectedPose(40.0, asteroids[i], parentPosition, settings, settings.AsteroidSize.Evaluate(asteroids[i].Size)); + var expected = ExpectedPose(40.0, asteroids[i], parentPosition, PlanetStrength, settings.AsteroidSize.Evaluate(asteroids[i].Size)); var actual = zone.ChunkPose(beltKey, i); AssertClose(expected.z, actual.z, 0.001f, $"asteroid {i} rotation"); Assert.InRange(actual.z, 0f, PI * 2); diff --git a/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs index 5ac5ecac8..c05fc1f32 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs @@ -40,7 +40,8 @@ public sealed class MiningCut2Tests : IDisposable // curve" is exercising real curvature, not a constant that any wrong formula would also satisfy. private static PlanetSettings Settings() => new PlanetSettings { - OrbitPeriod = new ExponentialCurve { Multiplier = 0.6f, Exponent = 1.3f, Constant = 40f }, + WellStrength = new ExponentialCurve { Constant = 25f }, + RocheLimit = new ExponentialCurve { Constant = 1f }, AsteroidSize = new ExponentialLerp { Minimum = 2f, Maximum = 9f, Exponent = 1.7f }, AsteroidHitpoints = new ExponentialLerp { Minimum = 20f, Maximum = 220f, Exponent = 1.6f }, AsteroidRespawnTime = new ExponentialLerp { Minimum = 8f, Maximum = 96f, Exponent = 1.2f }, diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index a9146613a..fdabaf6bd 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -47,11 +47,12 @@ public sealed partial class MiningCut3Tests : IDisposable FiringArc = 90f }; - // Flat terrain (no zone depth, no gravity wells), one sun whose light reaches 1000 units, and the chunk + // Every sun carries a well (the belts circle in it, c = 9), one sun whose light reaches 1000 units, and the chunk // curves MiningCut2Tests uses, so sizes and wear vary over the fixture's range. private static PlanetSettings PlanetSettings(float lightRadius = 1000f) => new PlanetSettings { - OrbitPeriod = new ExponentialCurve { Multiplier = 0.6f, Exponent = 1.3f, Constant = 40f }, + WellStrength = new ExponentialCurve { Constant = 9f }, + RocheLimit = new ExponentialCurve { Constant = 1f }, AsteroidSize = new ExponentialLerp { Minimum = 2f, Maximum = 9f, Exponent = 1.7f }, AsteroidHitpoints = new ExponentialLerp { Minimum = 20f, Maximum = 220f, Exponent = 1.6f }, AsteroidRespawnTime = new ExponentialLerp { Minimum = 8f, Maximum = 96f, Exponent = 1.2f }, diff --git a/tests/Aetheria.Shared.Tests/MiningIndexTests.cs b/tests/Aetheria.Shared.Tests/MiningIndexTests.cs index e398700df..e47f9a4a1 100644 --- a/tests/Aetheria.Shared.Tests/MiningIndexTests.cs +++ b/tests/Aetheria.Shared.Tests/MiningIndexTests.cs @@ -458,7 +458,8 @@ public sealed partial class MiningCut3Tests var spread = Enumerable.Range(0, 700).Select(_ => Rock(50f + (float) rng.NextDouble() * 5950f, (float) rng.NextDouble(), (float) rng.NextDouble())).ToArray(); var s = BuildSceneAt(centre, 20000f, 777.25, null, new[] { kind }, Belt(kind, spread), Belt(kind, Annulus(rng, 3000, 999f, 1001f))); var game = PlanetSettings(20000f); - game.OrbitPeriod = new ExponentialCurve { Multiplier = 1f, Exponent = 1f, Constant = 0f }; + // The game's periods: T = 2 pi d / sqrt(c) with c = 4 pi^2 is T = d. + game.WellStrength = new ExponentialCurve { Constant = 4f * PI * PI }; var searches = 0; foreach (var zone in new[] { s.Zone, new Zone(s.Items, game, s.Pack, new GalaxyZone { Name = "Game periods", Owner = null }, null) }) foreach (var step in new[] { 0f, 13f, 1e4f, 3.3e7f }) diff --git a/tests/Aetheria.Shared.Tests/OrbitsFromFieldTests.cs b/tests/Aetheria.Shared.Tests/OrbitsFromFieldTests.cs new file mode 100644 index 000000000..10de2c62e --- /dev/null +++ b/tests/Aetheria.Shared.Tests/OrbitsFromFieldTests.cs @@ -0,0 +1,362 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// Aetheria gravity, orbits-from-field: every rail's period is derived from the field it orbits in. Round a body it is +// that body's own well; round a bodyless node it is the ring law (the strength inside the rail, plus half of what shares +// it). The zones here are hand-built packs read by the production constructor, with the shipped well curves +// (c = 45.08 m^.2, R = 7.32 m^.25). Each expected period is spelled here from the field law outside the core, T = 2 pi d / sqrt(c), +// with c and R read from the zone's own bodies and every distance outside the core. +public sealed class OrbitsFromFieldTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-orbitsfield-" + Guid.NewGuid().ToString("N")); + private readonly List _caches = new List(); + + public OrbitsFromFieldTests() => 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 PlanetSettings Curves() => new PlanetSettings + { + WellStrength = new ExponentialCurve { Exponent = .2f, Multiplier = 45.08f }, + RocheLimit = new ExponentialCurve { Exponent = .25f, Multiplier = 7.32f }, + BodyRadius = new ExponentialCurve { Exponent = .25f, Multiplier = 3f }, + LightRadius = new ExponentialCurve() + }; + + private sealed class World + { + public CultCache Cache; + public ItemManager Items; + public ZonePack Pack = new ZonePack { Radius = 20000f, Mass = 10000f }; + + public CultRecordKey Orbit(CultRecordKey parent, float distance, float phase = 0f) + { + var data = new OrbitData { Distance = distance, Phase = phase }; + if (parent.IsSet()) data.Parent = new CultRecordRef(parent); + var key = Cache.Upsert(data).Key; + Pack.Orbits.Add(new CultRecordRef(key)); + return key; + } + + public CultRecordKey Body(CultRecordKey orbit, float mass) + { + var key = Cache.Upsert(new PlanetData { Name = "Body", Orbit = new CultRecordRef(orbit), Mass = mass }).Key; + Pack.Planets.Add(new CultRecordRef(key)); + return key; + } + + public Zone Build() => new Zone(Items, Curves(), Pack, new GalaxyZone { Name = "OrbitsFromField", Owner = null }, null); + } + + private World NewWorld() + { + var dir = Path.Combine(_root, $"world{_caches.Count}"); + Directory.CreateDirectory(dir); + var cache = AetheriaStores.Open(Path.Combine(dir, "Aetheria.cc"), Path.Combine(dir, "run.cc"), catalogWritable: true); + _caches.Add(cache); + return new World { Cache = cache, Items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }) }; + } + + private static float C(Zone zone, CultRecordKey body) => zone.PlanetInstances[body].WellStrength; + private static float Core(Zone zone, CultRecordKey body) => zone.PlanetInstances[body].RocheLimit; + private static float Outside(float distance, float c) => 2 * PI * distance / sqrt(c); + + private static void AssertPeriod(float expected, float actual, string what) => + Assert.True(Math.Abs(actual / expected - 1f) <= 1e-4f, $"{what}: expected {expected}, got {actual}"); + + [Fact] + public void AMoonsPeriodIsItsPlanetsFieldAlone() + { + var w = NewWorld(); + var sunOrbit = w.Orbit(default, 0f); + var sun = w.Body(sunOrbit, 5000f); + var planetOrbit = w.Orbit(sunOrbit, 3000f); + var planet = w.Body(planetOrbit, 100f); + var moonOrbit = w.Orbit(planetOrbit, 150f); + w.Body(moonOrbit, 1f); + var zone = w.Build(); + + Assert.True(Core(zone, planet) < 150f && Core(zone, sun) < 3000f, "fixture: both rails run outside the core"); + AssertPeriod(Outside(150f, C(zone, planet)), zone.Orbits[moonOrbit].Period, "the moon, in its planet's field"); + AssertPeriod(Outside(3000f, C(zone, sun)), zone.Orbits[planetOrbit].Period, "the planet, in its sun's field"); + } + + [Fact] + public void AChildInsideItsParentsCoreOrbitsAsASolidBody() + { + var w = NewWorld(); + var sunOrbit = w.Orbit(default, 0f); + w.Body(sunOrbit, 5000f); + var planetOrbit = w.Orbit(sunOrbit, 3000f); + var planet = w.Body(planetOrbit, 100f); + var coreRadius = Curves().RocheLimit.Evaluate(100f); + var near = w.Orbit(planetOrbit, coreRadius * .3f); + var far = w.Orbit(planetOrbit, coreRadius * .7f); + var zone = w.Build(); + + var solid = 2 * PI * coreRadius / sqrt(C(zone, planet)); // a = c d / R^2, so T = 2 pi R / sqrt(c) at every d inside + AssertPeriod(solid, zone.Orbits[near].Period, "the inner child"); + AssertPeriod(solid, zone.Orbits[far].Period, "the outer child"); + } + + [Fact] + public void AnInnerSiblingDoesNotSpeedABodysRail() + { + var w = NewWorld(); + var sunOrbit = w.Orbit(default, 0f); + var sun = w.Body(sunOrbit, 5000f); + var innerOrbit = w.Orbit(sunOrbit, 800f); + w.Body(innerOrbit, 20000f); + var outerOrbit = w.Orbit(sunOrbit, 3000f); + w.Body(outerOrbit, 100f); + var zone = w.Build(); + + AssertPeriod(Outside(3000f, C(zone, sun)), zone.Orbits[outerOrbit].Period, "the outer planet, with a heavy planet inside it"); + } + + [Fact] + public void ABinaryOrbitsUnderHalfItsPartnersPull() + { + var w = NewWorld(); + var node = w.Orbit(default, 0f); + var a = w.Orbit(node, 400f, 0f); + var heavy = w.Orbit(node, 400f, .5f); + var bodyA = w.Body(a, 100f); + var bodyHeavy = w.Body(heavy, 2000f); + var zone = w.Build(); + + Assert.True(C(zone, bodyHeavy) > 1.5f * C(zone, bodyA), "fixture: unequal members"); + AssertPeriod(Outside(400f, C(zone, bodyHeavy) / 2), zone.Orbits[a].Period, "the light member"); + AssertPeriod(Outside(400f, C(zone, bodyA) / 2), zone.Orbits[heavy].Period, "the heavy member"); + } + + [Fact] + public void AnOuterBodyFeelsTheWholePairAndItsMoons() + { + var w = NewWorld(); + var node = w.Orbit(default, 0f); + var m1 = w.Orbit(node, 400f, 0f); + var m2 = w.Orbit(node, 400f, .5f); + var moon = w.Orbit(m1, 40f); + var body1 = w.Body(m1, 100f); + var body2 = w.Body(m2, 300f); + var moonBody = w.Body(moon, 5f); + var outer = w.Orbit(node, 3000f); + w.Body(outer, 50f); + var zone = w.Build(); + + var whole = C(zone, body1) + C(zone, moonBody) + C(zone, body2); + AssertPeriod(Outside(3000f, whole), zone.Orbits[outer].Period, "the outer body, round the pair and the first one's moon"); + } + + [Fact] + public void ACompanionKeepsItsBodysPeriod() + { + // Under a body: a bodyless orbit at the planet's distance round the same sun. + var w = NewWorld(); + var sunOrbit = w.Orbit(default, 0f); + w.Body(sunOrbit, 5000f); + var planetOrbit = w.Orbit(sunOrbit, 2000f, 0f); + w.Body(planetOrbit, 100f); + var companion = w.Orbit(sunOrbit, 2000f, 1f / 6f); + var zone = w.Build(); + Assert.True(float.IsFinite(zone.Orbits[planetOrbit].Period)); + Assert.Equal(zone.Orbits[planetOrbit].Period, zone.Orbits[companion].Period); + + // Under a bodyless node: the same, round a node whose only field is an inner member's. + w = NewWorld(); + var node = w.Orbit(default, 0f); + var innerOrbit = w.Orbit(node, 100f); + var inner = w.Body(innerOrbit, 3000f); + var planet2 = w.Orbit(node, 500f, 0f); + w.Body(planet2, 100f); + var companion2 = w.Orbit(node, 500f, 1f / 6f); + zone = w.Build(); + AssertPeriod(Outside(500f, C(zone, inner)), zone.Orbits[planet2].Period, "the planet"); + Assert.Equal(zone.Orbits[planet2].Period, zone.Orbits[companion2].Period); + } + + // A station on a binary's ring, with nothing inside, feels what the pair's members feel (the mean of what each does), + // and so does one a hair off the ring's distance but within its window. + [Theory] + [InlineData(0f)] + [InlineData(.05f)] + [InlineData(-.05f)] + public void ACompanionOnABinarysRingTakesTheMembersPeriod(float offset) + { + var w = NewWorld(); + var node = w.Orbit(default, 0f); + var light = w.Orbit(node, 400f, 0f); + var heavy = w.Orbit(node, 400f, .5f); + var lightBody = w.Body(light, 100f); + var heavyBody = w.Body(heavy, 2000f); + var companion = w.Orbit(node, 400f + offset, .25f); + var zone = w.Build(); + + Assert.True(C(zone, heavyBody) > 1.5f * C(zone, lightBody), "fixture: unequal members"); + AssertPeriod(Outside(400f + offset, (C(zone, lightBody) + C(zone, heavyBody)) / 4), zone.Orbits[companion].Period, + "the companion, between its two members"); + + // An equal pair: the companion has exactly the members' period. + w = NewWorld(); + node = w.Orbit(default, 0f); + var a = w.Orbit(node, 400f, 0f); + var b = w.Orbit(node, 400f, .5f); + w.Body(a, 300f); + w.Body(b, 300f); + companion = w.Orbit(node, 400f, .25f); + zone = w.Build(); + Assert.True(float.IsFinite(zone.Orbits[a].Period)); + Assert.Equal(zone.Orbits[a].Period, zone.Orbits[companion].Period); + Assert.Equal(zone.Orbits[b].Period, zone.Orbits[companion].Period); + } + + // The ring is the 0.1 u window about a rail's distance: a member just inside it is a ring partner (pulls half), one just + // outside is inside or beyond the rail (pulls whole, or not at all). + [Theory] + [InlineData(-.09f, true)] + [InlineData(.09f, true)] + [InlineData(-.11f, false)] + public void TheRingWindowIsATenthOfAUnit(float partnerOffset, bool onRing) + { + var w = NewWorld(); + var node = w.Orbit(default, 0f); + var rail = w.Orbit(node, 400f, 0f); + var partner = w.Orbit(node, 400f + partnerOffset, .5f); + w.Body(rail, 100f); + var partnerBody = w.Body(partner, 2000f); + var zone = w.Build(); + + var c = C(zone, partnerBody); + AssertPeriod(Outside(400f, onRing ? c / 2 : c), zone.Orbits[rail].Period, "the rail, with a partner at " + partnerOffset); + } + + [Fact] + public void AMemberJustBeyondTheWindowIsOutsideTheRail() + { + var w = NewWorld(); + var node = w.Orbit(default, 0f); + var rail = w.Orbit(node, 400f, 0f); + var beyond = w.Orbit(node, 400.11f, .5f); + w.Body(rail, 100f); + w.Body(beyond, 2000f); + var zone = w.Build(); + + Assert.True(float.IsPositiveInfinity(zone.Orbits[rail].Period), "a body beyond the rail does not pull it"); + } + + // A bodyless node's children read only that node's ring: the well of the body the node itself orbits does not enter. + [Fact] + public void ABodylessNodesChildrenNeverReadItsParentsWell() + { + var w = NewWorld(); + var sunOrbit = w.Orbit(default, 0f); + w.Body(sunOrbit, 5000f); + var node = w.Orbit(sunOrbit, 3000f); + var alone = w.Orbit(node, 200f, 0f); + w.Body(alone, 100f); + var zone = w.Build(); + Assert.True(float.IsPositiveInfinity(zone.Orbits[alone].Period), "a lone body on a bodyless node has no ring partner and no field"); + + w = NewWorld(); + sunOrbit = w.Orbit(default, 0f); + w.Body(sunOrbit, 5000f); + node = w.Orbit(sunOrbit, 3000f); + var a = w.Orbit(node, 200f, 0f); + var b = w.Orbit(node, 200f, .5f); + var bodyA = w.Body(a, 100f); + var bodyB = w.Body(b, 300f); + zone = w.Build(); + AssertPeriod(Outside(200f, C(zone, bodyB) / 2), zone.Orbits[a].Period, "the first member, under its partner alone"); + AssertPeriod(Outside(200f, C(zone, bodyA) / 2), zone.Orbits[b].Period, "the second member, under its partner alone"); + } + + [Fact] + public void AnOrbitWithNoFieldStandsStill() + { + var w = NewWorld(); + var node = w.Orbit(default, 0f); + var lonely = w.Orbit(node, 300f, .25f); + var fixedOne = w.Orbit(default, 50f, .25f); + var zone = w.Build(); + + Assert.True(float.IsPositiveInfinity(zone.Orbits[lonely].Period), "no field to circle in"); + Assert.True(float.IsPositiveInfinity(zone.Orbits[fixedOne].Period), "a parentless orbit has nothing to circle"); + var before = (zone.Orbits[lonely].Position, zone.Orbits[fixedOne].Position); + foreach (var dt in new[] { .5f, 3f, 70f, 5000f }) + zone.Update(dt); + Assert.Equal(before, (zone.Orbits[lonely].Position, zone.Orbits[fixedOne].Position)); + Assert.True(length(zone.Orbits[lonely].Velocity) == 0f && length(zone.Orbits[fixedOne].Velocity) == 0f); + } + + // Generated zones, by the production generator: every orbit with a parent has a finite period and moves against its + // parent across an Update, and the seeds held a bodyless node with two members (a root rosette) and a pair on one ring. + [Fact] + public void EveryGeneratedOrbitMoves() + { + var rosettes = 0; + var binaries = 0; + for (uint seed = 1; seed <= 12; seed++) + { + var dir = Path.Combine(_root, $"galaxy{seed}"); + Directory.CreateDirectory(dir); + var catalog = Path.Combine(dir, "Aetheria.cc"); + File.Copy(TestCatalog.Repo, catalog); + var cache = new CultCache(TestCatalog.Registry()); + _caches.Add(cache); + cache.AddBackingStore(new GameCult.Caching.MessagePack.SingleFileMessagePackBackingStore(catalog), AetheriaStores.CatalogTypes); + cache.AddBackingStore(new GameCult.Caching.MessagePack.SingleFileMessagePackBackingStore(Path.Combine(dir, "run.cc")), AetheriaStores.RunTypes); + var authored = AuthoredSettings.Load(RunStartTests.FindRepoRoot()); + var tutorial = authored.Read("TutorialGenerationSettings"); + var galaxy = new Galaxy(tutorial, authored.Read("TutorialBackgroundSettings"), + authored.Read("NameGeneratorSettings"), cache, new PlayerSettings(), + Directory.CreateDirectory(Path.Combine(dir, "Narrative")), _ => { }, null, seed); + var items = new ItemManager(cache, new ProvenanceLedger(), authored.Read("GameplaySettings"), _ => { }); + var pack = ZoneGenerator.GenerateZone(items, authored.Read("ZoneSettings"), galaxy, galaxy.Entrance, + galaxy.IsPrelude, false); + var zone = new Zone(items, authored.Read("PlanetSettings"), pack, galaxy.Entrance, galaxy); + + var hosting = zone.Planets.Values.Select(body => body.Orbit.Key).ToHashSet(); + foreach (var key in zone.Orbits.Keys.Where(k => !hosting.Contains(k))) + { + var members = zone.Orbits.Where(o => o.Value.Data.Parent.IsSet() && o.Value.Data.Parent.Key.Equals(key) && hosting.Contains(o.Key)).ToList(); + if (members.Count >= 2) rosettes++; + if (members.Any(a => members.Any(b => !a.Key.Equals(b.Key) && Math.Abs(a.Value.Data.Distance - b.Value.Data.Distance) < .1f))) binaries++; + } + + zone.Update(.02f); + var before = zone.Orbits.ToDictionary(o => o.Key, o => o.Value.Position - ParentAt(zone, o.Value)); + zone.Update(.5f); + foreach (var orbit in zone.Orbits.Where(o => o.Value.Data.Parent.IsSet())) + { + Assert.True(float.IsFinite(orbit.Value.Period), $"seed {seed}: an orbit with a parent has no finite period"); + Assert.True(lengthsq(orbit.Value.Position - ParentAt(zone, orbit.Value) - before[orbit.Key]) > 0f, + $"seed {seed}: an orbit did not move against its parent (period {orbit.Value.Period}, distance {orbit.Value.Data.Distance})"); + } + } + Assert.True(rosettes > 0, "the seeds hold a bodyless node with two bodied members"); + Assert.True(binaries > 0, "the seeds hold two bodied orbits on one ring"); + } + + private static float2 ParentAt(Zone zone, Orbit orbit) => + orbit.Data.Parent.IsSet() ? zone.Orbits[orbit.Data.Parent.Key].Position : orbit.Data.FixedPosition; +} diff --git a/tests/Aetheria.Shared.Tests/PotentialOwnerTests.cs b/tests/Aetheria.Shared.Tests/PotentialOwnerTests.cs index bcb5310b5..3cd03747e 100644 --- a/tests/Aetheria.Shared.Tests/PotentialOwnerTests.cs +++ b/tests/Aetheria.Shared.Tests/PotentialOwnerTests.cs @@ -46,7 +46,7 @@ public sealed class PotentialOwnerTests : IDisposable WellStrength = new ExponentialCurve { Exponent = .2f, Multiplier = 45.08f }, RocheLimit = new ExponentialCurve { Exponent = .25f, Multiplier = 7.32f }, BodyRadius = new ExponentialCurve { Exponent = .25f, Multiplier = 2f }, - LightRadius = new ExponentialCurve(), OrbitPeriod = new ExponentialCurve(), + LightRadius = new ExponentialCurve(), HeightExaggeration = exaggeration }; @@ -83,7 +83,7 @@ public sealed class PotentialOwnerTests : IDisposable var planets = new PlanetSettings { BodyRadius = new ExponentialCurve(), - LightRadius = new ExponentialCurve(), OrbitPeriod = new ExponentialCurve() + LightRadius = new ExponentialCurve() }; var zone = ZoneOf(planets, (100f, float2.zero), (5000f, new float2(200f, 0f))); foreach (var p in new[] { float2.zero, float2(200f, 0f), float2(37f, -911f), float2(5000f, 5000f) }) @@ -138,7 +138,7 @@ public sealed class PotentialOwnerTests : IDisposable WellStrength = new ExponentialCurve { Constant = 100f }, RocheLimit = new ExponentialCurve { Constant = roche }, BodyRadius = new ExponentialCurve { Constant = 10f }, - LightRadius = new ExponentialCurve(), OrbitPeriod = new ExponentialCurve() + LightRadius = new ExponentialCurve() }; Assert.Throws(() => ZoneOf(Constants(0f), (100f, float2.zero))); Assert.Throws(() => ZoneOf(Constants(-5f), (100f, float2.zero))); diff --git a/tests/Aetheria.Shared.Tests/WellFixture.cs b/tests/Aetheria.Shared.Tests/WellFixture.cs index 54237862d..08bed1ad4 100644 --- a/tests/Aetheria.Shared.Tests/WellFixture.cs +++ b/tests/Aetheria.Shared.Tests/WellFixture.cs @@ -10,7 +10,6 @@ internal static class WellFixture public static void Dig(CultCache cache, ZonePack pack, PlanetSettings settings, float strength, float coreRadius, float2 at = default) { settings.BodyRadius = new ExponentialCurve(); - settings.OrbitPeriod = new ExponentialCurve(); settings.WellStrength = new ExponentialCurve { Constant = strength }; settings.RocheLimit = new ExponentialCurve { Constant = coreRadius }; var orbit = cache.Upsert(new OrbitData { FixedPosition = at }).Key; diff --git a/tests/Aetheria.Shared.Tests/ZonePlacedAtBuildTests.cs b/tests/Aetheria.Shared.Tests/ZonePlacedAtBuildTests.cs index e9673c3f5..f02992ff5 100644 --- a/tests/Aetheria.Shared.Tests/ZonePlacedAtBuildTests.cs +++ b/tests/Aetheria.Shared.Tests/ZonePlacedAtBuildTests.cs @@ -16,7 +16,7 @@ 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 const float SunStrength = 100f; // the root's body: the planet circles in its field private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-zoneplaced-" + Guid.NewGuid().ToString("N")); private readonly List _caches = new List(); @@ -38,13 +38,13 @@ public sealed class ZonePlacedAtBuildTests : IDisposable private static ExponentialCurve Flat(float v) => new ExponentialCurve { Constant = v }; - // One planet with a well (c 100, Roche limit and core 20 u), a fixed root orbit at RootAt and the - // planet orbiting it at Distance from `phase`. Every orbit period is Period seconds. + // A sun on the fixed root orbit at RootAt, a planet orbiting it at Distance from `phase`, and a light moon body on + // the moon orbit so the moon's moon has a field to circle in. A body's well is 100 per unit of mass; every core + // (Roche limit) is 20 u. private static PlanetSettings Settings() => new PlanetSettings { - OrbitPeriod = Flat(Period), BodyRadius = Flat(20f), - WellStrength = Flat(100f), + WellStrength = new ExponentialCurve { Multiplier = 100f, Exponent = 1f }, RocheLimit = Flat(20f), LightRadius = Flat(0f) }; @@ -86,10 +86,12 @@ public sealed class ZonePlacedAtBuildTests : IDisposable { Parent = new CultRecordRef(moonOrbit), Distance = 40f }).Key; + var sun = cache.Upsert(new PlanetData { Orbit = new CultRecordRef(rootOrbit), Mass = 1f }).Key; var planet = cache.Upsert(new PlanetData { Orbit = new CultRecordRef(planetOrbit), Mass = 1f }).Key; + var moon = cache.Upsert(new PlanetData { Orbit = new CultRecordRef(moonOrbit), Mass = .01f }).Key; var pack = new ZonePack { - Planets = { new CultRecordRef(planet) }, + Planets = { new CultRecordRef(sun), new CultRecordRef(planet), new CultRecordRef(moon) }, Radius = 5000f, Mass = 10000f, Time = time @@ -154,7 +156,7 @@ public sealed class ZonePlacedAtBuildTests : IDisposable public void TheFirstTickVelocityIsTheOrbitsSpeed(bool moonFirst) { var s = Build(.35f, 0.0, moonFirst); - var expected = 2f * PI * Distance / Period; + var expected = sqrt(SunStrength); // outside the core T = 2 pi d / sqrt(c): the speed is sqrt(c), whatever d foreach (var dt in new[] { 1f / 60f, 1f / 30f, 1f / 60f }) { s.Zone.Update(dt);