Gravity: one potential per zone owns acceleration and height
GravityField is the law of one well (logarithmic tail, softened core). Zone.GetForce and GetHeight sum it over the bodies; GetNormal derives from the force for ship tilt; Flow and Ship read GetForce. Well strength, core radius and Roche limit are PlanetSettings curves of mass, height exaggeration K one global. Deletes GravityAcceleration, GravityReferenceRate, GravityStrength, the PowerPulse well, GetNormal's finite differences and BodyData.GravityDepthExponent. Settings.asset carries the calibrated curves. Tests: PotentialOwnerTests, the moon's moon placement, retired keys over every BodyData type, a body-derived giant sweep. Not yet built or run (verify job follows); this commit may not build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ff63362f45
commit
021c4cbcbf
16 changed files with 433 additions and 171 deletions
BIN
Assets/Resources/Settings.asset
(Stored with Git LFS)
BIN
Assets/Resources/Settings.asset
(Stored with Git LFS)
Binary file not shown.
27
Assets/Scripts/ServerShared/GravityField.cs
Normal file
27
Assets/Scripts/ServerShared/GravityField.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
using CultMath;
|
||||
using static CultMath.math;
|
||||
using float2 = CultMath.float2;
|
||||
|
||||
/// <summary>
|
||||
/// The law of one gravity well: a 2D point mass with a softened core. Outside the core radius the potential is
|
||||
/// logarithmic and the pull is c / r; inside it the pull falls linearly to zero at the centre and meets the tail in
|
||||
/// value and slope at the core radius. c is the well's strength (u^2/s^2), R its core radius (u). No threshold
|
||||
/// anywhere: a well pulls at every distance. Zone.GetForce and Zone.GetHeight sum this over a zone's bodies.
|
||||
/// </summary>
|
||||
public static class GravityField
|
||||
{
|
||||
// The potential at distance r from the body, defined up to a constant (callers subtract their reference).
|
||||
public static float Potential(float c, float coreRadius, float r) =>
|
||||
r < coreRadius
|
||||
? c * r * r / (2 * coreRadius * coreRadius)
|
||||
: c * (log(r / coreRadius) + .5f);
|
||||
|
||||
// The acceleration at `offsetFromBody`, toward the body: zero at the body itself.
|
||||
public static float2 Acceleration(float c, float coreRadius, float2 offsetFromBody)
|
||||
{
|
||||
var r = length(offsetFromBody);
|
||||
if (r == 0f) return float2.zero;
|
||||
var pull = r < coreRadius ? c * r / (coreRadius * coreRadius) : c / r;
|
||||
return -offsetFromBody / r * pull;
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/ServerShared/GravityField.cs.meta
Normal file
11
Assets/Scripts/ServerShared/GravityField.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 5b0c7d3e9a1f4e2c8d6a7f1b3c9e0a42
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -17,11 +17,21 @@ public class PlanetSettings
|
|||
public ExponentialCurve GravityRadius;
|
||||
public ExponentialCurve LightRadius;
|
||||
public ExponentialCurve BodyRadius;
|
||||
|
||||
// The closest a child may orbit its parent, in u, by the parent's mass. Authored like BodyRadius, never per body.
|
||||
public ExponentialCurve RocheLimit = new ExponentialCurve();
|
||||
|
||||
// A body's well, by its mass (GravityField): its strength c in u^2/s^2 (the pull is c / r beyond the core) and its
|
||||
// softened core radius in u, which must stay inside the RocheLimit curve.
|
||||
public ExponentialCurve WellStrength = new ExponentialCurve();
|
||||
public ExponentialCurve CoreRadius = new ExponentialCurve();
|
||||
|
||||
// The one global exaggeration of gravity height: height = potential / K, in u/s^2 per unit of height.
|
||||
public float HeightExaggeration = 1f;
|
||||
public float AsteroidVerticalOffset = -5f;
|
||||
public ExponentialLerp AsteroidSize;
|
||||
public ExponentialLerp AsteroidHitpoints;
|
||||
public ExponentialLerp AsteroidRespawnTime;
|
||||
public float GravityStrength;
|
||||
public float SecureAreaRadiusMultiplier = .45f;
|
||||
|
||||
public ExponentialCurve OrbitPeriod;
|
||||
|
|
|
|||
|
|
@ -130,9 +130,8 @@ public class Ship : Entity
|
|||
|
||||
Position.xz += Velocity * delta;
|
||||
|
||||
Velocity += Zone.GetForce(Position.xz) * delta;
|
||||
var normal = Zone.GetNormal(Position.xz);
|
||||
var force = new float2(normal.x, normal.z);
|
||||
Velocity += Zone.GravityAcceleration(force, Zone.Settings.GravityStrength) * delta;
|
||||
var shipRight = Direction.Rotate(ItemRotation.Clockwise);
|
||||
var forward = cross(float3(shipRight.x, 0, shipRight.y), normal);
|
||||
Rotation = quaternion.LookRotation(forward, normal);
|
||||
|
|
|
|||
|
|
@ -617,17 +617,12 @@ public class Zone
|
|||
var result = 0f;
|
||||
foreach (var body in PlanetInstances.Values)
|
||||
{
|
||||
var p = position - body.Orbit.Position; //GetOrbitPosition(body.BodyData.Orbit)
|
||||
var distSqr = lengthsq(p);
|
||||
var gravityRadius = body.GravityWellRadius;
|
||||
if (distSqr < gravityRadius*gravityRadius)
|
||||
{
|
||||
var depth = body.GravityWellDepth;
|
||||
result -= PowerPulse(sqrt(distSqr) / gravityRadius, body.BodyData.GravityDepthExponent) * depth;
|
||||
}
|
||||
var offset = position - body.Orbit.Position;
|
||||
result += GravityField.Potential(body.WellStrength, body.CoreRadius, length(offset))
|
||||
- GravityField.Potential(body.WellStrength, body.CoreRadius, Pack.Radius);
|
||||
}
|
||||
|
||||
return result;
|
||||
return result / Settings.HeightExaggeration;
|
||||
}
|
||||
|
||||
public float GetLight(float2 position)
|
||||
|
|
@ -647,33 +642,21 @@ public class Zone
|
|||
internal float SchematicCellSize => _itemManager.GameplaySettings.SchematicCellSize;
|
||||
internal float FieldCrossSection(AsteroidBeltData belt) => _itemManager.ItemData.Get(belt.Kind)?.CrossSection ?? 0f;
|
||||
|
||||
// The one law of how a gravity well pushes anything: ships, floating items and mines all read it. The shape is
|
||||
// the one ships have always felt, normalize(f) * strength * (1 / (1 - |f|^2) - 1), gated off below |f|^2 = .001.
|
||||
// It is an acceleration, per second: the legacy law added that amount once per 60 fps frame with no dt, so
|
||||
// GravityReferenceRate (60) restates it and `acceleration * dt` gives the legacy push at 60 fps.
|
||||
public const float GravityReferenceRate = 60f;
|
||||
|
||||
public static float2 GravityAcceleration(float2 normalXz, float strength)
|
||||
{
|
||||
var magnitude = lengthsq(normalXz);
|
||||
if (magnitude <= .001f) return float2.zero;
|
||||
return normalize(normalXz) * strength * (1 / (1 - magnitude) - 1) * GravityReferenceRate;
|
||||
}
|
||||
|
||||
// The acceleration the well exerts at `position`.
|
||||
// The one source of gravitational acceleration (u/s^2): the sum of every body's well (GravityField). Ships,
|
||||
// floating items, clouds and mines all read it, and so does Flow. It is the gradient of the potential GetHeight
|
||||
// draws, so the two cannot disagree.
|
||||
public float2 GetForce(float2 position)
|
||||
{
|
||||
var normal = GetNormal(position);
|
||||
return GravityAcceleration(new float2(normal.x, normal.z), Settings.GravityStrength);
|
||||
var force = float2.zero;
|
||||
foreach (var body in PlanetInstances.Values)
|
||||
force += GravityField.Acceleration(body.WellStrength, body.CoreRadius, position - body.Orbit.Position);
|
||||
return force;
|
||||
}
|
||||
|
||||
// The global flow velocity at `position` in zone time, in u/s (FlowField is the law). The slope term reads the
|
||||
// downhill gradient, which GetNormal's xz is proportional to.
|
||||
public float3 Flow(float3 position)
|
||||
{
|
||||
var normal = GetNormal(position.xz);
|
||||
return FlowField.At(Settings.Flow, position, FlowScroll, new float2(normal.x, normal.z));
|
||||
}
|
||||
// downhill direction, which is the force.
|
||||
public float3 Flow(float3 position) =>
|
||||
FlowField.At(Settings.Flow, position, FlowScroll, GetForce(position.xz));
|
||||
|
||||
// The flow's noise scroll at zone time (Zone.Flow evaluates at exactly this); lowered to the shaders' _FlowScroll.
|
||||
public float2 FlowScroll => FlowField.Scroll(Settings.Flow, _time);
|
||||
|
|
@ -688,16 +671,12 @@ public class Zone
|
|||
return pow((x + 1) * (1 - x), exponent);
|
||||
}
|
||||
|
||||
public float3 GetNormal(float2 pos, float step = .1f, float mul = 1)
|
||||
// The surface normal of the drawn height (potential / K), derived from the force. Display only (ship tilt): the
|
||||
// sim never reads it as a force.
|
||||
public float3 GetNormal(float2 pos)
|
||||
{
|
||||
float hL = GetHeight(new float2(pos.x - step, pos.y)) * mul;
|
||||
float hR = GetHeight(new float2(pos.x + step, pos.y)) * mul;
|
||||
float hD = GetHeight(new float2(pos.x, pos.y - step)) * mul;
|
||||
float hU = GetHeight(new float2(pos.x, pos.y + step)) * mul;
|
||||
|
||||
// Deduce terrain normal
|
||||
float3 normal = new float3((hL - hR), (hD - hU), step*2);
|
||||
return normalize(normal).xzy;
|
||||
var slope = GetForce(pos) / Settings.HeightExaggeration;
|
||||
return normalize(new float3(slope.x, 1f, slope.y));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -706,10 +685,17 @@ public class Planet
|
|||
public Orbit Orbit;
|
||||
protected readonly PlanetSettings Settings;
|
||||
public BodyData BodyData;
|
||||
// Brush inputs only (ZoneRenderer's Gravity Well brushes and planet icon); no sim reader. grid-lowering deletes them.
|
||||
public float GravityWellDepth;
|
||||
public float GravityWellRadius;
|
||||
public float BodyRadius;
|
||||
|
||||
// The well (GravityField): strength c and softened core radius R by mass, and the Roche limit, the closest a child
|
||||
// may orbit. All three are authored PlanetSettings curves of mass; the core must sit inside the Roche limit.
|
||||
public float WellStrength;
|
||||
public float CoreRadius;
|
||||
public float RocheLimit;
|
||||
|
||||
public Planet(PlanetSettings settings, BodyData data, Orbit orbit)
|
||||
{
|
||||
Settings = settings;
|
||||
|
|
@ -723,6 +709,11 @@ public class Planet
|
|||
BodyRadius = Settings.BodyRadius.Evaluate(BodyData.Mass) * BodyData.BodyRadiusMultiplier;
|
||||
GravityWellRadius = Settings.GravityRadius.Evaluate(BodyData.Mass) * BodyData.GravityRadiusMultiplier;
|
||||
GravityWellDepth = Settings.GravityDepth.Evaluate(BodyData.Mass) * BodyData.GravityDepthMultiplier;
|
||||
WellStrength = Settings.WellStrength.Evaluate(BodyData.Mass);
|
||||
CoreRadius = Settings.CoreRadius.Evaluate(BodyData.Mass);
|
||||
RocheLimit = Settings.RocheLimit.Evaluate(BodyData.Mass);
|
||||
if (WellStrength > 0 && !(0 < CoreRadius && CoreRadius < RocheLimit))
|
||||
throw new InvalidOperationException("A body's core radius must be positive and inside its Roche limit (PlanetSettings.CoreRadius, RocheLimit).");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -92,9 +92,6 @@ public abstract class BodyData
|
|||
|
||||
[JsonProperty("depthMul")] [Key(7)]
|
||||
public float GravityDepthMultiplier = 1;
|
||||
|
||||
[JsonProperty("depthExp")] [Key(8)]
|
||||
public float GravityDepthExponent = 16;
|
||||
}
|
||||
|
||||
[CultDocument("aetheria.planetdata", "1"), MessagePackObject, JsonObject(MemberSerialization.OptIn)]
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ public sealed class FireControlTests : IDisposable
|
|||
var items = new ItemManager(_openCache, ledger, TestSettings(), _ => { });
|
||||
var planets = new PlanetSettings();
|
||||
var pack = new ZonePack();
|
||||
if (well) WellFixture.Dig(_openCache, pack, planets, pack.Radius, 1000f, 1f);
|
||||
if (well) WellFixture.Dig(_openCache, pack, planets, 300f, 100f);
|
||||
var zone = new Zone(items, planets, pack, new GalaxyZone { Name = "Test Zone", Owner = null }, null);
|
||||
|
||||
var hullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("TestHull"));
|
||||
|
|
|
|||
|
|
@ -168,53 +168,7 @@ public sealed class FloatingBodyTests : IDisposable
|
|||
Assert.NotEqual(new FloatingBodyId(7), new FloatingBodyId(8));
|
||||
}
|
||||
|
||||
// The law ships felt at 3035230b, written out here from that tree's Ship.Update (a per-tick velocity add, no dt)
|
||||
// and not from Zone: the pin must not move when Zone's law does.
|
||||
private static float2 LegacyPushPerFrame(float2 f, float strength)
|
||||
{
|
||||
var m = lengthsq(f);
|
||||
if (m <= .001f) return float2.zero;
|
||||
return normalize(f) * strength * (1 / (1 - m) - 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheGravityLawPushesShipsAsItDidAtSixtyFps()
|
||||
{
|
||||
foreach (var strength in new[] { 1f, 7.5f })
|
||||
foreach (var f in new[] { new float2(.1f, 0), new float2(.2f, .1f), new float2(-.3f, .4f), new float2(.5f, -.5f), new float2(0, .7f) })
|
||||
{
|
||||
var legacy = LegacyPushPerFrame(f, strength);
|
||||
var perFrame = Zone.GravityAcceleration(f, strength) * (1f / 60f);
|
||||
var tolerance = Math.Max(1e-7f, length(legacy) * 1e-5f);
|
||||
Assert.InRange(perFrame.x, legacy.x - tolerance, legacy.x + tolerance);
|
||||
Assert.InRange(perFrame.y, legacy.y - tolerance, legacy.y + tolerance);
|
||||
}
|
||||
// Per second: halving dt halves the push, so the law holds at any tick rate.
|
||||
var a = Zone.GravityAcceleration(new float2(.3f, .1f), 2f);
|
||||
Assert.Equal(a.x * .5f, (a * (1f / 120f)).x * 60f, 5);
|
||||
// The gate: |f|^2 at or below .001 pushes nothing.
|
||||
Assert.Equal(float2.zero, Zone.GravityAcceleration(new float2(.03f, 0), 1f));
|
||||
Assert.NotEqual(float2.zero, Zone.GravityAcceleration(new float2(.0317f, 0), 1f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DriftMatchesLegacyFloatingItemsAtSixtyFps()
|
||||
{
|
||||
// After 5 s at 60 fps with GravityStrength 1 and a normal of length |f| along x, the legacy GridObject drift
|
||||
// position (measured by Soul, 2026-10-07, off 3035230b's GridObject.Update): |f| .05 -> 1.7371, .1 -> 7.0012,
|
||||
// .3 -> 68.55, .5 -> 231.04. The sim steps the same law with exp decay where GridObject used 1 - k dt.
|
||||
var s = Settings();
|
||||
foreach (var (norm, expected) in new[] { (.05f, 1.7371f), (.1f, 7.0012f), (.3f, 68.55f), (.5f, 231.04f) })
|
||||
{
|
||||
var body = Run(new KinematicBody(), 5f, 1f / 60f, Zone.GravityAcceleration(new float2(norm, 0), 1f), s);
|
||||
Assert.InRange(body.Position.x, expected * .999f, expected * 1.001f);
|
||||
Assert.Equal(0f, body.Position.y, 4);
|
||||
}
|
||||
}
|
||||
|
||||
// Ship.Update's own gravity line, through the real ship and a real zone (no planets: the zone's well alone
|
||||
// gives a slope). The expectation is built from the zone's own normal and the 3035230b ship law, so flipping
|
||||
// the sign or changing how delta enters the line fails here, not only the Zone function's pin above.
|
||||
// Ship.Update's own gravity line, through the real ship and a real zone with one well at the origin.
|
||||
private Ship ShipAt(float x, out Zone zone)
|
||||
{
|
||||
var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), Path.Combine(_root, Guid.NewGuid().ToString("N") + ".run.cc"), catalogWritable: true);
|
||||
|
|
@ -230,9 +184,9 @@ public sealed class FloatingBodyTests : IDisposable
|
|||
QualityPriceModifier = new ExponentialLerp()
|
||||
};
|
||||
var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { });
|
||||
var planets = new PlanetSettings { GravityStrength = 1 };
|
||||
var planets = new PlanetSettings();
|
||||
var pack = new ZonePack { Radius = 1000 };
|
||||
WellFixture.Dig(cache, pack, planets, 1000, 1000, 1);
|
||||
WellFixture.Dig(cache, pack, planets, 300, 100);
|
||||
zone = new Zone(items, planets, pack, new GalaxyZone { Name = "Test Zone", Owner = null }, null);
|
||||
var design = cache.GetByName<HullData>("Skiff");
|
||||
var hull = new EquippableItem
|
||||
|
|
@ -248,21 +202,27 @@ public sealed class FloatingBodyTests : IDisposable
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void AShipInAWellIsPulledTowardItByTheLawPerSecond()
|
||||
public void AShipInAWellIsPulledByThePotential()
|
||||
{
|
||||
foreach (var delta in new[] { 1f / 60f, 1f / 30f })
|
||||
{
|
||||
var ship = ShipAt(400f, out var zone);
|
||||
var normal = zone.GetNormal(ship.Position.xz);
|
||||
var push = LegacyPushPerFrame(new float2(normal.x, normal.z), zone.Settings.GravityStrength) * (delta * 60f);
|
||||
Assert.True(push.x < 0f, "the well must pull a ship at +x toward the centre");
|
||||
var at = ship.Position.xz;
|
||||
var expected = zone.GetForce(at) * delta;
|
||||
Assert.True(expected.x < 0f, "the well must pull a ship at +x toward the centre");
|
||||
|
||||
ship.Update(delta);
|
||||
|
||||
var tolerance = Math.Max(1e-6f, length(push) * 1e-4f);
|
||||
Assert.InRange(ship.Velocity.x, push.x - tolerance, push.x + tolerance);
|
||||
Assert.InRange(ship.Velocity.y, push.y - tolerance, push.y + tolerance);
|
||||
var tolerance = Math.Max(1e-7f, length(expected) * 1e-4f);
|
||||
Assert.InRange(ship.Velocity.x, expected.x - tolerance, expected.x + tolerance);
|
||||
Assert.InRange(ship.Velocity.y, expected.y - tolerance, expected.y + tolerance);
|
||||
|
||||
var normal = zone.GetNormal(at);
|
||||
var q = ship.Rotation;
|
||||
var u = float3(q.x, q.y, q.z);
|
||||
var v = float3(0, 1, 0);
|
||||
var up = v + 2 * (q.w * cross(u, v) + cross(u, cross(u, v)));
|
||||
Assert.True(length(up - normal) < 1e-4f, "the ship tilts to the zone's surface normal");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,12 +45,12 @@ public sealed class FlowFieldTests : IDisposable
|
|||
SlopeAmplitude = slide, SwirlAmplitude = swirl
|
||||
};
|
||||
|
||||
// A zone at `time` (the pack's clock, as a saved zone restores it), flat unless `depth` digs the zone's own well.
|
||||
private Zone ZoneWith(FlowSettings flow, double time = 0, float depth = 0f)
|
||||
// A zone at `time` (the pack's clock, as a saved zone restores it), flat unless `strength` digs the zone's own well.
|
||||
private Zone ZoneWith(FlowSettings flow, double time = 0, float strength = 0f)
|
||||
{
|
||||
var planets = new PlanetSettings { Flow = flow };
|
||||
var pack = new ZonePack { Time = time };
|
||||
if (depth > 0f) WellFixture.Dig(_items.ItemData, pack, planets, pack.Radius, depth, 2f);
|
||||
if (strength > 0f) WellFixture.Dig(_items.ItemData, pack, planets, strength, 100f);
|
||||
return new Zone(_items, planets, pack, new GalaxyZone { Name = "Flow Zone", Owner = null }, null);
|
||||
}
|
||||
|
||||
|
|
@ -124,6 +124,28 @@ public sealed class FlowFieldTests : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
// Zone.Flow's slope term reads the owner's superposed force, not any one body's: two equal wells, probes off the
|
||||
// axis between them where the nearest body's direction and the sum's differ.
|
||||
[Fact]
|
||||
public void TheSlopeTermFollowsTheSuperposedForce()
|
||||
{
|
||||
var planets = new PlanetSettings { Flow = Flow(amplitude: 0f, slide: 1.5f) };
|
||||
var pack = new ZonePack();
|
||||
WellFixture.Dig(_items.ItemData, pack, planets, 300f, 100f, new float2(-300f, 0f));
|
||||
WellFixture.Dig(_items.ItemData, pack, planets, 300f, 100f, new float2(300f, 0f));
|
||||
var zone = new Zone(_items, planets, pack, new GalaxyZone { Name = "Two Wells", Owner = null }, null);
|
||||
var checkedPoints = 0;
|
||||
foreach (var at in new[] { float2(100f, 400f), float2(-50f, -300f), float2(250f, 220f), float2(-420f, 90f) })
|
||||
{
|
||||
var flow = zone.Flow(float3(at.x, 3f, at.y));
|
||||
var expected = 1.5f * normalize(zone.GetForce(at));
|
||||
Assert.Equal(expected.x, flow.x, 3);
|
||||
Assert.Equal(expected.y, flow.z, 3);
|
||||
checkedPoints++;
|
||||
}
|
||||
Assert.Equal(4, checkedPoints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlopeTermSwirlsAndSlidesOnTheGradient()
|
||||
{
|
||||
|
|
@ -131,13 +153,13 @@ public sealed class FlowFieldTests : IDisposable
|
|||
var at = new float3(500f, 3f, 300f);
|
||||
var downhill = normalize(float2(-at.x, -at.z));
|
||||
|
||||
var slide = ZoneWith(Flow(amplitude: 0f, slide: 1.5f), depth: 64f).Flow(at);
|
||||
var slide = ZoneWith(Flow(amplitude: 0f, slide: 1.5f), strength: 300f).Flow(at);
|
||||
Assert.Equal(0f, slide.y, 4);
|
||||
Assert.Equal(1.5f * downhill.x, slide.x, 2);
|
||||
Assert.Equal(1.5f * downhill.y, slide.z, 2);
|
||||
|
||||
// Swirl is perpendicular to downhill, and has a handedness: (down.y, -down.x).
|
||||
var swirl = ZoneWith(Flow(amplitude: 0f, swirl: 2f), depth: 64f).Flow(at);
|
||||
var swirl = ZoneWith(Flow(amplitude: 0f, swirl: 2f), strength: 300f).Flow(at);
|
||||
Assert.Equal(0f, swirl.y, 4);
|
||||
Assert.Equal(2f * downhill.y, swirl.x, 2);
|
||||
Assert.Equal(-2f * downhill.x, swirl.z, 2);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using CultMath;
|
||||
using GameCult.Caching;
|
||||
using Xunit;
|
||||
|
|
@ -41,7 +42,9 @@ public sealed class GravitySubtractTests : IDisposable
|
|||
// mass or multiplier than its twin would show it.
|
||||
private static PlanetSettings Planets() => new PlanetSettings
|
||||
{
|
||||
GravityStrength = 1,
|
||||
WellStrength = new ExponentialCurve { Exponent = .2f, Multiplier = 45.08f },
|
||||
CoreRadius = new ExponentialCurve { Exponent = .25f, Multiplier = 3f },
|
||||
RocheLimit = new ExponentialCurve { Exponent = .25f, Multiplier = 7.32f },
|
||||
BodyRadius = new ExponentialCurve { Exponent = .25f, Multiplier = 3f },
|
||||
GravityRadius = new ExponentialCurve { Exponent = .25f, Multiplier = 500f },
|
||||
GravityDepth = new ExponentialCurve { Exponent = .175f, Multiplier = 30f },
|
||||
|
|
@ -72,26 +75,6 @@ public sealed class GravitySubtractTests : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
// The fixture the five bowl tests share reproduces the zone bowl it replaced (map B14): the bowl was
|
||||
// -PowerPulse(r / (2 R), exponent) x depth, written out here from its definition, deepest at the centre and
|
||||
// zero from r = R on, so a fixture whose radius or depth drifted from the bowl would change every pull it feeds.
|
||||
[Fact]
|
||||
public void TheWellFixtureReproducesTheFormerBowl()
|
||||
{
|
||||
foreach (var (radius, depth, exponent, at, expected) in new[]
|
||||
{
|
||||
(1000f, 1000f, 1f, 0f, -1000f), (1000f, 1000f, 1f, 400f, -840f), (1000f, 1000f, 1f, 1000f, 0f),
|
||||
(2000f, 64f, 2f, 1000f, -36f), (2000f, 64f, 2f, 300f, -61.1524f)
|
||||
})
|
||||
{
|
||||
var planets = Planets();
|
||||
var pack = new ZonePack { Radius = radius };
|
||||
WellFixture.Dig(_cache, pack, planets, radius, depth, exponent);
|
||||
var zone = new Zone(_items, planets, pack, new GalaxyZone { Name = "Bowl Zone", Owner = null }, null);
|
||||
Assert.InRange(zone.GetHeight(new float2(at, 0)), expected - 1e-2f, expected + 1e-2f);
|
||||
}
|
||||
}
|
||||
|
||||
// One body of `data` at the origin of a zone whose only body it is.
|
||||
private Zone WithBody(BodyData data)
|
||||
{
|
||||
|
|
@ -100,9 +83,8 @@ public sealed class GravitySubtractTests : IDisposable
|
|||
data.Mass = 100f;
|
||||
data.GravityRadiusMultiplier = 1.5f;
|
||||
data.GravityDepthMultiplier = .7f;
|
||||
data.GravityDepthExponent = 2f;
|
||||
var body = _cache.Upsert(data).Key;
|
||||
var pack = new ZonePack { Radius = 1000f };
|
||||
var pack = new ZonePack { Radius = PackRadius };
|
||||
pack.Orbits.Add(new CultRecordRef<OrbitData>(orbit));
|
||||
pack.Planets.Add(new CultRecordRef<BodyData>(body));
|
||||
return new Zone(_items, Planets(), pack, new GalaxyZone { Name = "Giant " + data.GetType().Name, Owner = null }, null);
|
||||
|
|
@ -110,15 +92,18 @@ public sealed class GravitySubtractTests : IDisposable
|
|||
|
||||
// The wells are the whole of a giant's height at every point, not just at a few radii: a ripple confined to
|
||||
// any annulus, or to a band derived from the body's radius, shows against the plain body somewhere in a sweep
|
||||
// that runs from the centre to well past the well's support.
|
||||
// that runs from a quarter of the core to twice the pack's radius, the core radius itself included. The sweep
|
||||
// is derived from the body the zone built, not from constants.
|
||||
[Fact]
|
||||
public void AGiantsWellIsAPlainWellAtEveryTime()
|
||||
{
|
||||
var plain = WithBody(new PlanetData());
|
||||
var giants = new[] { WithBody(new GasGiantData()), WithBody(new SunData()) };
|
||||
var radii = new System.Collections.Generic.List<float> { 0.5f };
|
||||
for (var r = 1f; r <= 100f; r += 1f) radii.Add(r);
|
||||
for (var r = 105f; r <= 2000f; r += 5f) radii.Add(r);
|
||||
var core = plain.PlanetInstances.Values.Single().CoreRadius;
|
||||
Assert.True(core > 1f, "the fixture body has a core to sweep around");
|
||||
var radii = new System.Collections.Generic.List<float> { 0.5f, core };
|
||||
for (var r = core / 4f; r <= 4f * core; r += core / 16f) radii.Add(r);
|
||||
for (var r = 4f * core; r <= 2f * PackRadius; r += PackRadius / 200f) radii.Add(r);
|
||||
var angles = new[] { 0f, 0.7f, 2.1f, 4.0f };
|
||||
foreach (var time in new[] { 0f, 37.5f, 91.25f })
|
||||
{
|
||||
|
|
@ -146,19 +131,35 @@ public sealed class GravitySubtractTests : IDisposable
|
|||
}
|
||||
|
||||
private float _clock;
|
||||
private const float PackRadius = 1000f;
|
||||
|
||||
// CultCache has no primitive for reserving a removed MessagePack key; the wave keys of the gas giant (14-17)
|
||||
// must not come back with a different meaning, because stored records may still carry them.
|
||||
// CultCache has no primitive for reserving a removed MessagePack key. Key 8 of a body record (its former well
|
||||
// exponent) and the wave keys of the gas giants (14-17) must not come back with a different meaning, because
|
||||
// stored records may still carry them. Every record type that is a BodyData is checked, bases walked, so a giant
|
||||
// kind added later (the sun is one) cannot take them.
|
||||
[Fact]
|
||||
public void AGasGiantRecordNeverReusesTheRetiredWaveKeys()
|
||||
public void ABodyRecordNeverReusesRetiredKeys()
|
||||
{
|
||||
var keys = new System.Collections.Generic.List<int>();
|
||||
for (var t = typeof(GasGiantData); t != null; t = t.BaseType)
|
||||
foreach (var m in t.GetMembers(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic |
|
||||
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.DeclaredOnly))
|
||||
if (m.GetCustomAttributes(typeof(MessagePack.KeyAttribute), false) is MessagePack.KeyAttribute[] ks)
|
||||
foreach (var k in ks) if (k.IntKey.HasValue) keys.Add(k.IntKey.Value);
|
||||
Assert.NotEmpty(keys);
|
||||
foreach (var retired in new[] { 14, 15, 16, 17 }) Assert.DoesNotContain(retired, keys);
|
||||
static System.Collections.Generic.List<int> KeysOf(System.Type type)
|
||||
{
|
||||
var keys = new System.Collections.Generic.List<int>();
|
||||
for (var t = type; t != null; t = t.BaseType)
|
||||
foreach (var m in t.GetMembers(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic |
|
||||
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.DeclaredOnly))
|
||||
if (m.GetCustomAttributes(typeof(MessagePack.KeyAttribute), false) is MessagePack.KeyAttribute[] ks)
|
||||
foreach (var k in ks) if (k.IntKey.HasValue) keys.Add(k.IntKey.Value);
|
||||
return keys;
|
||||
}
|
||||
|
||||
var bodies = typeof(BodyData).Assembly.GetTypes().Where(x => typeof(BodyData).IsAssignableFrom(x)).ToArray();
|
||||
Assert.Contains(typeof(SunData), bodies);
|
||||
foreach (var type in bodies)
|
||||
{
|
||||
var keys = KeysOf(type);
|
||||
Assert.NotEmpty(keys);
|
||||
Assert.DoesNotContain(8, keys);
|
||||
if (typeof(GasGiantData).IsAssignableFrom(type))
|
||||
foreach (var retired in new[] { 14, 15, 16, 17 }) Assert.DoesNotContain(retired, keys);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,11 +110,7 @@ public sealed class LootTests : IDisposable
|
|||
var items = new ItemManager(cache, ledger, settings, _ => { });
|
||||
var planets = new PlanetSettings();
|
||||
var pack = new ZonePack { Radius = well ? 1000 : 0 };
|
||||
if (well)
|
||||
{
|
||||
planets.GravityStrength = 1;
|
||||
WellFixture.Dig(cache, pack, planets, 1000, 1000, 1);
|
||||
}
|
||||
if (well) WellFixture.Dig(cache, pack, planets, 300, 100);
|
||||
var zone = new Zone(items, planets, pack, new GalaxyZone { Name = zoneName, Owner = null }, null);
|
||||
return new Lab { Items = items, Zone = zone };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,9 +118,9 @@ public sealed class MineTests : IDisposable
|
|||
var ledger = new ProvenanceLedger();
|
||||
for (var i = 1; i <= 500; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 };
|
||||
var items = new ItemManager(cache, ledger, settings, _ => { });
|
||||
var planets = well ? new PlanetSettings { GravityStrength = 1 } : new PlanetSettings();
|
||||
var planets = new PlanetSettings();
|
||||
var pack = new ZonePack { Radius = well ? 1000 : 0 };
|
||||
if (well) WellFixture.Dig(cache, pack, planets, 1000, 1000, 1);
|
||||
if (well) WellFixture.Dig(cache, pack, planets, 300, 100);
|
||||
var zone = new Zone(items, planets, pack, new GalaxyZone { Name = "Mines", Owner = null }, null);
|
||||
var lab = new Lab { Items = items, Zone = zone, Data = data, ItemData = cache.GetByName<WeaponItemData>("Mine Layer") };
|
||||
|
||||
|
|
|
|||
216
tests/Aetheria.Shared.Tests/PotentialOwnerTests.cs
Normal file
216
tests/Aetheria.Shared.Tests/PotentialOwnerTests.cs
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
using System;
|
||||
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 target r4: one potential per zone owns gravity (potential-owns-gravity), built from the authored
|
||||
// curves of mass (well strength, core radius, Roche limit) and one global exaggeration. Each test builds the zone
|
||||
// through the production constructor from PlanetSettings and body records and reads the owner's GetForce and
|
||||
// GetHeight; the closed forms written here state the law, they are not Zone's code.
|
||||
public sealed class PotentialOwnerTests : IDisposable
|
||||
{
|
||||
private const float PackRadius = 1000f;
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-potential-" + Guid.NewGuid().ToString("N"));
|
||||
private readonly CultCache _cache;
|
||||
private readonly ItemManager _items;
|
||||
|
||||
public PotentialOwnerTests()
|
||||
{
|
||||
Directory.CreateDirectory(_root);
|
||||
_cache = AetheriaStores.Open(Path.Combine(_root, "pot.cc"), Path.Combine(_root, "pot-run.cc"), catalogWritable: true);
|
||||
_cache.Upsert(new VerseGrammar { Revision = 1 });
|
||||
_cache.FlushAsync().Wait();
|
||||
var settings = new GameplaySettings
|
||||
{
|
||||
DefaultEntitySettings = new EntitySettings(),
|
||||
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
|
||||
QualityPriceModifier = new ExponentialLerp()
|
||||
};
|
||||
_items = new ItemManager(_cache, new ProvenanceLedger(), settings, _ => { });
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cache.Dispose();
|
||||
Directory.Delete(_root, true);
|
||||
}
|
||||
|
||||
// The authored curves of the shipped Settings.asset: strength c = 45.08 m^.2, core R = 3 m^.25, Roche 7.32 m^.25.
|
||||
private static PlanetSettings Curves(float exaggeration = 1f) => new PlanetSettings
|
||||
{
|
||||
WellStrength = new ExponentialCurve { Exponent = .2f, Multiplier = 45.08f },
|
||||
CoreRadius = new ExponentialCurve { Exponent = .25f, Multiplier = 3f },
|
||||
RocheLimit = new ExponentialCurve { Exponent = .25f, Multiplier = 7.32f },
|
||||
BodyRadius = new ExponentialCurve { Exponent = .25f, Multiplier = 2f },
|
||||
GravityRadius = new ExponentialCurve(), GravityDepth = new ExponentialCurve(),
|
||||
LightRadius = new ExponentialCurve(), OrbitPeriod = new ExponentialCurve(),
|
||||
HeightExaggeration = exaggeration
|
||||
};
|
||||
|
||||
private int _bodies;
|
||||
|
||||
private Zone ZoneOf(PlanetSettings planets, params (float mass, float2 at)[] bodies)
|
||||
{
|
||||
var pack = new ZonePack { Radius = PackRadius };
|
||||
foreach (var (mass, at) in bodies)
|
||||
{
|
||||
var orbit = _cache.Upsert(new OrbitData { FixedPosition = at }).Key;
|
||||
var body = _cache.Upsert(new PlanetData { Name = "Body " + ++_bodies, Orbit = new CultRecordRef<OrbitData>(orbit), Mass = mass }).Key;
|
||||
pack.Orbits.Add(new CultRecordRef<OrbitData>(orbit));
|
||||
pack.Planets.Add(new CultRecordRef<BodyData>(body));
|
||||
}
|
||||
return new Zone(_items, planets, pack, new GalaxyZone { Name = "Potential " + _bodies, Owner = null }, null);
|
||||
}
|
||||
|
||||
private static void Near(float expected, float actual, float relative) =>
|
||||
Assert.InRange(actual, expected - Math.Abs(expected) * relative - 1e-5f, expected + Math.Abs(expected) * relative + 1e-5f);
|
||||
|
||||
// The law written out: potential c r^2 / 2R^2 inside the core, c (ln(r / R) + 1/2) beyond.
|
||||
private static float Phi(float c, float core, float r) => r < core ? c * r * r / (2 * core * core) : c * (log(r / core) + .5f);
|
||||
|
||||
[Fact]
|
||||
public void StrengthFollowsTheAuthoredMassCurve()
|
||||
{
|
||||
var planets = Curves();
|
||||
foreach (var mass in new[] { 29.2f, 273.9f, 14152.4f })
|
||||
{
|
||||
var zone = ZoneOf(planets, (mass, float2.zero));
|
||||
var core = planets.CoreRadius.Evaluate(mass);
|
||||
foreach (var r in new[] { 3f * core, 10f * core, PackRadius / 2f })
|
||||
Near(planets.WellStrength.Evaluate(mass), length(zone.GetForce(new float2(r, 0))) * r, 1e-3f);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheCoreRadiusFollowsItsOwnCurve()
|
||||
{
|
||||
var planets = Curves();
|
||||
foreach (var mass in new[] { 29.2f, 14152.4f })
|
||||
{
|
||||
var zone = ZoneOf(planets, (mass, float2.zero));
|
||||
var core = planets.CoreRadius.Evaluate(mass);
|
||||
float Pull(float r) => length(zone.GetForce(new float2(0, r)));
|
||||
var slope = Pull(.25f * core) / (.25f * core);
|
||||
Near(slope, Pull(.5f * core) / (.5f * core), 1e-3f);
|
||||
Near(slope, Pull(.9f * core) / (.9f * core), 1e-3f);
|
||||
Assert.True(Pull(core) > Pull(.98f * core), "the pull still rises up to the core radius");
|
||||
Assert.True(Pull(core) > Pull(1.02f * core), "and falls beyond it");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheCoreSitsInsideTheRocheLimit()
|
||||
{
|
||||
var planets = Curves();
|
||||
foreach (var mass in new[] { 29.2f, 14152.4f })
|
||||
Near(planets.RocheLimit.Evaluate(mass), ZoneOf(planets, (mass, float2.zero)).PlanetInstances.Values.Single().RocheLimit, 1e-6f);
|
||||
|
||||
PlanetSettings Constants(float core, float roche) => new PlanetSettings
|
||||
{
|
||||
WellStrength = new ExponentialCurve { Constant = 100f },
|
||||
CoreRadius = new ExponentialCurve { Constant = core },
|
||||
RocheLimit = new ExponentialCurve { Constant = roche },
|
||||
BodyRadius = new ExponentialCurve { Constant = 10f },
|
||||
GravityRadius = new ExponentialCurve(), GravityDepth = new ExponentialCurve(),
|
||||
LightRadius = new ExponentialCurve(), OrbitPeriod = new ExponentialCurve()
|
||||
};
|
||||
Assert.Throws<InvalidOperationException>(() => ZoneOf(Constants(60f, 50f), (100f, float2.zero)));
|
||||
Assert.Throws<InvalidOperationException>(() => ZoneOf(Constants(50f, 50f), (100f, float2.zero)));
|
||||
Assert.Throws<InvalidOperationException>(() => ZoneOf(Constants(0f, 50f), (100f, float2.zero)));
|
||||
ZoneOf(Constants(20f, 50f), (100f, float2.zero)); // inside the limit, and past the body's radius
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheTailIsAPointMassWithNoRimAndNoGate()
|
||||
{
|
||||
var planets = Curves();
|
||||
var zone = ZoneOf(planets, (273.9f, float2.zero));
|
||||
var c = planets.WellStrength.Evaluate(273.9f);
|
||||
var core = planets.CoreRadius.Evaluate(273.9f);
|
||||
foreach (var r in new[] { core, 2f * core, PackRadius, 10f * PackRadius, 100f * PackRadius, 1000f * PackRadius })
|
||||
foreach (var angle in new[] { 0f, 1.1f, 3.5f })
|
||||
{
|
||||
var at = new float2(r * cos(angle), r * sin(angle));
|
||||
var force = zone.GetForce(at);
|
||||
Near(c, length(force) * r, 1e-3f);
|
||||
Assert.True(dot(normalize(force), normalize(-at)) > .9999f, "the pull points at the body");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WellsSuperpose()
|
||||
{
|
||||
var planets = Curves();
|
||||
var a = (mass: 29.2f, at: new float2(-300f, 0f));
|
||||
var b = (mass: 14152.4f, at: new float2(300f, 0f));
|
||||
var both = ZoneOf(planets, a, b);
|
||||
var onlyA = ZoneOf(planets, a);
|
||||
var onlyB = ZoneOf(planets, b);
|
||||
var probes = new[]
|
||||
{
|
||||
float2(-300f, 0f), float2(300f, 0f), float2(-310f, 4f), float2(305f, -9f), float2(0f, 0f), float2(0f, 250f),
|
||||
float2(-150f, 100f), float2(150f, -100f), float2(900f, 700f), float2(-1200f, -300f), float2(40f, 3000f), float2(-300f, -40f)
|
||||
};
|
||||
foreach (var p in probes)
|
||||
{
|
||||
var sum = onlyA.GetForce(p) + onlyB.GetForce(p);
|
||||
Near(sum.x, both.GetForce(p).x, 1e-4f);
|
||||
Near(sum.y, both.GetForce(p).y, 1e-4f);
|
||||
Near(onlyA.GetHeight(p) + onlyB.GetHeight(p), both.GetHeight(p), 1e-4f);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeightIsThePotentialOverOneK()
|
||||
{
|
||||
foreach (var k in new[] { 1f, 10f })
|
||||
{
|
||||
var planets = Curves(k);
|
||||
var lone = ZoneOf(planets, (273.9f, float2.zero));
|
||||
var c = planets.WellStrength.Evaluate(273.9f);
|
||||
var core = planets.CoreRadius.Evaluate(273.9f);
|
||||
Assert.Equal(0f, lone.GetHeight(new float2(PackRadius, 0)), 4);
|
||||
Near(-c * (log(PackRadius / core) + .5f) / k, lone.GetHeight(float2.zero), 1e-4f);
|
||||
|
||||
var two = ZoneOf(planets, (29.2f, new float2(-300f, 0f)), (14152.4f, new float2(300f, 0f)));
|
||||
var c0 = planets.WellStrength.Evaluate(29.2f);
|
||||
var core0 = planets.CoreRadius.Evaluate(29.2f);
|
||||
var c1 = planets.WellStrength.Evaluate(14152.4f);
|
||||
var core1 = planets.CoreRadius.Evaluate(14152.4f);
|
||||
const float step = .5f;
|
||||
foreach (var p in new[] { float2(-200f, 120f), float2(0f, 0f), float2(100f, -300f), float2(500f, 40f), float2(-290f, 12f), float2(310f, 20f), float2(700f, 700f), float2(-80f, -500f) })
|
||||
{
|
||||
var expected = Phi(c0, core0, length(p - float2(-300f, 0f))) - Phi(c0, core0, PackRadius)
|
||||
+ Phi(c1, core1, length(p - float2(300f, 0f))) - Phi(c1, core1, PackRadius);
|
||||
Near(expected, two.GetHeight(p) * k, 1e-3f);
|
||||
|
||||
// The downhill slope of the potential is the force: the central difference of height * K is -GetForce.
|
||||
var gradient = float2(
|
||||
(two.GetHeight(p + float2(step, 0)) - two.GetHeight(p - float2(step, 0))) * k / (2 * step),
|
||||
(two.GetHeight(p + float2(0, step)) - two.GetHeight(p - float2(0, step))) * k / (2 * step));
|
||||
var force = two.GetForce(p);
|
||||
Assert.InRange(length(gradient + force), 0f, length(force) * 1e-2f + 1e-4f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheCoreMeetsTheTailAtItsRadius()
|
||||
{
|
||||
var planets = Curves();
|
||||
foreach (var mass in new[] { 29.2f, 14152.4f })
|
||||
{
|
||||
var zone = ZoneOf(planets, (mass, float2.zero));
|
||||
var core = planets.CoreRadius.Evaluate(mass);
|
||||
var inside = new float2(core * (1f - 1e-3f), 0);
|
||||
var outside = new float2(core * (1f + 1e-3f), 0);
|
||||
Assert.InRange(abs(zone.GetHeight(inside) - zone.GetHeight(outside)), 0f, abs(zone.GetHeight(outside)) * 1e-3f);
|
||||
Assert.InRange(abs(length(zone.GetForce(inside)) - length(zone.GetForce(outside))), 0f, length(zone.GetForce(outside)) * 1e-3f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +1,21 @@
|
|||
using CultMath;
|
||||
using GameCult.Caching;
|
||||
|
||||
// The one fixture that gives a test zone a slope: a single body on a parentless orbit at the origin, with
|
||||
// the curves a body needs. Gravity height is the sum of body wells, so a test that wants ground to slide on
|
||||
// digs it here and nowhere else. `radius` is the zone radius the well is sized to (its well reaches 2 x radius,
|
||||
// as the former bowl did); the well is deepest at the centre and zero at and beyond half its radius.
|
||||
// The one fixture that gives a test zone a slope: a body on a parentless orbit (the origin unless `at` says otherwise; call again for more bodies), with the
|
||||
// curves a body needs. Gravity is the sum of body wells (GravityField), so a test that wants ground to slide on
|
||||
// digs it here and nowhere else. `strength` is the well's c (u^2/s^2) and `coreRadius` its softened core in u; the
|
||||
// Roche limit curve sits at 2.5 cores so the body is a valid well.
|
||||
internal static class WellFixture
|
||||
{
|
||||
public static void Dig(CultCache cache, ZonePack pack, PlanetSettings settings, float radius, float depth, float exponent)
|
||||
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.GravityRadius = new ExponentialCurve { Constant = 2f * radius };
|
||||
settings.GravityDepth = new ExponentialCurve { Constant = depth };
|
||||
var orbit = cache.Upsert(new OrbitData { FixedPosition = float2.zero }).Key;
|
||||
var body = cache.Upsert(new PlanetData { Orbit = new CultRecordRef<OrbitData>(orbit), GravityDepthExponent = exponent }).Key;
|
||||
settings.WellStrength = new ExponentialCurve { Constant = strength };
|
||||
settings.CoreRadius = new ExponentialCurve { Constant = coreRadius };
|
||||
settings.RocheLimit = new ExponentialCurve { Constant = 2.5f * coreRadius };
|
||||
var orbit = cache.Upsert(new OrbitData { FixedPosition = at }).Key;
|
||||
var body = cache.Upsert(new PlanetData { Name = $"Well {at.x},{at.y}", Orbit = new CultRecordRef<OrbitData>(orbit) }).Key;
|
||||
pack.Orbits.Add(new CultRecordRef<OrbitData>(orbit));
|
||||
pack.Planets.Add(new CultRecordRef<BodyData>(body));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,15 +38,15 @@ public sealed class ZonePlacedAtBuildTests : IDisposable
|
|||
|
||||
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
|
||||
// One planet with a well (c 100, core 20 u, Roche limit 30 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,
|
||||
WellStrength = Flat(100f),
|
||||
CoreRadius = Flat(20f),
|
||||
RocheLimit = Flat(30f),
|
||||
LightRadius = Flat(0f)
|
||||
};
|
||||
|
||||
|
|
@ -57,6 +57,8 @@ public sealed class ZonePlacedAtBuildTests : IDisposable
|
|||
public HullData Hull;
|
||||
public Zone Zone;
|
||||
public CultRecordKey PlanetOrbit;
|
||||
public CultRecordKey MoonOrbit;
|
||||
public CultRecordKey MoonsMoonOrbit;
|
||||
}
|
||||
|
||||
private Scene Build(float phase, double time, bool moonFirst = false)
|
||||
|
|
@ -81,6 +83,10 @@ public sealed class ZonePlacedAtBuildTests : IDisposable
|
|||
{
|
||||
Parent = new CultRecordRef<OrbitData>(planetOrbit), Distance = 100f
|
||||
}).Key;
|
||||
var moonsMoonOrbit = cache.Upsert(new OrbitData
|
||||
{
|
||||
Parent = new CultRecordRef<OrbitData>(moonOrbit), Distance = 40f
|
||||
}).Key;
|
||||
var planet = cache.Upsert(new PlanetData { Orbit = new CultRecordRef<OrbitData>(planetOrbit), Mass = 1f }).Key;
|
||||
var pack = new ZonePack
|
||||
{
|
||||
|
|
@ -90,10 +96,10 @@ public sealed class ZonePlacedAtBuildTests : IDisposable
|
|||
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 };
|
||||
var listed = moonFirst ? new[] { moonsMoonOrbit, moonOrbit, planetOrbit, rootOrbit } : new[] { rootOrbit, planetOrbit, moonOrbit, moonsMoonOrbit };
|
||||
foreach (var key in listed) pack.Orbits.Add(new CultRecordRef<OrbitData>(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 };
|
||||
return new Scene { Cache = cache, Items = items, Hull = hull, Zone = zone, PlanetOrbit = planetOrbit, MoonOrbit = moonOrbit, MoonsMoonOrbit = moonsMoonOrbit };
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
|
@ -116,6 +122,31 @@ public sealed class ZonePlacedAtBuildTests : IDisposable
|
|||
Assert.InRange(normalize(force).x, -1f, -.99f);
|
||||
}
|
||||
|
||||
// A moon's moon (the fourth orbit, a child of a child of the planet) is placed by the constructor too, whichever
|
||||
// order the pack lists the chain in: each orbit sits its distance from its parent at its own phase of its own period.
|
||||
[Theory]
|
||||
[InlineData(false, 0.0)]
|
||||
[InlineData(true, 0.0)]
|
||||
[InlineData(false, 123.0)]
|
||||
[InlineData(true, 123.0)]
|
||||
public void AMoonsMoonSitsOnItsOrbitBeforeTheFirstTick(bool moonFirst, double time)
|
||||
{
|
||||
var s = Build(.1f, time, moonFirst);
|
||||
var planet = s.Zone.Orbits[s.PlanetOrbit];
|
||||
var moon = s.Zone.Orbits[s.MoonOrbit];
|
||||
var moonsMoon = s.Zone.Orbits[s.MoonsMoonOrbit];
|
||||
float2 Offset(Orbit orbit) => OrbitData.Evaluate(frac((float) frac(time / orbit.Period) + orbit.Data.Phase)) * orbit.Data.Distance;
|
||||
|
||||
var planetAt = RootAt + Offset(planet);
|
||||
var moonAt = planetAt + Offset(moon);
|
||||
var moonsMoonAt = moonAt + Offset(moonsMoon);
|
||||
|
||||
Assert.True(moon.Period > .01f && moonsMoon.Period > .01f, "the chain's orbits move");
|
||||
Assert.InRange(length(moon.Position - moonAt), 0f, .01f);
|
||||
Assert.InRange(length(moonsMoon.Position - moonsMoonAt), 0f, .01f);
|
||||
Assert.True(length(moonsMoonAt - moonAt) > 1f, "the moon's moon is not on its parent");
|
||||
}
|
||||
|
||||
// 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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue