Merge gravity-orbits-from-field

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-15 19:52:57 +02:00
commit 09ec04a8d5
14 changed files with 528 additions and 61 deletions

BIN
Assets/Resources/Settings.asset (Stored with Git LFS)

Binary file not shown.

View file

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

View file

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

View file

@ -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<CultRecordKey>();
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<CultRecordKey, Planet> _wellOn = new Dictionary<CultRecordKey, Planet>();
private readonly Dictionary<CultRecordKey, List<CultRecordKey>> _childOrbits = new Dictionary<CultRecordKey, List<CultRecordKey>>();
private readonly Dictionary<CultRecordKey, float> _strengths = new Dictionary<CultRecordKey, float>();
// 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);
}
}

View file

@ -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],

View file

@ -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 },

View file

@ -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<OrbitData>(sunOrbitKey), Mass = SunStrength }).Key;
var rootOrbit = new OrbitData { Parent = new CultRecordRef<OrbitData>(sunOrbitKey), Distance = RootDistance, Phase = RootPhase };
var rootKey = cache.Upsert(rootOrbit).Key;
var planetKey = cache.Upsert(new PlanetData { Name = "Planet", Orbit = new CultRecordRef<OrbitData>(rootKey), Mass = PlanetStrength }).Key;
var beltOrbit = new OrbitData { Parent = new CultRecordRef<OrbitData>(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<OrbitData>(rootKey), new CultRecordRef<OrbitData>(beltOrbitKey) },
Planets = { new CultRecordRef<BodyData>(beltKey) },
Orbits = { new CultRecordRef<OrbitData>(sunOrbitKey), new CultRecordRef<OrbitData>(rootKey), new CultRecordRef<OrbitData>(beltOrbitKey) },
Planets = { new CultRecordRef<BodyData>(sunKey), new CultRecordRef<BodyData>(planetKey), new CultRecordRef<BodyData>(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);

View file

@ -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 },

View file

@ -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 },

View file

@ -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 })

View file

@ -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<CultCache> _caches = new List<CultCache>();
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<OrbitData>(parent);
var key = Cache.Upsert(data).Key;
Pack.Orbits.Add(new CultRecordRef<OrbitData>(key));
return key;
}
public CultRecordKey Body(CultRecordKey orbit, float mass)
{
var key = Cache.Upsert(new PlanetData { Name = "Body", Orbit = new CultRecordRef<OrbitData>(orbit), Mass = mass }).Key;
Pack.Planets.Add(new CultRecordRef<BodyData>(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>("TutorialGenerationSettings");
var galaxy = new Galaxy(tutorial, authored.Read<SectorBackgroundSettings>("TutorialBackgroundSettings"),
authored.Read<NameGeneratorSettings>("NameGeneratorSettings"), cache, new PlayerSettings(),
Directory.CreateDirectory(Path.Combine(dir, "Narrative")), _ => { }, null, seed);
var items = new ItemManager(cache, new ProvenanceLedger(), authored.Read<GameplaySettings>("GameplaySettings"), _ => { });
var pack = ZoneGenerator.GenerateZone(items, authored.Read<ZoneGenerationSettings>("ZoneSettings"), galaxy, galaxy.Entrance,
galaxy.IsPrelude, false);
var zone = new Zone(items, authored.Read<PlanetSettings>("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;
}

View file

@ -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<InvalidOperationException>(() => ZoneOf(Constants(0f), (100f, float2.zero)));
Assert.Throws<InvalidOperationException>(() => ZoneOf(Constants(-5f), (100f, float2.zero)));

View file

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

View file

@ -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<CultCache> _caches = new List<CultCache>();
@ -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<OrbitData>(moonOrbit), Distance = 40f
}).Key;
var sun = cache.Upsert(new PlanetData { Orbit = new CultRecordRef<OrbitData>(rootOrbit), Mass = 1f }).Key;
var planet = cache.Upsert(new PlanetData { Orbit = new CultRecordRef<OrbitData>(planetOrbit), Mass = 1f }).Key;
var moon = cache.Upsert(new PlanetData { Orbit = new CultRecordRef<OrbitData>(moonOrbit), Mass = .01f }).Key;
var pack = new ZonePack
{
Planets = { new CultRecordRef<BodyData>(planet) },
Planets = { new CultRecordRef<BodyData>(sun), new CultRecordRef<BodyData>(planet), new CultRecordRef<BodyData>(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);