From cb4a1dbd8aba5e16f4743ffaf66e7f74801e7070 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 12:30:07 +0200 Subject: [PATCH] Mining Cut 3: record the per-tick detection trace before Sensor.Gain is extracted SensorGainExtractionChangesNothing drives a fixed scene (two reflecting ships at different ranges and bearings, a ping part way through) and pins the observer's EntityInfoGathered trace, bit for bit, by hash. The golden value is recorded against the pre-extraction Sensor.Execute. Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests/MiningCut3Tests.cs | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/MiningCut3Tests.cs diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs new file mode 100644 index 000000000..d90f983fa --- /dev/null +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -0,0 +1,205 @@ +/* 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.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using CultMath; +using GameCult.Caching; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// Cut 3 (docs/mining-cut-refresh.md): a chunk is a target, detected by reflected light. One detection gain rule +// (Sensor.Gain) serves both the per-tick entity loop and the on-demand chunk query, and the chunk query returns +// the value the per-tick rule settles at (Q14 A). A belt's field kind is chosen once and saved (Q16 B). +public sealed class MiningCut3Tests : IDisposable +{ + private const float Dt = .05f; + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-miningcut3-" + Guid.NewGuid().ToString("N")); + private readonly List _caches = new List(); + private int _fixtureCount; + + public MiningCut3Tests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + foreach (var cache in _caches) cache.Dispose(); + Directory.Delete(_root, true); + } + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + 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(), + // Settings.asset's own values for the three detection tunables. + VisibilityDecay = .5f, + TargetInfoDecay = .5f, + TargetDetectionInfoThreshold = .1f, + SchematicCellSize = 2f + }; + + // Flat terrain (no zone depth, no gravity wells), one sun whose light reaches 1000 units, and the chunk + // curves MiningCut2Tests uses, so sizes and wear vary over the fixture's range. + private static PlanetSettings PlanetSettings() => 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 = 20f, Maximum = 220f, Exponent = 1.6f }, + AsteroidRespawnTime = new ExponentialLerp { Minimum = 8f, Maximum = 96f, Exponent = 1.2f }, + BodyRadius = new ExponentialCurve(), + GravityRadius = new ExponentialCurve(), + GravityDepth = new ExponentialCurve(), + WaveDepth = new ExponentialCurve(), + WaveRadius = new ExponentialCurve(), + WaveSpeed = new ExponentialCurve(), + WaveFrequency = new ExponentialCurve(), + LightRadius = new ExponentialCurve { Constant = 1000f } + }; + + // A sensitivity that falls off away from the sensor's facing, so the angle term is exercised, not a constant. + private static BezierCurve Falloff() => new BezierCurve + { + Keys = new[] { new float4(0, 1, 0, 0), new float4(1, .3f, 0, 0) } + }; + + private sealed class Scene + { + public CultCache Cache; + public ItemManager Items; + public Zone Zone; + public HullData Hull; + public GearData Eye; + } + + private Scene BuildScene() + { + var dir = Path.Combine(_root, $"scene{_fixtureCount++}"); + Directory.CreateDirectory(dir); + var cache = AetheriaStores.Open(Path.Combine(dir, "Aetheria.cc"), Path.Combine(dir, "run.cc"), catalogWritable: true); + _caches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var hullShape = new Shape(5, 5); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + var hull = new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10, Mass = 1000 }; + cache.Upsert(hull); + var eye = Gear("Eye", new SensorData + { + Sensitivity = Constant(3f), + SensitivityCurve = Falloff(), + PingBoost = Constant(.02f), + PingEnergy = Constant(0f), + PingVisibility = Constant(5f), + PingRange = Constant(300f), + PingCooldown = Constant(4f) + }); + cache.Upsert(eye); + var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }); + + var sunOrbit = cache.Upsert(new OrbitData()).Key; + var sun = cache.Upsert(new SunData { Orbit = new CultRecordRef(sunOrbit) }).Key; + var pack = new ZonePack + { + Orbits = { new CultRecordRef(sunOrbit) }, + Planets = { new CultRecordRef(sun) }, + Radius = 5000f, + Mass = 10000f, + Time = 0 + }; + var zone = new Zone(items, PlanetSettings(), pack, new GalaxyZone { Name = "MiningCut3", Owner = null }, null); + return new Scene { Cache = cache, Items = items, Zone = zone, Hull = hull, Eye = eye }; + } + + private static GearData Gear(string name, BehaviorData behavior) => new GearData + { + Name = name, Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { behavior } + }; + + private static EquippableItem Mint(Scene s, ItemData design) => new EquippableItem + { + Data = s.Cache.RefOf(design), + Durability = design is EquippableItemData equippable ? equippable.Durability : 0, + Lot = s.Items.Lots.Add(new Lot { Design = s.Cache.RefOf(design), Origin = new Attributed(), Quality = .5f, Roles = new List() }) + }; + + // A ship held at a planar position, facing +z. `sensor` fits the scene's sensor; `crossSection` fits a + // reflector of that cross-section (its own design, so ships in one scene can differ). + private Ship SpawnShip(Scene s, float2 at, bool sensor = false, float crossSection = 0f) + { + var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings()); + if (sensor) Assert.True(ship.TryEquip(Mint(s, s.Eye))); + if (crossSection > 0f) + { + var mirror = Gear($"Mirror {crossSection}", new ReflectorData { CrossSection = Constant(crossSection) }); + s.Cache.Upsert(mirror); + Assert.True(ship.TryEquip(Mint(s, mirror))); + } + s.Zone.Entities.Add(ship); + Hold(ship, at); + ship.LookDirection = float3(0, 0, 1); + ship.Activate(); + return ship; + } + + private static void Hold(Entity entity, float2 at) + { + entity.Position = float3(at.x, entity.Position.y, at.y); + entity.Velocity = float2.zero; + entity.Direction = float2(0, 1); + } + + // One simulation tick for the given entities, each held in place, in order. Zone time does not advance, so + // chunk poses and sun positions stay where they are. + private static void Tick(params (Entity entity, float2 at)[] held) + { + foreach (var (entity, at) in held) + { + Hold(entity, at); + entity.Update(Dt); + } + } + + private static uint Bits(float value) => BitConverter.ToUInt32(BitConverter.GetBytes(value), 0); + + // The per-tick entity detection loop is unchanged by the extraction of its gain rule: a fixed scene (two + // reflecting ships at different ranges and bearings, a ping part way through) yields the same + // EntityInfoGathered trace, bit for bit, as it did before Sensor.Gain existed. The golden hash was recorded + // against the pre-extraction Sensor.Execute. + private const string Golden = "6C23DF2584646E976A11CB0B8013A18499F5DCCABD86C7A840F201899984D737"; + + [Fact] + public void SensorGainExtractionChangesNothing() + { + var s = BuildScene(); + var eyeAt = float2(0, 50); + var nearAt = float2(30, 120); + var farAt = float2(-200, 90); + var observer = SpawnShip(s, eyeAt, sensor: true); + var near = SpawnShip(s, nearAt, crossSection: 40f); + var far = SpawnShip(s, farAt, crossSection: 400f); + + var trace = new List(); + for (var tick = 0; tick < 60; tick++) + { + if (tick == 5) observer.Sensor.Ping(); + Tick((near, nearAt), (far, farAt), (observer, eyeAt)); + trace.AddRange(BitConverter.GetBytes(Bits(observer.EntityInfoGathered[near]))); + trace.AddRange(BitConverter.GetBytes(Bits(observer.EntityInfoGathered[far]))); + } + + // Not a degenerate scene: both targets gathered some info, neither saturated. + Assert.InRange(observer.EntityInfoGathered[near], .01f, .99f); + Assert.InRange(observer.EntityInfoGathered[far], .01f, .99f); + var hash = Convert.ToHexString(SHA256.HashData(trace.ToArray())); + Assert.True(hash == Golden, $"the EntityInfoGathered trace changed: hash {hash}"); + } +}