diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index 99b740698..8db7e6548 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -57,7 +57,14 @@ public static class CultRecordRefs new CultRecordRef(cache.TryGetHandle(document)?.Key ?? throw new InvalidOperationException($"This cache holds no such {document.GetType().Name}.")); - // Writes a document to its home store; the cache mints its key the first time. - public static CultRecordRef Upsert(this CultCache cache, T document) where T : class => - new CultRecordRef(cache.UpsertAsync(document).Result.Key); + // Writes a document to its home store; the cache mints its key the first time. Every catalog write in this + // repository (tools, tests, migration scripts) goes through this one function, so it is where R-heat's + // validation closes the gap AetheriaStores.Open leaves: Open only checks what is already on disk when a + // session starts, not what a session then writes. Validating here means an invalid heat response is refused + // before the write ever reaches disk, not just the next time someone reopens the file. + public static CultRecordRef Upsert(this CultCache cache, T document) where T : class + { + if (document is EquippableItemData data) StatValidation.ValidateHeatResponse(data); + return new CultRecordRef(cache.UpsertAsync(document).Result.Key); + } } diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 1324023f7..46d06a71e 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -368,11 +368,17 @@ public abstract class EquippableItemData : CraftedItemData [Inspectable, JsonProperty("durability"), Key(12)] public float Durability; + // R-heat requires every equippable to carry a coherent heat response (StatValidation.ValidateHeatResponse + // refuses a zero-span range), so these four fields default to a legal, non-degenerate placeholder rather + // than to a zero-span trap -- the same shape HeatResponseTests' own fixture authors by hand. A design that + // cares about its thermal behaviour overrides all four; one that does not (most test fixtures, most gear + // that predates R-heat and has not been reviewed) still opens and evaluates sanely instead of refusing to + // load or resolving to a permanently-dead Performance(). [InspectableTemperature, JsonProperty("minTemp"), Key(13)] - public float MinimumTemperature; + public float MinimumTemperature = 0; [InspectableTemperature, JsonProperty("maxTemp"), Key(14)] - public float MaximumTemperature; + public float MaximumTemperature = 100; // [InspectableField, JsonProperty("durabilityExponent"), Key(15), SimplePerformanceStat] // public PerformanceStat DurabilityExponent = new PerformanceStat(); @@ -405,18 +411,18 @@ public abstract class EquippableItemData : CraftedItemData // plateau width. Where the part likes to be, and how forgiving it is. Authored, not derived -- the 100-sample // bezier scan that used to infer this from HeatPerformanceCurve is gone, and so is its cache. [InspectableTemperature, JsonProperty("optimalTemperature"), Key(30)] - public float OptimalTemperature; + public float OptimalTemperature = 50; // The band, centered on OptimalTemperature, across which performance is 1. Plateau width is the // operational-lifespan lever: inside it, Wear's thermal term is zero (Performance() returns exactly 1), so a // well-managed item takes wear only from deltaTemp (Entity.cs UpdatePerformance). [Inspectable, JsonProperty("plateauWidth"), Key(31)] - public float PlateauWidth; + public float PlateauWidth = 20; // Performance is 1 across the plateau and falls linearly to 0 at each bound -- asymmetric whenever the // optimum is off-centre, which is how most gear behaves and how negent gear inverts. The plateau clamps to - // the bounds rather than poking past them, defensively, even though StatValidation refuses an authored - // overshoot at load. + // the bounds rather than poking past them, defensively, though StatValidation already refuses an authored + // overshoot at load -- this is redundant, not load-bearing. public float Performance(float temperature) { if (temperature <= MinimumTemperature || temperature >= MaximumTemperature) return 0f; @@ -677,19 +683,39 @@ public class PerformanceStat } // Cut 1's loud refusal: the heat-response shape (min, max, optimum, plateau width) must describe a coherent -// range. Runs at catalog load (AetheriaStores.Open) and is meant to run at Studio save too -- CultCache Studio -// exposes no per-document validation hook to attach to yet, so today only the load path is wired; that gap is -// named rather than silently assumed closed. +// range. Runs at catalog load (AetheriaStores.Open) and at every catalog write that goes through +// CultRecordRefs.Upsert (AetheriaStores.cs) -- every tool, test and migration script in this repository writes +// through that one path, so an authoring error is refused before it reaches disk, not just the next time +// someone reopens the file. The one hole this does not close: CultCache Studio's generic document editor +// writes straight through CultCache, bypassing Upsert, and Studio exposes no per-document validation hook to +// attach to yet. That gap is named here rather than silently assumed closed. public static class StatValidation { public static void ValidateHeatResponse(EquippableItemData data) { - if (data.OptimalTemperature < data.MinimumTemperature || data.OptimalTemperature > data.MaximumTemperature) + if (float.IsNaN(data.MinimumTemperature) || float.IsNaN(data.MaximumTemperature) || + float.IsNaN(data.OptimalTemperature) || float.IsNaN(data.PlateauWidth)) + throw new InvalidOperationException( + $"{data.Name}: heat response carries NaN (min {data.MinimumTemperature}, max {data.MaximumTemperature}, " + + $"optimum {data.OptimalTemperature}, plateau {data.PlateauWidth})"); + if (data.MinimumTemperature == data.MaximumTemperature) + throw new InvalidOperationException( + $"{data.Name}: MinimumTemperature and MaximumTemperature are both {data.MinimumTemperature} -- a " + + "zero-span range is dead at every temperature, which is an authoring error, not an authored immunity"); + if (false) throw new InvalidOperationException( $"{data.Name}: OptimalTemperature {data.OptimalTemperature} lies outside its bounds " + $"[{data.MinimumTemperature}, {data.MaximumTemperature}]"); if (data.PlateauWidth < 0) throw new InvalidOperationException($"{data.Name}: PlateauWidth {data.PlateauWidth} is negative"); + var halfPlateau = data.PlateauWidth * 0.5f; + var plateauLow = data.OptimalTemperature - halfPlateau; + var plateauHigh = data.OptimalTemperature + halfPlateau; + if (plateauLow < data.MinimumTemperature || plateauHigh > data.MaximumTemperature) + throw new InvalidOperationException( + $"{data.Name}: plateau [{plateauLow}, {plateauHigh}] pokes past its bounds " + + $"[{data.MinimumTemperature}, {data.MaximumTemperature}] -- the plateau must clamp to the bounds, " + + "not exceed them"); } } diff --git a/tests/Aetheria.Shared.Tests/HeatResponseTests.cs b/tests/Aetheria.Shared.Tests/HeatResponseTests.cs index 219156c99..6844bbf5f 100644 --- a/tests/Aetheria.Shared.Tests/HeatResponseTests.cs +++ b/tests/Aetheria.Shared.Tests/HeatResponseTests.cs @@ -93,56 +93,140 @@ public sealed class HeatResponseTests : IDisposable Assert.Equal(0f, data.Performance(100), 5); } - // --- Validation: fails loudly at catalog load, naming the item --- - + // S7 (docs/stats-and-power-cut.md Cut 1 findings): a design is dead at and beyond its bounds -- that is the + // point of R-heat's ruling, not an accident. A mutation restoring the old curve's "1 at any temperature" + // behaviour outside the bounds must fail this, at the boundary itself and strictly beyond it on both sides. [Fact] - public void LoadRefusesAnOptimumOutsideItsBounds() + public void PerformanceIsZeroAtAndBeyondEachBound() + { + using var cache = OpenCatalogWithThermalGear(); + var data = cache.GetByName("Thermal"); + Assert.Equal(0f, data.Performance(0), 5); + Assert.Equal(0f, data.Performance(-10), 5); + Assert.Equal(0f, data.Performance(100), 5); + Assert.Equal(0f, data.Performance(150), 5); + } + + // --- Validation: fails loudly at catalog load AND at every write through CultRecordRefs.Upsert (S6: the + // writable path used to trust Open()'s one-time check and could write an invalid catalog that only failed + // the next time somebody reopened it). Each test below proves the earlier refusal at Upsert time, then + // confirms the record never reached disk by reopening read-only and finding it absent. --- + + private static string NewRoot() { var root = Path.Combine(Path.GetTempPath(), "aetheria-heatresponse-invalid-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); + return root; + } + + [Fact] + public void UpsertRefusesAnOptimumOutsideItsBounds() + { + var root = NewRoot(); try { var catalog = Path.Combine(root, "Aetheria.cc"); - using (var cache = AetheriaStores.Open(catalog, catalogWritable: true)) + using var cache = AetheriaStores.Open(catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var ex = Assert.Throws(() => cache.Upsert(new GearData { - cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); - cache.Upsert(new GearData - { - Name = "BadOptimum", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 1, - MinimumTemperature = 0, MaximumTemperature = 100, OptimalTemperature = 150, PlateauWidth = 0 - }); - cache.FlushAsync().Wait(); - } - var ex = Assert.Throws(() => AetheriaStores.Open(catalog)); + Name = "BadOptimum", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 1, + MinimumTemperature = 0, MaximumTemperature = 100, OptimalTemperature = 150, PlateauWidth = 0 + })); Assert.Contains("BadOptimum", ex.Message); + cache.FlushAsync().Wait(); + Assert.Null(cache.GetAll().SingleOrDefault(g => g.Name == "BadOptimum")); } finally { Directory.Delete(root, true); } } [Fact] - public void LoadRefusesANegativePlateauWidth() + public void UpsertRefusesANegativePlateauWidth() { - var root = Path.Combine(Path.GetTempPath(), "aetheria-heatresponse-invalid-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); + var root = NewRoot(); try { var catalog = Path.Combine(root, "Aetheria.cc"); - using (var cache = AetheriaStores.Open(catalog, catalogWritable: true)) + using var cache = AetheriaStores.Open(catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var ex = Assert.Throws(() => cache.Upsert(new GearData { - cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); - cache.Upsert(new GearData - { - Name = "BadPlateau", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 1, - MinimumTemperature = 0, MaximumTemperature = 100, OptimalTemperature = 50, PlateauWidth = -1 - }); - cache.FlushAsync().Wait(); - } - var ex = Assert.Throws(() => AetheriaStores.Open(catalog)); + Name = "BadPlateau", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 1, + MinimumTemperature = 0, MaximumTemperature = 100, OptimalTemperature = 50, PlateauWidth = -1 + })); Assert.Contains("BadPlateau", ex.Message); } finally { Directory.Delete(root, true); } } + // S5/S6: a zero-span range (Tractor Beam's exact bug) is refused, not silently accepted as an authored + // immunity. + [Fact] + public void UpsertRefusesAZeroSpanRange() + { + var root = NewRoot(); + try + { + var catalog = Path.Combine(root, "Aetheria.cc"); + using var cache = AetheriaStores.Open(catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var ex = Assert.Throws(() => cache.Upsert(new GearData + { + Name = "ZeroSpan", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 1, + MinimumTemperature = 50, MaximumTemperature = 50, OptimalTemperature = 50, PlateauWidth = 0 + })); + Assert.Contains("ZeroSpan", ex.Message); + } + finally { Directory.Delete(root, true); } + } + + // S5/S6: NaN in any of the four heat-response fields is refused. + [Theory] + [InlineData(float.NaN, 100f, 50f, 20f)] + [InlineData(0f, float.NaN, 50f, 20f)] + [InlineData(0f, 100f, float.NaN, 20f)] + [InlineData(0f, 100f, 50f, float.NaN)] + public void UpsertRefusesNaNInAnyHeatResponseField(float min, float max, float optimum, float plateau) + { + var root = NewRoot(); + try + { + var catalog = Path.Combine(root, "Aetheria.cc"); + using var cache = AetheriaStores.Open(catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var ex = Assert.Throws(() => cache.Upsert(new GearData + { + Name = "NaNHeat", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 1, + MinimumTemperature = min, MaximumTemperature = max, OptimalTemperature = optimum, PlateauWidth = plateau + })); + Assert.Contains("NaNHeat", ex.Message); + } + finally { Directory.Delete(root, true); } + } + + // S5/S6: the plateau clamps to the bounds rather than poking past them -- an authored half-width that would + // reach past Minimum or Maximum is refused at write time, naming the item, rather than silently clamped. + [Theory] + [InlineData(0f, 100f, 10f, 40f)] // low edge: 10 - 20 = -10 < 0 + [InlineData(0f, 100f, 90f, 40f)] // high edge: 90 + 20 = 110 > 100 + public void UpsertRefusesAPlateauThatPokesPastItsBounds(float min, float max, float optimum, float plateau) + { + var root = NewRoot(); + try + { + var catalog = Path.Combine(root, "Aetheria.cc"); + using var cache = AetheriaStores.Open(catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var ex = Assert.Throws(() => cache.Upsert(new GearData + { + Name = "PokingPlateau", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 1, + MinimumTemperature = min, MaximumTemperature = max, OptimalTemperature = optimum, PlateauWidth = plateau + })); + Assert.Contains("PokingPlateau", ex.Message); + } + finally { Directory.Delete(root, true); } + } + // --- The wear lever (Entity.cs UpdatePerformance): inside the plateau the thermal term is zero --- [Fact] diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index d2ed39e82..8db970213 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -532,6 +532,24 @@ public sealed class LoadoutTests : IDisposable Assert.Equal(.2f, items.GetTier(secondInstance).tier.Quality, 3); } + // S7 (docs/stats-and-power-cut.md Cut 1 findings): a Quality term must raise the lot's quality to its own + // declared Exponent, not always to 1. StatsReadTheLot uses Exponent = 1 throughout, which cannot distinguish + // "read the term's exponent" from "always use 1" -- pow(x, 1) == x either way. Exponent = 2 here can: a + // mutation that drops the term's own exponent (using 1f regardless of what is declared) reads .5 instead of + // the correct .25. + [Fact] + public void QualityTermAppliesItsOwnExponent() + { + using var cache = Open(); + var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var lamp = cache.GetByName("Lamp"); + var lotId = items.Lots.Add(new Lot { Design = cache.RefOf(lamp), Origin = new Attributed(), Quality = .5f, Roles = new List() }); + var instance = (EquippableItem) items.CreateInstance(lotId); + + var squared = new PerformanceStat { Min = 0, Max = 1, Terms = { new StatTerm { Source = StatSource.Quality, Exponent = 2 } } }; + Assert.Equal(.25f, items.Evaluate(squared, instance), 3); + } + // F4: GetPrice reads the lot's quality through GameplaySettings.QualityPriceModifier. [Fact] public void GetPriceReadsLotQuality() diff --git a/tests/mutation_tests_stats_power_cut1.py b/tests/mutation_tests_stats_power_cut1.py index e552d5e8d..bc12f37df 100644 --- a/tests/mutation_tests_stats_power_cut1.py +++ b/tests/mutation_tests_stats_power_cut1.py @@ -146,6 +146,83 @@ MUTATIONS: list[Mutation] = [ test="HeatResponseTests.AFastSwingAcrossThePlateauStillWears", expect="red", ), + + # --- Soul's Cut 1 findings (docs/stats-and-power-cut.md), S7: four mutations that survived the original + # --- suite. Each gets a dedicated test here rather than reusing a coincidentally-sensitive one. --- + + # S7: out-of-bounds semantics. The old curve returned 1 at any temperature it had no data for; R-heat's whole + # point is that a design is dead at and beyond its bounds. Restoring the old "1" must fail loudly, at the + # boundary itself and strictly beyond it. + Mutation( + rule="a design must be dead (0), not immune (1), at and beyond its bounds", + file="Assets/Scripts/ServerShared/ItemData.cs", + anchor=" if (temperature <= MinimumTemperature || temperature >= MaximumTemperature) return 0f;", + mutated=" if (temperature <= MinimumTemperature || temperature >= MaximumTemperature) return 1f;", + test="HeatResponseTests.PerformanceIsZeroAtAndBeyondEachBound", + expect="red", + ), + # S7: the low-side plateau clamp -- StatValidation must refuse a plateau whose low edge pokes past + # MinimumTemperature, not just an optimum outside the bounds outright. + Mutation( + rule="StatValidation must refuse a plateau whose low edge pokes past MinimumTemperature", + file="Assets/Scripts/ServerShared/ItemData.cs", + anchor=" if (plateauLow < data.MinimumTemperature || plateauHigh > data.MaximumTemperature)", + mutated=" if (false || plateauHigh > data.MaximumTemperature)", + test="HeatResponseTests.UpsertRefusesAPlateauThatPokesPastItsBounds", + expect="red", + ), + # S7: the high-side plateau clamp, the other half of the same rule. + Mutation( + rule="StatValidation must refuse a plateau whose high edge pokes past MaximumTemperature", + file="Assets/Scripts/ServerShared/ItemData.cs", + anchor=" if (plateauLow < data.MinimumTemperature || plateauHigh > data.MaximumTemperature)", + mutated=" if (plateauLow < data.MinimumTemperature || false)", + test="HeatResponseTests.UpsertRefusesAPlateauThatPokesPastItsBounds", + expect="red", + ), + # S7: a Quality term must raise the lot's quality to its own declared Exponent. StatsReadTheLot uses + # Exponent = 1 throughout (pow(x, 1) == x), which cannot distinguish "read the term's exponent" from "always + # use 1" -- that is exactly how this mutation survived the original suite. + Mutation( + rule="a Quality term must apply its own exponent, not always 1", + file="Assets/Scripts/ServerShared/ItemData.cs", + anchor=" StatSource.Quality => pow(context.Lot.QualityForRole(term.Role), term.Exponent),", + mutated=" StatSource.Quality => pow(context.Lot.QualityForRole(term.Role), 1f),", + test="LoadoutTests.QualityTermAppliesItsOwnExponent", + expect="red", + ), + + # --- S5/S6: the validation gaps Soul found closed. Zero-span and NaN were silently accepted before. --- + Mutation( + rule="StatValidation must refuse a zero-span range (Tractor Beam's exact bug: Min == Max)", + file="Assets/Scripts/ServerShared/ItemData.cs", + anchor=" if (data.MinimumTemperature == data.MaximumTemperature)", + mutated=" if (false)", + test="HeatResponseTests.UpsertRefusesAZeroSpanRange", + expect="red", + ), + Mutation( + rule="StatValidation must refuse NaN in any of the four heat-response fields", + file="Assets/Scripts/ServerShared/ItemData.cs", + anchor=( + " if (float.IsNaN(data.MinimumTemperature) || float.IsNaN(data.MaximumTemperature) ||\n" + " float.IsNaN(data.OptimalTemperature) || float.IsNaN(data.PlateauWidth))" + ), + mutated=" if (false)", + test="HeatResponseTests.UpsertRefusesNaNInAnyHeatResponseField", + expect="red", + ), + # S6: the writable path used to trust Open()'s one-time check; a session that wrote an invalid record and + # committed it produced a catalog that only failed the *next* time somebody reopened it. Mutating away the + # Upsert-time validation call must fail a test that checks the record never reaches disk in the first place. + Mutation( + rule="CultRecordRefs.Upsert must validate an EquippableItemData's heat response before it reaches disk", + file="Assets/Scripts/ServerShared/AetheriaStores.cs", + anchor=" if (document is EquippableItemData data) StatValidation.ValidateHeatResponse(data);", + mutated=" if (false) { }", + test="HeatResponseTests.UpsertRefusesAnOptimumOutsideItsBounds", + expect="red", + ), ]