diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 62c8db228..768077deb 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -667,22 +667,6 @@ public static class FireControl // so whether a gated shot draws is unobservable from anywhere. The claim this comment used to make is // gone with the stream it was about, and the test that asserted it became vacuous the same moment; what // replaced it pins the live rule instead (combat touches no shared stream, on any path). - // 9.1 (docs/fire-control-cut.md): fmix32, MurmurHash3's 32-bit finalizer -- xor-shift, multiply, - // xor-shift, multiply, xor-shift. Diffuses a structured seed (here, a zone seed XORed with a small, - // low-bits-only ShotId) across every bit before the first xorshift draw reads it, so two seeds that - // differ only in ShotId's low bits still land on uncorrelated first outputs. Caller-side mixing only - // -- CultMath.Random itself is unchanged (see Commit's own note on why that follow-up is a separate - // CultLib cut). - private static uint MixSeed(uint seed) - { - seed ^= seed >> 16; - seed *= 0x85ebca6bu; - seed ^= seed >> 13; - seed *= 0xc2b2ae35u; - seed ^= seed >> 16; - return seed; - } - // Cut 12.2 (docs/fire-control-cut.md, "Bearing timing"): steps 1-5. Everything the shooter decided is // frozen in `shot` (R10); everything the target is doing -- position (deviation) and now also facing // (bearing) -- is read live, here, at the commit tick. The Cut 7 guard extends to cover the new factors: @@ -722,11 +706,11 @@ public static class FireControl // round for NextFloat's first call, and one round does not diffuse a seed that differs from its // neighbours only in ShotId's low bits. Unmixed, every zone's first draw was a near-constant // function of ShotId (measured span ~0.125), making a shot a step function of p fixed at zone - // creation, not a roll (Soul C1). MixSeed is a standard fmix32 finalizer (MurmurHash3): this is + // creation, not a roll (Soul C1). SimulationDice.Mix is a standard fmix32 finalizer (MurmurHash3): this is // a caller's obligation, not a CultMath defect -- seeding xorshift with a structured value and // drawing once is the documented way to get a correlated first output, and a follow-up to give // CultMath.Random a mixed-seed entry point of its own is recorded for a separate CultLib cut. - var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u)); + var random = new Random(SimulationDice.Mix((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u)); var p = CommitProbability(shot, now, out var sil); var hit = p > 0f && random.NextFloat() < p; diff --git a/Assets/Scripts/ServerShared/FloatingBodies.cs b/Assets/Scripts/ServerShared/FloatingBodies.cs new file mode 100644 index 000000000..a5d26ae20 --- /dev/null +++ b/Assets/Scripts/ServerShared/FloatingBodies.cs @@ -0,0 +1,42 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using CultMath; +using static CultMath.math; +using float2 = CultMath.float2; + +// A free-floating body in a zone: a floating item or a mine. Zone-scoped and never reused within a zone; +// runtime state only, never saved. +public readonly struct FloatingBodyId : IEquatable +{ + public readonly uint Value; + public FloatingBodyId(uint value) => Value = value; + public bool Equals(FloatingBodyId other) => Value == other.Value; + public override bool Equals(object obj) => obj is FloatingBodyId other && Equals(other); + public override int GetHashCode() => (int) Value; + public override string ToString() => Value.ToString(); +} + +// The one drift integrator of every free body in a zone. A launch velocity decays on its own drag; the zone's +// force accumulates into a separate drift velocity that has its own drag, so a body settles to +// force * gravity / drag. Engine-free: the caller supplies dt and the zone force at the body's position. +public struct KinematicBody +{ + public float2 Position; + // The launch velocity, decaying. + public float2 Velocity; + public float2 Drift; + + // What a presenter draws ahead with. + public float2 TotalVelocity => Velocity + Drift; + + public void Step(float dt, float2 force, GameplaySettings s) + { + Velocity *= exp(-s.FloatingBodyLaunchDrag * dt); + Drift += force * s.FloatingBodyGravity * dt; + Drift *= exp(-s.FloatingBodyDrag * dt); + Position += (Velocity + Drift) * dt; + } +} diff --git a/Assets/Scripts/ServerShared/FloatingBodies.cs.meta b/Assets/Scripts/ServerShared/FloatingBodies.cs.meta new file mode 100644 index 000000000..b71bf1d0d --- /dev/null +++ b/Assets/Scripts/ServerShared/FloatingBodies.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 274c5573e2634fb58c52dbbf0406ce79 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index cc7de7652..9c738b287 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -173,6 +173,11 @@ public class GameplaySettings public float ThermalQualityMin = 2; public float ThermalQualityMax = .25f; public float DefaultShutdownPerformance = .25f; + // Free-floating bodies (floating items, mines): drag on a launch velocity, drag on the force-driven drift, and + // how strongly the zone force accelerates it. + public float FloatingBodyDrag = .05f; + public float FloatingBodyLaunchDrag = .2f; + public float FloatingBodyGravity = 1f; public float SevereHeatstrokeRiskThreshold = .25f; public float WormholeDepth = 1000; public float WormholeExitVelocity = 20; diff --git a/Assets/Scripts/ServerShared/Ship.cs b/Assets/Scripts/ServerShared/Ship.cs index d10f097ca..b257f1cc9 100644 --- a/Assets/Scripts/ServerShared/Ship.cs +++ b/Assets/Scripts/ServerShared/Ship.cs @@ -299,12 +299,7 @@ public class Ship : Entity var normal = Zone.GetNormal(Position.xz); var force = new float2(normal.x, normal.z); - var forceMagnitude = lengthsq(force); - if (forceMagnitude > .001f) - { - var fa = 1 / (1 - forceMagnitude) - 1; - Velocity += normalize(force) * Zone.Settings.GravityStrength * fa; - } + 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); diff --git a/Assets/Scripts/ServerShared/SimulationDice.cs b/Assets/Scripts/ServerShared/SimulationDice.cs new file mode 100644 index 000000000..94d8f8946 --- /dev/null +++ b/Assets/Scripts/ServerShared/SimulationDice.cs @@ -0,0 +1,35 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using Random = CultMath.Random; + +// Structured simulation seeds: the one place a zone seed, a stream and an ordinal become a generator. Every +// draw is a pure function of its arguments, never of a shared stream, so one roll cannot perturb another. +public static class SimulationDice +{ + public const uint LootStream = 1; + public const uint MineStream = 2; + + // 9.1 (docs/fire-control-cut.md): fmix32, MurmurHash3's 32-bit finalizer -- xor-shift, multiply, + // xor-shift, multiply, xor-shift. Diffuses a structured seed (a zone seed XORed with a small, + // low-bits-only id) across every bit before the first xorshift draw reads it, so two seeds that differ + // only in their low bits still land on uncorrelated first outputs. Caller-side mixing only -- + // CultMath.Random itself is unchanged. FireControl.Commit's per-shot seed is built on this verbatim. + public static uint Mix(uint seed) + { + seed ^= seed >> 16; + seed *= 0x85ebca6bu; + seed ^= seed >> 13; + seed *= 0xc2b2ae35u; + seed ^= seed >> 16; + return seed; + } + + // The generator for the `ordinal`th roll of `stream` in the zone identified by `zoneSeed`. + public static Random For(uint zoneSeed, uint stream, uint ordinal) + { + var seed = Mix(Mix(zoneSeed ^ Mix(stream)) ^ ordinal); + return new Random(seed == 0 ? 1u : seed); + } +} diff --git a/Assets/Scripts/ServerShared/SimulationDice.cs.meta b/Assets/Scripts/ServerShared/SimulationDice.cs.meta new file mode 100644 index 000000000..c32cf0aba --- /dev/null +++ b/Assets/Scripts/ServerShared/SimulationDice.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: da428be21afa46a991b55ab9b794d192 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index d3973d92e..2e2fe6290 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -81,6 +81,10 @@ public class Zone // of touching a shared stream. public uint CombatSeed { get; } + // The one source of floating item and mine ids: zone-scoped, from 1, never reused. Runtime state, not saved. + private uint _lastBodyId; + public FloatingBodyId NextBodyId() => new FloatingBodyId(++_lastBodyId); + public Zone(ItemManager itemManager, PlanetSettings settings, ZonePack pack, GalaxyZone galaxyZone, Galaxy galaxy) { _time = pack.Time; @@ -435,11 +439,24 @@ 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`. public float2 GetForce(float2 position) { var normal = GetNormal(position); - var f = new float2(normal.x, normal.z); - return f * Settings.GravityStrength * lengthsq(f); + return GravityAcceleration(new float2(normal.x, normal.z), Settings.GravityStrength); } public static float PowerPulse(float x, float exponent) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs index 2ebeb72f1..811fc3a47 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs @@ -247,6 +247,20 @@ public sealed class FireControlCut9Tests : IDisposable } } + // Commit's seed is ((CombatSeed * K) ^ ShotId) | 1 before the fmix32: the `| 1` is part of the shot dice that + // 9.1 shipped, so two shot ids that differ only in bit 0 share a seed and so a draw. Mutation: drop the + // `| 1` -- the pairs below then draw different rolls. + [Fact] + public void ShotIdsDifferingOnlyInTheLowBitShareTheirDraw() + { + var e = BuildDieEngagement(TestSettings(), "Cut9-LowBit"); + var four = BisectRoll(e, 4); + Assert.Equal(four, BisectRoll(e, 5), 5); + var six = BisectRoll(e, 6); + Assert.Equal(six, BisectRoll(e, 7), 5); + Assert.NotEqual(four, six, 3); + } + // --------------------------------------------------------------------------------------------------- // 9.2: pOnHull must stay monotonic in Precision over the whole domain. A neutral shooter/target pair // (Accuracy 1, huge Resolution/Tracking, zero weapon Spread), the same isolation convention diff --git a/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs b/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs new file mode 100644 index 000000000..173a86678 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs @@ -0,0 +1,267 @@ +using System; +using System.Collections.Generic; +using System.IO; +using CultMath; +using GameCult.Caching; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using Random = CultMath.Random; + +// Floating bodies (aetheria-release cut floating-bodies): the drift loot and mines share, the structured +// dice they roll with, and the zone-scoped ids they carry. The body has no consumer yet; these tests are +// its whole contract. +public sealed class FloatingBodyTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-floatingbody-" + Guid.NewGuid().ToString("N")); + private readonly List _caches = new List(); + + public FloatingBodyTests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + foreach (var c in _caches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings Settings(float drag = .05f, float launchDrag = .2f, float gravity = 1f) => new GameplaySettings + { + FloatingBodyDrag = drag, FloatingBodyLaunchDrag = launchDrag, FloatingBodyGravity = gravity + }; + + private Zone NewZone() + { + var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true); + _caches.Add(cache); + 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() + }; + var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + return new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + } + + private static KinematicBody Run(KinematicBody b, float seconds, float dt, float2 force, GameplaySettings s) + { + var steps = (int) Math.Round(seconds / dt); + for (var i = 0; i < steps; i++) b.Step(dt, force, s); + return b; + } + + [Fact] + public void LaunchVelocityDecaysExponentially() + { + var s = Settings(); + // Speed .3: the semi-implicit step's position error is bounded by speed * dt / 2 (about .005 at dt .1), inside the 1e-2 pin. + var start = new KinematicBody { Velocity = new float2(.18f, .24f) }; + foreach (var dt in new[] { 1f / 120f, .1f }) + { + var b = Run(start, 3f, dt, float2.zero, s); + Assert.Equal(.3f * exp(-s.FloatingBodyLaunchDrag * 3f), length(b.Velocity), 4); + } + + var fine = Run(start, 3f, 1f / 120f, float2.zero, s); + var coarse = Run(start, 3f, .1f, float2.zero, s); + Assert.True(length(fine.Position - coarse.Position) < 1e-2f); + // It really moved, along the launch direction. + Assert.True(fine.Position.x > 0f && fine.Position.y > fine.Position.x); + } + + [Fact] + public void DriftFollowsTheForce() + { + var s = Settings(); + var force = new float2(.02f, -.01f); + var b = Run(new KinematicBody(), 600f, .1f, force, s); + Assert.Equal(force.x * s.FloatingBodyGravity / s.FloatingBodyDrag, b.Drift.x, 2); + Assert.Equal(force.y * s.FloatingBodyGravity / s.FloatingBodyDrag, b.Drift.y, 2); + Assert.Equal(b.Velocity + b.Drift, b.TotalVelocity); + + var noGravity = Run(new KinematicBody(), 10f, .1f, force, Settings(gravity: 0f)); + Assert.Equal(float2.zero, noGravity.Drift); + Assert.Equal(float2.zero, noGravity.Position); + + var noForce = Run(new KinematicBody { Velocity = new float2(1f, 0f) }, 2f, .1f, float2.zero, s); + Assert.Equal(float2.zero, noForce.Drift); + Assert.True(noForce.Position.x > 0f && noForce.Position.y == 0f); + } + + [Fact] + public void OneStepMovesByLaunchPlusDrift() + { + var s = Settings(); + var b = new KinematicBody { Velocity = new float2(2f, 0f), Drift = new float2(0f, 1f) }; + b.Step(.5f, new float2(.4f, 0f), s); + var velocity = 2f * exp(-s.FloatingBodyLaunchDrag * .5f); + var drift = new float2(.4f * .5f, 1f) * exp(-s.FloatingBodyDrag * .5f); + Assert.Equal(velocity, b.Velocity.x, 4); + Assert.Equal(drift.x, b.Drift.x, 4); + Assert.Equal(drift.y, b.Drift.y, 4); + Assert.Equal((velocity + drift.x) * .5f, b.Position.x, 4); + Assert.Equal(drift.y * .5f, b.Position.y, 4); + } + + [Fact] + public void ShotDiceAreUnchanged() + { + // fmix32 as FireControl.MixSeed wrote it, the function this cut moved. + static uint Reference(uint seed) + { + seed ^= seed >> 16; seed *= 0x85ebca6bu; seed ^= seed >> 13; seed *= 0xc2b2ae35u; seed ^= seed >> 16; + return seed; + } + var inputs = new List { 0u, 0xFFFFFFFFu }; + for (uint i = 1; inputs.Count < 64; i++) inputs.Add(i * 2654435761u); + foreach (var x in inputs) Assert.Equal(Reference(x), SimulationDice.Mix(x)); + Assert.Equal(0u, SimulationDice.Mix(0u)); + Assert.Equal(0x514E28B7u, SimulationDice.Mix(1u)); + } + + [Fact] + public void DiceStreamsAreIndependentAndRepeatable() + { + var loot = SimulationDice.For(1234u, SimulationDice.LootStream, 5u); + var mine = SimulationDice.For(1234u, SimulationDice.MineStream, 5u); + Assert.NotEqual(loot.NextUInt(), mine.NextUInt()); + + var a = SimulationDice.For(1234u, SimulationDice.LootStream, 5u); + var b = SimulationDice.For(1234u, SimulationDice.LootStream, 5u); + for (var i = 0; i < 8; i++) Assert.Equal(a.NextUInt(), b.NextUInt()); + + Assert.NotEqual(SimulationDice.For(1234u, SimulationDice.LootStream, 5u).NextUInt(), SimulationDice.For(1235u, SimulationDice.LootStream, 5u).NextUInt()); + Assert.NotEqual(SimulationDice.For(1234u, SimulationDice.LootStream, 5u).NextUInt(), SimulationDice.For(1234u, SimulationDice.LootStream, 6u).NextUInt()); + + // Never seeded 0: the argument set that mixes to zero still rolls. + var zero = SimulationDice.For(0u, 0u, 0u); + Assert.Equal(1u, zero.state); + } + + [Fact] + public void NeighbouringZonesAndOrdinalsNeverShareADraw() + { + // Folding stream and ordinal into the zone seed with one xor would make (zone, n) and (zone ^ 1, n ^ 1) + // the same seed; the double mix keeps them apart. + for (uint zone = 0; zone < 64; zone++) + for (uint n = 0; n < 16; n++) + Assert.NotEqual(SimulationDice.For(zone, SimulationDice.LootStream, n).NextUInt(), + SimulationDice.For(zone ^ 1u, SimulationDice.LootStream, n ^ 1u).NextUInt()); + } + + [Fact] + public void BodyIdsAreUniqueInAZone() + { + var zone = NewZone(); + var seen = new HashSet(); + uint last = 0; + for (var i = 0; i < 10000; i++) + { + var id = zone.NextBodyId(); + Assert.True(id.Value > last); + last = id.Value; + Assert.True(seen.Add(id)); + } + Assert.Equal(1u, NewZone().NextBodyId().Value); + Assert.Equal("1", new FloatingBodyId(1).ToString()); + Assert.Equal(new FloatingBodyId(7), new FloatingBodyId(7)); + 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. + private Ship ShipAt(float x, out Zone zone) + { + var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true); + _caches.Add(cache); + var hullShape = new Shape(5, 5); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10, Mass = 1000 }); + 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() + }; + var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var planets = new PlanetSettings { ZoneDepth = 1000, ZoneDepthExponent = 1, GravityStrength = 1 }; + zone = new Zone(items, planets, new ZonePack { Radius = 1000 }, new GalaxyZone { Name = "Test Zone", Owner = null }, null); + var design = cache.GetByName("Skiff"); + var hull = new EquippableItem + { + Data = cache.RefOf(design), Durability = design.Durability, + Lot = items.Lots.Add(new Lot { Design = cache.RefOf(design), Origin = new Attributed(), Quality = .5f, Roles = new List() }) + }; + var ship = new Ship(items, zone, hull, new EntitySettings()); + zone.Entities.Add(ship); + ship.LookDirection = float3(0, 0, 1); + ship.Activate(); + ship.Position = float3(x, 0, 0); + return ship; + } + + [Fact] + public void AShipInAWellIsPulledTowardItByTheLawPerSecond() + { + 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"); + + 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); + } + } + +}