From 18fa8a5e1d4c7e8a116cf86c228387ae4010f674 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 21:37:41 +0200 Subject: [PATCH] launch-suppressor: apply lands only its own records; tests pin late hiding, the rating line, linear suppression and a named observer sensor Command: the batch carries ExpectUnchanged, so CultCache lands it onto the file as it is. A plain commit re-encoded every record through today's types (two sensors lost a retired key-7 payload and gained a trailing nil, LRMM72 and GT 3K gained a trailing nil); this one leaves every record it did not author byte-identical. A conflicting writer now fails the apply instead of being overwritten. Tests: the mild baffle stores exactly flash x Factor and pays exactly its predicted heat for the first 30 launches of a 300 flash or 8 of a 1000 flash, then is either hidden or bright; the rating is pinned to half a kelvin either side at three heat-sink qualities; suppression and heat are checked at six flash strengths; the observer fits the named sensor 'not if i see you first'; the command test widens every payload and checks apply leaves all other records byte-identical. This commit's GameData/Aetheria.cc pointer is the previous apply's output; the regenerated catalog follows in its own commit. Not built or run at this commit yet. Co-Authored-By: Claude Opus 5.5 --- .../LaunchSuppressorCatalogTests.cs | 111 ++++++++++++++++-- .../LaunchSuppressorCommandTests.cs | 41 +++++++ tools/AetherDb/Program.cs | 8 +- 3 files changed, 146 insertions(+), 14 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/LaunchSuppressorCatalogTests.cs b/tests/Aetheria.Shared.Tests/LaunchSuppressorCatalogTests.cs index 5bc0a787a..924b98834 100644 --- a/tests/Aetheria.Shared.Tests/LaunchSuppressorCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/LaunchSuppressorCatalogTests.cs @@ -15,6 +15,9 @@ public sealed class LaunchSuppressorCatalogTests { internal const string StrongName = "Launch Suppressor"; internal const string MildName = "Launch Baffle"; + // The observer's sensor, named: the reveal readings below are this design's PassiveInfo, so a catalog that gains an earlier + // sensor must not change which one the observer fits. + private const string SensorName = "not if i see you first"; private const float Flash = 1000f; private const float Tick = .25f; // exact in floats: four ticks are one second @@ -104,7 +107,7 @@ public sealed class LaunchSuppressorCatalogTests public readonly LaunchSuppressorData Data; public readonly int2 Cell; - public Rig(string hullName, string designName, float baffles = .5f, float sink = .5f) + public Rig(string hullName, string designName, float baffles = .5f, float sink = .5f, string sensorName = null) { var hull = Cache.GetByName(hullName); var design = Design(Cache, designName); @@ -135,6 +138,12 @@ public sealed class LaunchSuppressorCatalogTests var filler = Cache.GetAll().First(hardpoint.IsFilledBy); Assert.True(Ship.TryEquip(new EquippableItem { Data = Cache.RefOf(filler), Durability = filler.Durability, Lot = lot++ }, hardpoint.Position)); } + if (sensorName != null) + { + var sensor = Design(Cache, sensorName); + var hardpoint = hull.Hardpoints.First(h => h.Type == HardpointType.Sensors && h.IsFilledBy(sensor)); + Assert.True(Ship.TryEquip(new EquippableItem { Data = Cache.RefOf(sensor), Durability = sensor.Durability, Lot = lot++ }, hardpoint.Position)); + } Assert.True(Ship.TryEquip(new EquippableItem { Data = Cache.RefOf(design), Durability = design.Durability, Lot = suppressorLot })); zone.Admit(Ship, piloted: false); @@ -171,31 +180,43 @@ public sealed class LaunchSuppressorCatalogTests } } - // The number of launches, one a second with the hull ticking between them, that are suppressed before the first that is not. - internal static int LeadingSuppressed(string hull, string design, float strength, int launches = 60) + // One launch of a fight: what was stored, how far the suppressor's cell rose, and the rise the authored figures predict for the + // light removed (removed x HeatPerUnit / the cell's thermal mass). + internal readonly record struct Launched(float Stored, float Rise, float Predicted); + + // A fight of one launch a second with the hull ticking between them (four quarter-second updates). + internal static Launched[] Fight(string hull, string design, float strength, int launches = 60) { using var rig = new Rig(hull, design); + var perUnit = rig.Reading(rig.Data.HeatPerUnit); + var mass = rig.Ship.ThermalMass[rig.Cell.x, rig.Cell.y]; + var fight = new Launched[launches]; for (var i = 0; i < launches; i++) { - if (rig.Launch(strength) >= strength) return i; + var before = rig.Temperature; + var stored = rig.Launch(strength); + fight[i] = new Launched(stored, rig.Temperature - before, (strength - stored) * perUnit / mass); for (var t = 0; t < 4; t++) rig.Ship.Zone.Update(Tick); } - return launches; + return fight; } + // The number of launches that are suppressed before the first that is not. + internal static int LeadingSuppressed(string hull, string design, float strength, int launches = 60) => + Fight(hull, design, strength, launches).TakeWhile(l => l.Stored < strength).Count(); + // A sensor ship at the origin: the observer. What it reveals is Entity.Sense's own test, a stored strength times its // passive information at the emitter's position over the settings' threshold. private sealed class Observer : IDisposable { - private readonly CultCache _cache = RestoredHullsTests.OpenCatalog(); - private readonly Ship _ship; + private readonly Rig _rig; private readonly float _threshold = RestoredHullsTests.Settings().TargetDetectionInfoThreshold; - public Observer() => _ship = RestoredHullsTests.BuildThrustedShip(_cache, "Longinus", HardpointType.Sensors); + public Observer() => _rig = new Rig("Longinus", MildName, sensorName: SensorName); - public void Dispose() => _cache.Dispose(); + public void Dispose() => _rig.Dispose(); - public bool Sees(float stored, float distance) => stored * _ship.PassiveInfo(new float2(distance, 0f), 1f) > _threshold; + public bool Sees(float stored, float distance) => stored * _rig.Ship.PassiveInfo(new float2(distance, 0f), 1f) > _threshold; } public static IEnumerable QualityGrid() => @@ -273,16 +294,82 @@ public sealed class LaunchSuppressorCatalogTests Assert.False(observer.Sees(strong.Launch(Flash), 1500f)); // the strong design hides it from the target too } + // What the ruled figures make of a flash at mid quality: Factor lerp(.08, .03, .5) and lerp(.4, .2, .5). + private static float MidFactor(string design) => design == StrongName ? .055f : .3f; + + // The suppressor's one cell starts at rest (280 K) and a direct launch is made at each of these temperatures around the + // rating, a fraction of a kelvin either side: the rating alone decides whether the flash is hidden. No launch step is + // involved, so the line is pinned to the kelvin, not to however far a launch happens to move the cell. + [Theory] + [InlineData(StrongName, 0f, -.5f, true)] + [InlineData(StrongName, 0f, 0f, true)] + [InlineData(StrongName, 0f, .5f, false)] + [InlineData(StrongName, 1f, -.5f, true)] + [InlineData(StrongName, 1f, .5f, false)] + [InlineData(MildName, 0f, -.5f, true)] + [InlineData(MildName, 0f, .5f, false)] + [InlineData(MildName, .5f, 0f, true)] + [InlineData(MildName, 1f, -.5f, true)] + [InlineData(MildName, 1f, .5f, false)] + public void TheRatedTemperatureIsTheLineBetweenHidingAndNot(string design, float sink, float offset, bool hidden) + { + using var rig = new Rig("Longinus", design, sink: sink); + var rated = rig.Reading(rig.Data.RatedTemperature); + Assert.Equal(310f + 20f * sink, rated, 3); // the authored 310..330 on the heat sink + rig.Ship.Temperature[rig.Cell.x, rig.Cell.y] = rated + offset; + Assert.Equal(hidden ? Flash * MidFactor(design) : Flash, rig.Launch(Flash), 2); + } + + // Hiding is linear in the flash at every strength, and so is the heat it costs: a rule that treats weak flashes differently + // (or scales heat with strength) moves a row. Both designs at mid quality, a first launch from rest. + public static IEnumerable FlashGrid() => + from design in new[] { StrongName, MildName } + from strength in new[] { 40f, 200f, 499f, 501f, 1000f, 2400f } + select new object[] { design, strength }; + + [Theory] + [MemberData(nameof(FlashGrid))] + public void SuppressionAndItsHeatAreLinearInTheFlash(string design, float strength) + { + using var rig = new Rig("Longinus", design); + var before = rig.Temperature; + var stored = rig.Launch(strength); + Assert.Equal(strength * MidFactor(design), stored, 2); + var expected = (strength - stored) * rig.Reading(rig.Data.HeatPerUnit) / rig.Ship.ThermalMass[rig.Cell.x, rig.Cell.y]; + Assert.InRange(rig.Temperature - before, expected * .999f, expected * 1.001f); + } + + // "A long fight" for the mild design is launches at one launch a second: 30 of a 300 flash (a light weapon) or 8 of a 1000 + // flash (a heavy launch), on either hull (probed 36 and 33, and 10 and 10). Every one of those must store exactly the flash + // times the authored Factor and cost exactly the heat its removed light predicts, so a design that stops hiding late, or + // whose Factor or heat drifts as the cell warms, fails here. Past that the sink sits on its rating and the design flickers: + // each launch is either fully hidden or fully bright, never in between. That flicker is the ruled design at its rating. + [Theory] + [InlineData("Longinus", 300f, 30)] + [InlineData("Djinni", 300f, 30)] + [InlineData("Longinus", Flash, 8)] + [InlineData("Djinni", Flash, 8)] + public void MildSuppressorKeepsHidingThroughALongFight(string hull, float flash, int hold) + { + var fight = Fight(hull, MildName, flash); + var hidden = flash * MidFactor(MildName); + for (var i = 0; i < hold; i++) + { + Assert.True(Math.Abs(fight[i].Stored - hidden) < .01f, $"{hull}: launch {i} of {flash} stored {fight[i].Stored}, not the authored {hidden}"); + Assert.InRange(fight[i].Rise, fight[i].Predicted * .999f, fight[i].Predicted * 1.001f); + } + Assert.All(fight.Skip(hold), l => Assert.True(Math.Abs(l.Stored - hidden) < .01f || l.Stored == flash, $"{hull}: a launch stored {l.Stored}, neither hidden nor bright")); + } + [Theory] [InlineData("Longinus")] [InlineData("Djinni")] public void MildSuppressorSurvivesALongerFightThanStrong(string hull) { - foreach (var (flash, atLeast) in new[] { (300f, 30), (Flash, 8) }) + foreach (var flash in new[] { 300f, Flash }) { var mild = LeadingSuppressed(hull, MildName, flash); var strong = LeadingSuppressed(hull, StrongName, flash); - Assert.True(mild >= atLeast, $"{hull}: the mild design hid only {mild} launches of {flash}"); Assert.True(mild > strong, $"{hull}: mild {mild} did not outlast strong {strong} at {flash}"); } } diff --git a/tests/Aetheria.Shared.Tests/LaunchSuppressorCommandTests.cs b/tests/Aetheria.Shared.Tests/LaunchSuppressorCommandTests.cs index e6d2c55ac..90c1d3873 100644 --- a/tests/Aetheria.Shared.Tests/LaunchSuppressorCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/LaunchSuppressorCommandTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using GameCult.Caching; @@ -99,6 +100,46 @@ public sealed class LaunchSuppressorCommandTests : IDisposable Assert.Equal(landed, File.ReadAllBytes(CatalogPath)); } + // Re-spell every record's payload in a longer, equally valid MessagePack form (an array16 header for the fixarray one). The + // catalog then holds bytes no re-encode through the current types would reproduce, so a write that re-encodes records it did + // not author shows as changed bytes; a write that lands onto the file as it is leaves them alone. + private int WidenEveryPayload() + { + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(CatalogPath)); + var widened = 0; + foreach (var record in snapshot.Records) + { + var payload = record.Payload; + if (payload.Length == 0 || (payload[0] & 0xF0) != 0x90) continue; + record.Payload = new byte[] { 0xDC, 0x00, (byte)(payload[0] & 0x0F) }.Concat(payload.Skip(1)).ToArray(); + widened++; + } + File.WriteAllBytes(CatalogPath, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + return widened; + } + + private Dictionary Records() => + CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(CatalogPath)).Records.ToDictionary(r => r.Key, StringComparer.Ordinal); + + [Fact] + public void Apply_writes_only_the_records_it_authors() + { + Assert.True(WidenEveryPayload() > 100, "the scratch catalog should hold many records in the widened spelling"); + var before = Records(); + + Assert.Equal(0, Program.LaunchSuppressorCatalog(apply: true, root: _root)); + + var after = Records(); + Assert.Equal(before.Count + DesignNames.Length + _shippedProducts.Length, after.Count); + foreach (var (key, record) in before) + { + Assert.True(after.TryGetValue(key, out var kept), "a record the command did not author is gone"); + Assert.Equal(record.SchemaId, kept.SchemaId); + Assert.Equal(record.StoredAt, kept.StoredAt); + Assert.True(record.Payload.AsSpan().SequenceEqual(kept.Payload), "a record the command did not author has changed bytes"); + } + } + public enum PartialState { OneDesignOnly, DesignWithSomeProducts, ProductsWithoutTheirDesign } [Theory] diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 6651ce4e1..80d4b61c8 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -2357,8 +2357,11 @@ public static class Program } foreach (var design in designs) CultRecordRefs.Validate(design); - db.Cache.Commit(batch => + // ExpectUnchanged lands the batch onto the file as it is: every other record keeps the bytes it has, where a plain + // commit would re-encode the whole catalog through today's types. + if (!db.Cache.Commit(batch => { + batch.ExpectUnchanged(); var keys = new Dictionary(); foreach (var design in designs) keys[design] = batch.Upsert(typeof(GearData), design); foreach (var (design, name, maker, description) in products) @@ -2373,7 +2376,8 @@ public static class Program Roles = design.Roles.Select(r => new ProductRole { Role = r.Name, Mean = mean, StandardDeviation = dev }).ToList(), }); } - }); + })) + throw new InvalidOperationException("The catalog changed while the launch suppressors were being authored; nothing was written. Run it again."); Console.WriteLine($"\nAuthored 2 designs and {products.Length} products in Aetheria.cc"); return 0; }