From 22a54ccbaf4bebc2308bdc84c9978e0a3a615d8b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 01:27:16 +0200 Subject: [PATCH] Cut 1: belts are evaluated when asked, not simulated every tick Zone.Update no longer threads a per-belt Task.Run over a double-buffered Transforms/OrbitPosition pair. AsteroidBelt.Pose/Size are the one place a chunk pose is computed, a pure function of zone time; Zone.ChunkPose and Zone.EvaluateBelt read them at the zone's own time. MiningTool, ResourceScanner and ZoneRenderer (belt matrices and AsteroidBeltUI) all read through Zone instead of the deleted per-tick buffers; the renderer owns its own float4[] buffer, filled fresh every frame. Deleted: System.Threading/.Tasks usings, BeltUpdates, the wait loop, the per-belt copy+Task.Run, NearestAsteroid (dead/broken reader), the private UpdateAsteroidTransforms, and AsteroidBelt's four pose fields plus their allocations. ~65 lines, matching the cut map's estimate. miningprobe, densest zone of the operator's save (EAC-2265, 809 asteroids, 8 belts), rebuilt against this cut and rerun 10x each side: before (branch point, 8bd25f6f/e729e6d6): Zone.Update wall 541-3654 us/tick, CPU 120-391 us/tick (7 clean runs) 3/10 runs crashed: InvalidOperationException from Zone.GetOrbitPosition, the documented _updatedOrbits/Orbits data race between the main thread's Clear() and a belt Task.Run after (this commit): Zone.Update wall 95-185 us/tick, CPU 99-152 us/tick, in line with the 31-39 us "no belt work" baseline the map recorded -- belts are free per tick 0/10 races ChunkPose has no buffer to go stale: ~0.12 u of parent+asteroid motion is visible across one 17 ms tick instead of being one tick behind tests/Aetheria.Shared.Tests/MiningCut1Tests.cs (new, 260/260 suite green): ChunkPoseIsTheOrbitAtZoneTime, ChunkPoseDoesNotDependOnUpdateCadence, DamagedChunkShrinksAndABrokenOneHasNoSize -- each expected value is the closed-form orbit/size rule reimplemented in the test, never a call to AsteroidBelt.Pose/Size. Negative grep (Zone.cs, "Zone Display", ServerShared/Behaviors) for Task\.Run|System\.Threading|NewTransforms|\.Transforms\b|BeltUpdates| NearestAsteroid|\.OrbitPosition\b|NewOrbitPosition: one hit, the commented line in MineAsteroid that Cut 2 deletes. Co-Authored-By: Claude Opus 5.5 --- .../ServerShared/Behaviors/MiningTool.cs | 9 +- .../ServerShared/Behaviors/ResourceScanner.cs | 2 +- Assets/Scripts/ServerShared/Zone.cs | 107 ++++------ Assets/Scripts/Zone Display/ZoneRenderer.cs | 24 ++- .../Aetheria.Shared.Tests/MiningCut1Tests.cs | 201 ++++++++++++++++++ 5 files changed, 266 insertions(+), 77 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/MiningCut1Tests.cs diff --git a/Assets/Scripts/ServerShared/Behaviors/MiningTool.cs b/Assets/Scripts/ServerShared/Behaviors/MiningTool.cs index f03c12fac..1b388100f 100644 --- a/Assets/Scripts/ServerShared/Behaviors/MiningTool.cs +++ b/Assets/Scripts/ServerShared/Behaviors/MiningTool.cs @@ -56,10 +56,11 @@ public class MiningTool : Behavior public override bool Execute(float dt) { Range = Evaluate(_data.Range); - var belt = Entity.Zone.AsteroidBelts[AsteroidBelt]; - if (AsteroidBelt.IsSet() && - Entity.Zone.AsteroidExists(AsteroidBelt, Asteroid) && - length(Entity.Position.xz - belt.Transforms[Asteroid].xy) - belt.Transforms[Asteroid].w < Range) + if (!AsteroidBelt.IsSet() || !Entity.Zone.AsteroidExists(AsteroidBelt, Asteroid)) + return false; + + var pose = Entity.Zone.ChunkPose(AsteroidBelt, Asteroid); + if (length(Entity.Position.xz - pose.xy) - pose.w < Range) { Entity.Zone.MineAsteroid( Entity, diff --git a/Assets/Scripts/ServerShared/Behaviors/ResourceScanner.cs b/Assets/Scripts/ServerShared/Behaviors/ResourceScanner.cs index 0b708cd96..a8379204f 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ResourceScanner.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ResourceScanner.cs @@ -77,7 +77,7 @@ public class ResourceScanner : Behavior, IAlwaysUpdatedBehavior { if(Asteroid > -1 && Asteroid < beltData.Asteroids.Length && - length(Entity.Position.xz - Entity.Zone.AsteroidBelts[ScanTarget].Transforms[Asteroid].xy) < Range) + length(Entity.Position.xz - Entity.Zone.ChunkPose(ScanTarget, Asteroid).xy) < Range) { _scanTime += dt; if (_scanTime > ScanDuration) diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 14ee6b6c0..af7d22436 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -6,8 +6,6 @@ using System; using System.Collections; using System.Collections.Generic; using System.Linq; -using System.Threading; -using System.Threading.Tasks; using GameCult.Caching; using UniRx; using CultMath; @@ -43,8 +41,6 @@ public class Zone private int _nextShotId; public int NextShotId() => ++_nextShotId; - private List BeltUpdates = new List(); - public float Time { get => (float) _time; @@ -150,9 +146,6 @@ public class Zone { _time += deltaTime; _updatedOrbits.Clear(); - foreach (var t in BeltUpdates) - t.Wait(); - BeltUpdates.Clear(); foreach (var orbit in Orbits) { orbit.Value.PreviousPosition = orbit.Value.Position; @@ -160,13 +153,6 @@ public class Zone orbit.Value.Velocity = (orbit.Value.Position - orbit.Value.PreviousPosition) / deltaTime; } - foreach (var belt in AsteroidBelts) - { - Array.Copy(belt.Value.NewTransforms, belt.Value.Transforms, belt.Value.Transforms.Length); - belt.Value.OrbitPosition = belt.Value.NewOrbitPosition; - BeltUpdates.Add(Task.Run(() => UpdateAsteroidTransforms(belt.Key))); - } - foreach(var agent in Agents) agent.Update(deltaTime); @@ -223,55 +209,22 @@ public class Zone return float2.zero; } - public int NearestAsteroid(CultRecordKey planetDataID, float2 position) - { - var beltData = Planets[planetDataID] as AsteroidBeltData; - - var asteroidPositions = AsteroidBelts[planetDataID].Transforms; - - int nearest = 0; - float nearestDistance = Single.MaxValue; - for (int i = 0; i < beltData.Asteroids.Length; i++) - { - var dist = lengthsq(asteroidPositions[i].xz - position); - if (AsteroidExists(planetDataID, i) && dist < nearestDistance) - { - nearest = i; - nearestDistance = dist; - } - } - - return nearest; - } - public bool AsteroidExists(CultRecordKey planetDataID, int asteroid) => ((AsteroidBeltData) Planets[planetDataID]).Asteroids.Length > asteroid && asteroid >= 0; - private void UpdateAsteroidTransforms(CultRecordKey planetDataID) + // Cut 1 (docs/mining-cut.md): a chunk's pose is a pure function of zone time, computed when asked. No stored + // pose survives a tick; the sim and the renderer both read it fresh through here. + public float4 ChunkPose(CultRecordKey belt, int index) { - var beltData = Planets[planetDataID] as AsteroidBeltData; + var beltData = Planets[belt] as AsteroidBeltData; + var parentPosition = GetOrbitPosition(Orbits[beltData.Orbit.Key].Data.Parent.Key); + return AsteroidBelts[belt].Pose(index, _time, parentPosition, Settings); + } - var belt = AsteroidBelts[planetDataID]; - - var orbitData = Orbits[beltData.Orbit.Key].Data; - belt.NewOrbitPosition = GetOrbitPosition(orbitData.Parent.Key); - for (var i = 0; i < beltData.Asteroids.Length; i++) - { - float size; - if(belt.RespawnTimers.ContainsKey(i)) size = 0; - else if (belt.Damage.ContainsKey(i)) - { - var asteroidHitpoints = Settings.AsteroidHitpoints.Evaluate(beltData.Asteroids[i].Size); - var damage = (asteroidHitpoints - belt.Damage[i]) / asteroidHitpoints; - size = Settings.AsteroidSize.Evaluate(damage * beltData.Asteroids[i].Size); - } - else size = Settings.AsteroidSize.Evaluate(beltData.Asteroids[i].Size); - - var rot = (float) (_time * beltData.Asteroids[i].RotationSpeed % (PI * 2)); - var pos = OrbitData.Evaluate((float) frac(_time / Settings.OrbitPeriod.Evaluate(beltData.Asteroids[i].Distance) + - beltData.Asteroids[i].Phase)) * beltData.Asteroids[i].Distance + belt.NewOrbitPosition; - //belt.NewPositions[i] = float3(pos.x, GetHeight(pos) + Settings.AsteroidVerticalOffset, pos.y); - belt.NewTransforms[i] = float4(pos.x, pos.y, rot, size); - } + public void EvaluateBelt(CultRecordKey belt, Span into) + { + var beltData = Planets[belt] as AsteroidBeltData; + var parentPosition = GetOrbitPosition(Orbits[beltData.Orbit.Key].Data.Parent.Key); + AsteroidBelts[belt].Evaluate(into, _time, parentPosition, Settings); } public OrbitData CreateOrbit(CultRecordKey parent, float2 position) @@ -502,11 +455,7 @@ public class Sun : GasGiant public class AsteroidBelt { public AsteroidBeltData Data; - public float4[] Transforms; // x, y, rotation, scale - public float4[] NewTransforms; // x, y, rotation, scale public float Radius { get; } - public float2 OrbitPosition; - public float2 NewOrbitPosition; public Dictionary RespawnTimers = new Dictionary(); public Dictionary Damage = new Dictionary(); public Dictionary<(Entity, int), float> MiningAccumulator = new Dictionary<(Entity, int), float>(); @@ -514,10 +463,38 @@ public class AsteroidBelt public AsteroidBelt(AsteroidBeltData data) { Data = data; - Transforms = new float4[data.Asteroids.Length]; - NewTransforms = new float4[data.Asteroids.Length]; Radius = data.Asteroids.Max(a => a.Distance); } + + // Cut 1 (docs/mining-cut.md): the formula of Zone.cs:269-271 verbatim, `time` in double as `_time` was used. + // The only place a chunk pose is computed; no per-tick copy survives it. + public float4 Pose(int index, double time, float2 parentPosition, PlanetSettings settings) + { + 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; + return float4(pos.x, pos.y, rot, Size(index, settings)); + } + + // Cut 1 (docs/mining-cut.md): the size rule of Zone.cs:259-267, moved unchanged. + public float Size(int index, PlanetSettings settings) + { + if (RespawnTimers.ContainsKey(index)) return 0; + if (Damage.ContainsKey(index)) + { + var asteroidHitpoints = settings.AsteroidHitpoints.Evaluate(Data.Asteroids[index].Size); + var damage = (asteroidHitpoints - Damage[index]) / asteroidHitpoints; + return settings.AsteroidSize.Evaluate(damage * Data.Asteroids[index].Size); + } + return settings.AsteroidSize.Evaluate(Data.Asteroids[index].Size); + } + + public void Evaluate(Span into, double time, float2 parentPosition, PlanetSettings settings) + { + for (var i = 0; i < Data.Asteroids.Length; i++) + into[i] = Pose(i, time, parentPosition, settings); + } } public class Orbit diff --git a/Assets/Scripts/Zone Display/ZoneRenderer.cs b/Assets/Scripts/Zone Display/ZoneRenderer.cs index 0b437dcfe..13164eac3 100644 --- a/Assets/Scripts/Zone Display/ZoneRenderer.cs +++ b/Assets/Scripts/Zone Display/ZoneRenderer.cs @@ -72,6 +72,9 @@ public class ZoneRenderer : MonoBehaviour private Dictionary _beltObjects = new Dictionary(); private Dictionary _beltMeshes = new Dictionary(); private Dictionary _beltMatrices = new Dictionary(); + // Cut 1 (docs/mining-cut.md): the renderer's own buffer, filled from Zone.EvaluateBelt each frame. No pose + // is stored on AsteroidBelt any more. + private Dictionary _beltTransforms = new Dictionary(); private float _viewDistance; //private float _maxDepth; private float _minimapDistance; @@ -282,6 +285,7 @@ public class ZoneRenderer : MonoBehaviour _beltObjects.Clear(); _beltMeshes.Clear(); _beltMatrices.Clear(); + _beltTransforms.Clear(); _tourPlanets.Clear(); } } @@ -343,6 +347,7 @@ public class ZoneRenderer : MonoBehaviour meshes.RemoveAt(Random.Range(0, meshes.Count)); _beltMeshes[key] = meshes.ToArray(); _beltMatrices[key] = new Matrix4x4[meshes.Count][]; + _beltTransforms[key] = new float4[beltData.Asteroids.Length]; var count = beltData.Asteroids.Length / meshes.Count; var remainder = beltData.Asteroids.Length - count * meshes.Count; for (int i = 0; i < meshes.Count; i++) @@ -446,25 +451,30 @@ public class ZoneRenderer : MonoBehaviour foreach (var (key, belt) in Zone.AsteroidBelts) { - var height = Zone.GetHeight(belt.OrbitPosition); + // Cut 1 (docs/mining-cut.md): belts have no stored pose any more. The renderer fills its own buffer + // from Zone.EvaluateBelt, at the current zone time, every frame. + var orbitPosition = Zone.GetOrbitPosition(Zone.Orbits[belt.Data.Orbit.Key].Data.Parent.Key); + var height = Zone.GetHeight(orbitPosition); + var transforms = _beltTransforms[key]; + Zone.EvaluateBelt(key, transforms); if(isVisible(new Bounds( - new Vector3(belt.OrbitPosition.x,height,belt.OrbitPosition.y), + new Vector3(orbitPosition.x,height,orbitPosition.y), new Vector3(belt.Radius * 2,100,belt.Radius * 2)))) { var meshes = _beltMeshes[key]; var matrices = _beltMatrices[key]; - var count = belt.Transforms.Length / meshes.Length; + var count = transforms.Length / meshes.Length; for (int i = 0; i < meshes.Length; i++) { for (int t = 0; t < matrices[i].Length; t++) { var tx = t + i * count; - var transform = belt.Transforms[tx]; + var transform = transforms[tx]; matrices[i][t] = Matrix4x4.TRS(new Vector3(transform.x,0,transform.y), Quaternion.Euler( cos(transform.z + (float)i / meshes.Length) * 100, sin(transform.z + (float)i / meshes.Length) * 100, - (float)tx / belt.Transforms.Length * 360), + (float)tx / transforms.Length * 360), Vector3.one * transform.w); } @@ -473,7 +483,7 @@ public class ZoneRenderer : MonoBehaviour } if(_showAsteroidUI) - _beltObjects[key].Update(belt.Transforms, height); + _beltObjects[key].Update(transforms, height); } foreach (var planet in Planets) @@ -659,7 +669,7 @@ public class AsteroidBeltUI public void Update(float4[] transforms, float height) { - var parentPosition = _belt.OrbitPosition; + var parentPosition = _zone.GetOrbitPosition(_orbitParent); for (var i = 0; i < transforms.Length; i++) { var transform = transforms[i]; diff --git a/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs new file mode 100644 index 000000000..b47a0bcc1 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs @@ -0,0 +1,201 @@ +/* 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 System.IO; +using CultMath; +using GameCult.Caching; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; + +// Cut 1 (docs/mining-cut.md): belts stop being simulated every tick on a threaded double buffer and become a +// pure function of zone time, evaluated on request. These tests pin that: a chunk's pose is exactly the +// closed-form orbit at the zone's current time (no staleness), it does not depend on how many ticks it took to +// get there (no stored state), and the size rule moved off the deleted per-tick pass unchanged. Every expected +// value below is computed independently, by the closed form written in this file -- never by calling +// AsteroidBelt.Pose/Size, the code under test. +public sealed class MiningCut1Tests : IDisposable +{ + private int _fixtureCount; + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-miningcut1-" + Guid.NewGuid().ToString("N")); + + public MiningCut1Tests() => Directory.CreateDirectory(_root); + public void 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() + }; + + // 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. + private static PlanetSettings Settings() => new PlanetSettings + { + OrbitPeriod = new ExponentialCurve { Multiplier = 0.6f, Exponent = 1.3f, Constant = 40f }, + 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 } + }; + + private const int AsteroidCount = 24; + private const float RootDistance = 4000f; + 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 + // 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() + { + var dir = Path.Combine(_root, $"cat{_fixtureCount++}"); + Directory.CreateDirectory(dir); + var catalog = Path.Combine(dir, "Aetheria.cc"); + var run = Path.Combine(dir, "run.cc"); + // OrbitData and BodyData (AsteroidBeltData) are run types (AetheriaStores.RunTypes), not catalog types: + // they need a run store to write into. + var cache = AetheriaStores.Open(catalog, run, catalogWritable: true); + var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }); + var settings = Settings(); + + var rootOrbit = new OrbitData { Distance = RootDistance, Phase = RootPhase }; + var rootKey = cache.Upsert(rootOrbit).Key; + + var beltOrbit = new OrbitData { Parent = new CultRecordRef(rootKey), Distance = 900f, Phase = 0.42f }; + var beltOrbitKey = cache.Upsert(beltOrbit).Key; + + var asteroids = new Asteroid[AsteroidCount]; + for (var i = 0; i < AsteroidCount; i++) + { + asteroids[i] = new Asteroid + { + Distance = 120f + i * 11.3f, + Phase = frac(i * 0.6180339887f), + Size = frac(i * 0.37f + 0.1f), + RotationSpeed = 0.15f + i * 0.037f + }; + } + + var beltData = new AsteroidBeltData { Orbit = new CultRecordRef(beltOrbitKey), Asteroids = asteroids }; + var beltKey = cache.Upsert(beltData).Key; + + var pack = new ZonePack + { + Orbits = { new CultRecordRef(rootKey), new CultRecordRef(beltOrbitKey) }, + Planets = { new CultRecordRef(beltKey) }, + Radius = 5000f, + Mass = 10000f, + Time = 0 + }; + + var zone = new Zone(items, settings, pack, new GalaxyZone { Name = "MiningCut1", Owner = null }, null); + 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) + { + var period = settings.OrbitPeriod.Evaluate(distance); + if (period <= .01f) return fixedPosition; + 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) + { + 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; + return float4(pos.x, pos.y, rot, size); + } + + private static void AssertClose(float expected, float actual, float eps, string what) + { + Assert.True(Math.Abs(expected - actual) <= eps, $"{what}: expected {expected}, got {actual} (eps {eps})"); + } + + [Fact] + public void ChunkPoseIsTheOrbitAtZoneTime() + { + var (zone, beltKey, asteroids, settings) = BuildFixture(); + + // Uneven dt, so a tick-count-based or double-buffered implementation would disagree with a pure + // function of the accumulated time. + var dts = new[] { 0.13f, 0.07f, 0.31f, 0.02f, 0.5f, 0.11f, 0.19f, 0.08f, 0.44f, 0.03f, 0.27f, 0.09f }; + double time = 0; + foreach (var dt in dts) + { + zone.Update(dt); + time += dt; + } + + var parentPosition = OrbitPosition(time, RootDistance, RootPhase, settings); + + for (var i = 0; i < asteroids.Length; i++) + { + var expected = ExpectedPose(time, asteroids[i], parentPosition, settings, 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"); + AssertClose(expected.z, actual.z, 0.001f, $"asteroid {i} rotation"); + AssertClose(expected.w, actual.w, 0.001f, $"asteroid {i} size"); + } + } + + [Fact] + public void ChunkPoseDoesNotDependOnUpdateCadence() + { + var (zoneOneStep, beltKeyA, asteroids, settings) = BuildFixture(); + zoneOneStep.Update(1.0f); + + var (zoneManySteps, beltKeyB, _, _) = BuildFixture(); + for (var i = 0; i < 60; i++) + zoneManySteps.Update(1f / 60f); + + for (var i = 0; i < asteroids.Length; i++) + { + var a = zoneOneStep.ChunkPose(beltKeyA, i); + var b = zoneManySteps.ChunkPose(beltKeyB, i); + // Float summation of 60 steps of 1/60 drifts from 1.0 by a few ULPs; the pose is a pure function + // of that (nearly identical) time, not of how many ticks produced it, so a loose position epsilon + // catches a cadence-dependent implementation without chasing float noise. + AssertClose(a.x, b.x, 0.05f, $"asteroid {i} x"); + AssertClose(a.y, b.y, 0.05f, $"asteroid {i} y"); + AssertClose(a.z, b.z, 0.01f, $"asteroid {i} rotation"); + AssertClose(a.w, b.w, 0.0001f, $"asteroid {i} size"); + } + } + + [Fact] + public void DamagedChunkShrinksAndABrokenOneHasNoSize() + { + var (zone, beltKey, asteroids, settings) = BuildFixture(); + var belt = zone.AsteroidBelts[beltKey]; + + const int undamagedIndex = 5; + const int damagedIndex = 9; + const int brokenIndex = 14; + + var undamagedExpected = settings.AsteroidSize.Evaluate(asteroids[undamagedIndex].Size); + Assert.Equal(undamagedExpected, belt.Size(undamagedIndex, settings), 4); + + var hp = settings.AsteroidHitpoints.Evaluate(asteroids[damagedIndex].Size); + var damage = hp * 0.6f; + belt.Damage[damagedIndex] = damage; + var damagedExpected = settings.AsteroidSize.Evaluate((hp - damage) / hp * asteroids[damagedIndex].Size); + var damagedActual = belt.Size(damagedIndex, settings); + Assert.Equal(damagedExpected, damagedActual, 4); + Assert.True(damagedActual < settings.AsteroidSize.Evaluate(asteroids[damagedIndex].Size), + "a damaged asteroid must be smaller than its undamaged size"); + + belt.RespawnTimers[brokenIndex] = settings.AsteroidRespawnTime.Evaluate(asteroids[brokenIndex].Size); + Assert.Equal(0f, belt.Size(brokenIndex, settings)); + } +}