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 <noreply@anthropic.com>
This commit is contained in:
parent
3f7bd145bb
commit
18fa8a5e1d
3 changed files with 146 additions and 14 deletions
|
|
@ -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<HullData>(hullName);
|
||||
var design = Design(Cache, designName);
|
||||
|
|
@ -135,6 +138,12 @@ public sealed class LaunchSuppressorCatalogTests
|
|||
var filler = Cache.GetAll<GearData>().First(hardpoint.IsFilledBy);
|
||||
Assert.True(Ship.TryEquip(new EquippableItem { Data = Cache.RefOf<ItemData>(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<ItemData>(sensor), Durability = sensor.Durability, Lot = lot++ }, hardpoint.Position));
|
||||
}
|
||||
Assert.True(Ship.TryEquip(new EquippableItem { Data = Cache.RefOf<ItemData>(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<object[]> 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<object[]> 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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, CultPersistedRecord> 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]
|
||||
|
|
|
|||
|
|
@ -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<GearData, CultRecordKey>();
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue