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/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index d3973d92e..84318ab4b 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; diff --git a/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs b/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs new file mode 100644 index 000000000..d35f02a2f --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs @@ -0,0 +1,143 @@ +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(); + var start = new KinematicBody { Velocity = new float2(.3f, .4f) }; + foreach (var dt in new[] { 1f / 120f, .1f }) + { + var b = Run(start, 3f, dt, float2.zero, s); + Assert.Equal(.5f * 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 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.NotEqual(0u, zero.state); + } + + [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)); + } +}