diff --git a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs index b9c4c152e..e1d0d8cea 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs @@ -170,7 +170,7 @@ public interface IPowerConsumer Union(23, typeof(ThermotoggleData)), Union(24, typeof(EnergyDrawData)), // Union(25, typeof(MiningControllerData)), - Union(26, typeof(MiningToolData)), + // Union(26): retired, do not reuse -- was the "hit it with a tool" behavior deleted in Cut 2 (docs/mining-cut.md, Q1=A). // Union(27, typeof(SurveyControllerData)), Union(28, typeof(ResourceScannerData)), // Union(29, typeof(WanderControllerData)), diff --git a/Assets/Scripts/ServerShared/Behaviors/MiningTool.cs b/Assets/Scripts/ServerShared/Behaviors/MiningTool.cs deleted file mode 100644 index 1b388100f..000000000 --- a/Assets/Scripts/ServerShared/Behaviors/MiningTool.cs +++ /dev/null @@ -1,77 +0,0 @@ -/* 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 GameCult.Caching; -using System; -using System.Linq; -using MessagePack; -using Newtonsoft.Json; -using CultMath; -using static CultMath.math; - -[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)] -public class MiningToolData : BehaviorData -{ - [Inspectable, JsonProperty("dps"), Key(1)] - public PerformanceStat DamagePerSecond = new PerformanceStat(); - - [Inspectable, JsonProperty("efficiency"), Key(2)] - public PerformanceStat Efficiency = new PerformanceStat(); - - [Inspectable, JsonProperty("penetration"), Key(3)] - public PerformanceStat Penetration = new PerformanceStat(); - - [Inspectable, JsonProperty("range"), Key(4)] - public PerformanceStat Range = new PerformanceStat(); - - public override Behavior CreateInstance(EquippedItem item) - { - return new MiningTool(this, item); - } - public override Behavior CreateInstance(ConsumableItemEffect item) - { - return new MiningTool(this, item); - } -} - -public class MiningTool : Behavior -{ - public CultRecordKey AsteroidBelt; - public int Asteroid; - - private MiningToolData _data; - public float Range { get; private set; } - - public MiningTool(MiningToolData data, EquippedItem item) : base(data, item) - { - _data = data; - } - - public MiningTool(MiningToolData data, ConsumableItemEffect item) : base(data, item) - { - _data = data; - } - - public override bool Execute(float dt) - { - Range = Evaluate(_data.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, - AsteroidBelt, - Asteroid, - Evaluate(_data.DamagePerSecond) * dt, - Evaluate(_data.Efficiency), - Evaluate(_data.Penetration)); - return true; - } - - return false; - } -} \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Behaviors/MiningTool.cs.meta b/Assets/Scripts/ServerShared/Behaviors/MiningTool.cs.meta deleted file mode 100644 index 9985f36a2..000000000 --- a/Assets/Scripts/ServerShared/Behaviors/MiningTool.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: a7eebfaae57b2304580ef7e4a2740993 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Behaviors/ResourceScanner.cs b/Assets/Scripts/ServerShared/Behaviors/ResourceScanner.cs index a8379204f..051dd5832 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ResourceScanner.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ResourceScanner.cs @@ -2,13 +2,8 @@ * 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 GameCult.Caching; -using System; -using System.Linq; using MessagePack; using Newtonsoft.Json; -using CultMath; -using static CultMath.math; [Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable] public class ResourceScannerData : BehaviorData @@ -33,31 +28,18 @@ public class ResourceScannerData : BehaviorData } } +// Cut 2 (docs/mining-cut.md, Q1=A): the dead survey Execute and its scan-target fields are gone +// (ScanTarget/Asteroid/_scanTime/_scanTarget, and the always-false-until-scanned PlanetSurveyFloor write it +// worked toward). Range, MinimumDensity and ScanDuration stay authored data on this behavior until Q2 rules +// whether the scanner becomes a chunk's detection-range gate (docs/mining-cut.md Q2 consequence "Visibility"). public class ResourceScanner : Behavior, IAlwaysUpdatedBehavior { - public int Asteroid = -1; - private ResourceScannerData _data; - private float _scanTime; - private CultRecordKey _scanTarget; public float Range { get; private set; } public float MinimumDensity { get; private set; } public float ScanDuration { get; private set; } - public CultRecordKey ScanTarget - { - get => _scanTarget; - set - { - if (!value.Equals(_scanTarget)) - { - _scanTarget = value; - _scanTime = 0; - } - } - } - public ResourceScanner(ResourceScannerData data, EquippedItem item) : base(data, item) { _data = data; @@ -68,44 +50,6 @@ public class ResourceScanner : Behavior, IAlwaysUpdatedBehavior _data = data; } - public override bool Execute(float dt) - { - var planetData = Entity.Zone.Planets[ScanTarget]; - if (planetData != null) - { - if (planetData is AsteroidBeltData beltData) - { - if(Asteroid > -1 && - Asteroid < beltData.Asteroids.Length && - length(Entity.Position.xz - Entity.Zone.ChunkPose(ScanTarget, Asteroid).xy) < Range) - { - _scanTime += dt; - if (_scanTime > ScanDuration) - { - // TODO: Implement Scanning! - //Context.ItemData.Get(Entity.Corporation).PlanetSurveyFloor[ScanTarget] = MinimumDensity; - _scanTime = 0; - } - return true; - } - } - else - { - if(length(Entity.Position.xz - Entity.Zone.GetOrbitPosition(planetData.Orbit.Key)) < Range) - { - _scanTime += dt; - if (_scanTime > ScanDuration) - { - //Context.ItemData.Get(Entity.Corporation).PlanetSurveyFloor[ScanTarget] = MinimumDensity; - _scanTime = 0; - } - return true; - } - } - } - return false; - } - public void Update(float delta) { Range = Evaluate(_data.Range); diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index ffd369b01..67de2f0aa 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -29,7 +29,6 @@ public class PlanetSettings public ExponentialLerp AsteroidHitpoints; public ExponentialLerp AsteroidRespawnTime; public float GravityStrength; - public float MiningDifficulty = 500f; public float SecureAreaRadiusMultiplier = .45f; public ExponentialCurve OrbitPeriod; diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index af7d22436..c04fa4b8e 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -11,7 +11,6 @@ using UniRx; using CultMath; using static CultMath.math; using float2 = CultMath.float2; -using Random = CultMath.Random; public class Zone { @@ -28,9 +27,12 @@ public class Zone private ItemManager _itemManager; private double _time; - private Random _random; public List Agents = new List(); + // Cut 2 (docs/mining-cut.md): chunk wear, keyed by chunk. Zone.Wear is the only writer; ChunkExists and + // ChunkRadius are the only readers besides the pack/unpack round trip below. + private Dictionary _wear = new Dictionary(); + // Cut 3 (docs/fire-control-cut.md, 0b table): Zone owns the pending-shot collection and the ShotId // namespace; FireControl owns every transition a shot goes through. ShotCommitted publishes once a shot's // outcome is decided (R4's commit horizon); ShotResolved republishes the same, unchanged outcome at @@ -64,7 +66,6 @@ public class Zone _itemManager = itemManager; Settings = settings; CombatSeed = galaxyZone?.Name.StableHash() ?? 1337u; - _random = new Random(CombatSeed); var cache = itemManager.ItemData; // Cut 5, 5.6 (docs/fire-control-cut.md, Soul finding 7; operator ruling Q3): death removes the ship, @@ -99,6 +100,13 @@ public class Zone } } + // Cut 2 (docs/mining-cut.md): read after belts are built so a later ChunkExists/ChunkRadius call has + // Planets/AsteroidBelts populated. A pack from before key 6 existed has ChunkWear == null (nullable + // persistence rule); that reads as no wear rather than throwing. + if (pack.ChunkWear != null) + foreach (var wear in pack.ChunkWear) + _wear[new ChunkId(wear.Field, wear.Index)] = new ChunkWear { Damage = wear.Damage, BrokenUntil = wear.BrokenUntil }; + foreach (var entityPack in pack.Entities) { var entity = EntitySerializer.Unpack(_itemManager, this, entityPack); @@ -133,10 +141,19 @@ public class Zone Entities = Entities.Select(EntitySerializer.Pack).ToList(), Orbits = Orbits.Keys.Select(key => new CultRecordRef(key)).ToList(), Planets = Planets.Keys.Select(key => new CultRecordRef(key)).ToList(), - Time = _time + Time = _time, + // Cut 2 (docs/mining-cut.md): drop entries that carry no live information -- healed (no damage) and + // not currently broken -- so a save never accumulates wear rows for chunks nobody has touched since + // they last respawned. + ChunkWear = _wear + .Where(kv => !IsExpired(kv.Value)) + .Select(kv => new ChunkWearPack { Field = kv.Key.Field, Index = kv.Key.Index, Damage = kv.Value.Damage, BrokenUntil = kv.Value.BrokenUntil }) + .ToList() }; } + private bool IsExpired(ChunkWear wear) => wear.Damage <= 0f && (!wear.BrokenUntil.HasValue || wear.BrokenUntil.Value <= _time); + public void AddOrbit(OrbitData orbit) { Orbits.Add(_itemManager.ItemData.RefOf(orbit).Key, new Orbit(Settings, orbit)); @@ -209,7 +226,54 @@ public class Zone return float2.zero; } - public bool AsteroidExists(CultRecordKey planetDataID, int asteroid) => ((AsteroidBeltData) Planets[planetDataID]).Asteroids.Length > asteroid && asteroid >= 0; + // Cut 2 (docs/mining-cut.md): the index is in range for its field and the chunk is not broken at zone time. + // Respawn is a comparison against zone time, never a per-tick loop or a write on read. + public bool ChunkExists(ChunkId chunk) + { + if (!(Planets.TryGetValue(chunk.Field, out var body) && body is AsteroidBeltData beltData)) + return false; + if (chunk.Index < 0 || chunk.Index >= beltData.Asteroids.Length) + return false; + return !(_wear.TryGetValue(chunk, out var wear) && wear.BrokenUntil.HasValue && wear.BrokenUntil.Value > _time); + } + + // Cut 2: the size rule of AsteroidBelt.Size (Cut 1), now reading wear from its one owner (Zone) instead of + // per-belt dictionaries. A broken chunk (BrokenUntil in the future) has no size; an unbroken, undamaged + // chunk reads its authored size; a damaged one shrinks toward it. + public float ChunkRadius(ChunkId chunk) + { + var belt = AsteroidBelts[chunk.Field]; + if (!_wear.TryGetValue(chunk, out var wear)) + return belt.UndamagedSize(chunk.Index, Settings); + if (wear.BrokenUntil.HasValue && wear.BrokenUntil.Value > _time) + return 0f; + if (wear.Damage <= 0f) + return belt.UndamagedSize(chunk.Index, Settings); + return belt.DamagedSize(chunk.Index, wear.Damage, Settings); + } + + // Cut 2: the only writer of chunk wear. Returns whether this hit broke the chunk. The break threshold is + // strictly greater-than, matching the deleted per-tick miner's own comparison against accumulated damage: + // damage exactly equal to hitpoints does not yet break the chunk, only damage that exceeds them does. + public bool Wear(ChunkId chunk, float damage) + { + var beltData = (AsteroidBeltData) Planets[chunk.Field]; + var size = beltData.Asteroids[chunk.Index].Size; + var hitpoints = Settings.AsteroidHitpoints.Evaluate(size); + + _wear.TryGetValue(chunk, out var wear); + var newDamage = wear.Damage + damage; + + if (newDamage > hitpoints) + { + var respawnTime = Settings.AsteroidRespawnTime.Evaluate(size); + _wear[chunk] = new ChunkWear { Damage = 0f, BrokenUntil = _time + respawnTime }; + return true; + } + + _wear[chunk] = new ChunkWear { Damage = newDamage, BrokenUntil = null }; + return false; + } // 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. @@ -217,14 +281,17 @@ public class Zone { 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 size = ChunkRadius(new ChunkId(belt, index)); + return AsteroidBelts[belt].Pose(index, _time, parentPosition, Settings, 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); + var beltInstance = AsteroidBelts[belt]; + for (var i = 0; i < beltData.Asteroids.Length; i++) + into[i] = beltInstance.Pose(i, _time, parentPosition, Settings, ChunkRadius(new ChunkId(belt, i))); } public OrbitData CreateOrbit(CultRecordKey parent, float2 position) @@ -247,46 +314,6 @@ public class Zone return orbit; } - public void MineAsteroid(Entity miner, CultRecordKey asteroidBelt, int asteroid, float damage, float efficiency, float penetration) - { - var beltData = Planets[asteroidBelt] as AsteroidBeltData; - var belt = AsteroidBelts[asteroidBelt]; - //var asteroidTransform = belt.Transforms[asteroid]; - - var size = beltData.Asteroids[asteroid].Size; - var asteroidHitpoints = Settings.AsteroidHitpoints.Evaluate(size); - - if (!belt.Damage.ContainsKey(asteroid)) - belt.Damage[asteroid] = 0; - belt.Damage[asteroid] = belt.Damage[asteroid] + damage; - - if (!belt.MiningAccumulator.ContainsKey((miner, asteroid))) - belt.MiningAccumulator[(miner, asteroid)] = 0; - belt.MiningAccumulator[(miner, asteroid)] = belt.MiningAccumulator[(miner, asteroid)] + damage; - - if (belt.Damage[asteroid] > asteroidHitpoints) - { - belt.RespawnTimers[asteroid] = Settings.AsteroidRespawnTime.Evaluate(size); - belt.Damage.Remove(asteroid); - belt.MiningAccumulator.Remove((miner, asteroid)); - return; - } - - var resourceCount = beltData.Resources.Sum(x => x.Value); - var resource = beltData.Resources.MaxBy(x => pow(x.Value, 1f / penetration) * _random.NextFloat()); - if (efficiency * _random.NextFloat() * belt.MiningAccumulator[(miner, asteroid)] * resourceCount / Settings.MiningDifficulty > 1) - { - belt.MiningAccumulator.Remove((miner, asteroid)); - // var newSimpleCommodity = new SimpleCommodity - // { - // Data = resource.Key, - // Quantity = 1 - // }; - // TODO: Drop item onto the Grid - //miner.AddCargo(newSimpleCommodity); - } - } - public SecurityLevel GetSecurityLevel(float2 pos) { if (GalaxyZone.Owner==null) return SecurityLevel.Open; @@ -456,9 +483,6 @@ public class AsteroidBelt { public AsteroidBeltData Data; public float Radius { get; } - public Dictionary RespawnTimers = new Dictionary(); - public Dictionary Damage = new Dictionary(); - public Dictionary<(Entity, int), float> MiningAccumulator = new Dictionary<(Entity, int), float>(); public AsteroidBelt(AsteroidBeltData data) { @@ -467,34 +491,57 @@ public class AsteroidBelt } // 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) + // The only place a chunk pose is computed; no per-tick copy survives it. Cut 2: size is no longer read from + // per-belt storage -- Zone owns wear now, so the caller (Zone.ChunkPose/EvaluateBelt) computes it through + // Zone.ChunkRadius and passes it in. + public float4 Pose(int index, double time, float2 parentPosition, PlanetSettings settings, float size) { 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)); + return float4(pos.x, pos.y, rot, size); } - // Cut 1 (docs/mining-cut.md): the size rule of Zone.cs:259-267, moved unchanged. - public float Size(int index, PlanetSettings settings) + // Cut 1's size rule (Zone.cs:259-267), split in Cut 2 into its undamaged and damaged halves now that wear + // lives on Zone instead of here. Neither reads wear directly; the caller (Zone.ChunkRadius) decides which + // applies and supplies the damage. + public float UndamagedSize(int index, PlanetSettings settings) => settings.AsteroidSize.Evaluate(Data.Asteroids[index].Size); + + public float DamagedSize(int index, float damage, 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); + var asteroidHitpoints = settings.AsteroidHitpoints.Evaluate(Data.Asteroids[index].Size); + var remaining = (asteroidHitpoints - damage) / asteroidHitpoints; + return settings.AsteroidSize.Evaluate(remaining * Data.Asteroids[index].Size); + } +} + +// Cut 2 (docs/mining-cut.md): a chunk is (field, index). "Field" is deliberate, not "belt" -- the operator's +// ruling generalizes chunks to other kinds of debris field later; only AsteroidBelt/AsteroidBeltData, the one +// live field kind, keep their existing names. +public readonly struct ChunkId : IEquatable +{ + public readonly CultRecordKey Field; + public readonly int Index; + + public ChunkId(CultRecordKey field, int index) + { + Field = field; + Index = index; } - 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 bool Equals(ChunkId other) => Field.Equals(other.Field) && Index == other.Index; + public override bool Equals(object obj) => obj is ChunkId other && Equals(other); + public override int GetHashCode() => (Field.GetHashCode() * 397) ^ Index; +} + +// Cut 2: wear on one chunk. BrokenUntil is the absolute zone time the chunk respawns at; null means the chunk +// carries damage (or none) but is not currently broken. Respawn is read lazily by comparing to zone time -- +// nothing decrements or clears this on a timer. +public struct ChunkWear +{ + public float Damage; + public double? BrokenUntil; } public class Orbit diff --git a/Assets/Scripts/ServerShared/ZoneData.cs b/Assets/Scripts/ServerShared/ZoneData.cs index 90158fdb4..7b3515841 100644 --- a/Assets/Scripts/ServerShared/ZoneData.cs +++ b/Assets/Scripts/ServerShared/ZoneData.cs @@ -35,7 +35,32 @@ public class ZonePack [JsonProperty("time"), Key(5)] public double Time; + // Cut 2 (docs/mining-cut.md): chunk wear (damage taken, broken-until time), keyed by chunk. Nullable, not + // defaulted to an empty list: an older record has no key 6 at all, and MessagePack fills a missing trailing + // key with nil, which a reference-typed field reads back as null. ARunWithoutKeySixLoadsWhole pins that a + // record from before this key existed loads with no wear, rather than throwing or silently reading a + // default instance. + [JsonProperty("chunkWear"), Key(6)] + public List ChunkWear; +} +// Cut 2 (docs/mining-cut.md): one packed wear entry for one chunk. "Field" names the body that owns the chunk +// (an asteroid belt today; the operator's ruling generalizes chunks to other kinds of debris field later), not +// "belt" -- the chunk surface itself must not bake in the one live field kind. +[MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public class ChunkWearPack +{ + [JsonProperty("field"), Key(0)] + public CultRecordKey Field; + + [JsonProperty("index"), Key(1)] + public int Index; + + [JsonProperty("damage"), Key(2)] + public float Damage; + + [JsonProperty("brokenUntil"), Key(3)] + public double? BrokenUntil; } [JsonObject(MemberSerialization.OptIn)] diff --git a/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs index d35dcfca4..3091fad4d 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs @@ -173,30 +173,29 @@ public sealed class MiningCut1Tests : IDisposable } } + // Cut 2 (docs/mining-cut.md) moved wear off AsteroidBelt and onto Zone, so this now pins only the two pure + // size formulas AsteroidBelt still owns (undamaged, and damaged-given-a-damage-value). Whether a chunk is + // broken, and what its size reads as when broken, is Zone's decision -- see MiningCut2Tests + // (BrokenChunkHasNoRadius, DamagedChunkShrinksTowardBroken). [Fact] - public void DamagedChunkShrinksAndABrokenOneHasNoSize() + public void UndamagedAndDamagedSizeAreThePureCurves() { 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); + Assert.Equal(undamagedExpected, belt.UndamagedSize(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); + var damagedActual = belt.DamagedSize(damagedIndex, damage, 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)); } // Stryker gap (2026-09-25 Stryker pass on this cut): nothing called Zone.EvaluateBelt or