Aetheria/tests/Aetheria.Shared.Tests/HeatResponseTests.cs
soul cc0c164574 determinism-ledger: every ItemManager states its seed; scatter on the zone's dice
- ItemManager(uint seed, ...) refuses seed 0 and seeds Random from it.
- RunStart.ItemSeed(galaxy, step) is public (formula unchanged), refuses a
  zero result; named steps ArenaStep, StageStep, ContinueStep and NoRunSeed.
- Continue seeds the item stream from the saved run's seed (ContinueStep);
  a scene with no galaxy uses NoRunSeed.
- Zone's unpack scatter draws SimulationDice.For(CombatSeed, ScatterStream,
  1-based pack ordinal), never the item stream.
- Loadout's scratch manager and AetherDb's catalog command take fixed seeds;
  every test and Editor construction gains 1u (mechanical rewrite).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:01:41 +02:00

286 lines
13 KiB
C#

using System;
using System.IO;
using System.Linq;
using CultMath;
using GameCult.Caching;
using Xunit;
// Cut 1 (docs/stats-and-power-cut.md), R-heat: the authored heat-response shape (minimum, maximum, optimum,
// plateau width), its load-time validation, and the wear-lever consequence (the plateau is where Wear's thermal
// term goes to zero). Each rule here has a matching mutation in tests/mutation_tests_stats_power_cut1.py.
public sealed class HeatResponseTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-heatresponse-" + Guid.NewGuid().ToString("N"));
private string Catalog => Path.Combine(_root, "Aetheria.cc");
private static readonly int2 HardpointCell = new int2(0, 0);
public HeatResponseTests() => Directory.CreateDirectory(_root);
public void Dispose() => Directory.Delete(_root, true);
private GameplaySettings Settings() => new GameplaySettings
{
DefaultEntitySettings = new EntitySettings(),
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp()
};
// A hull plus one thermal gear design, equipped and settled at the given start temperature (two
// UpdatePerformance calls so deltaTemp reads 0 before the test drives its own change).
private EquippedItem BuildEquippedAt(CultCache cache, ItemManager items, float startTemperature, out Ship ship, Lot gearLot = null)
{
var maker = cache.RefOf(cache.GetByName<Faction>("Maker"));
var hullData = cache.GetByName<HullData>("Skiff");
var thermalData = cache.GetByName<GearData>("Thermal");
var hullItem = (EquippableItem) items.CreateInstance(items.CreateLot(hullData, maker, .5f));
ship = new Ship(items, null, hullItem, new EntitySettings());
var gearItem = (EquippableItem) items.CreateInstance(gearLot != null ? items.Lots.Add(gearLot) : items.CreateLot(thermalData, maker, .5f));
Assert.True(ship.TryEquip(gearItem, HardpointCell));
var equipped = ship.Equipment.Single(e => e.Data is GearData);
foreach (var cell in equipped.InsetShape.Coordinates) ship.Temperature[cell.x, cell.y] = startTemperature;
equipped.UpdatePerformance();
equipped.UpdatePerformance(); // settle: oldTemperature now equals startTemperature, deltaTemp reads 0
return equipped;
}
private void SetTemperature(EquippedItem equipped, Ship ship, float temperature)
{
foreach (var cell in equipped.InsetShape.Coordinates) ship.Temperature[cell.x, cell.y] = temperature;
}
private CultCache OpenCatalogWithThermalGear()
{
var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
cache.Upsert(new VerseGrammar { Revision = 1 });
cache.Upsert(new Faction { Name = "Maker", ShortName = "MKR" });
var hullShape = new Shape(3, 3);
foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true;
cache.Upsert(new HullData
{
Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Price = 100,
Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() } }
});
// Plateau 40-60 of a 0-100 range: full performance across it, linear falloff to the bounds outside it.
cache.Upsert(new GearData
{
Name = "Thermal", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10,
Durability = 100, MinimumTemperature = 0, MaximumTemperature = 100,
OptimalTemperature = 50, PlateauWidth = 20
});
cache.FlushAsync().Wait();
return cache;
}
// --- R-heat shape: Performance() is 1 across the plateau and falls linearly to 0 at the bounds ---
[Fact]
public void PerformanceIsFullAcrossThePlateau()
{
using var cache = OpenCatalogWithThermalGear();
var data = cache.GetByName<GearData>("Thermal");
Assert.Equal(1f, data.Performance(40), 5);
Assert.Equal(1f, data.Performance(50), 5);
Assert.Equal(1f, data.Performance(60), 5);
}
[Fact]
public void PerformanceFallsLinearlyOutsideThePlateau()
{
using var cache = OpenCatalogWithThermalGear();
var data = cache.GetByName<GearData>("Thermal");
// Halfway from the plateau's low edge (40) down to the minimum (0) -> halfway performance.
Assert.Equal(.5f, data.Performance(20), 5);
Assert.Equal(0f, data.Performance(0), 5);
Assert.Equal(0f, data.Performance(100), 5);
}
// 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 PerformanceIsZeroAtAndBeyondEachBound()
{
using var cache = OpenCatalogWithThermalGear();
var data = cache.GetByName<GearData>("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);
cache.Upsert(new VerseGrammar { Revision = 1 });
var ex = Assert.Throws<InvalidOperationException>(() => cache.Upsert(new GearData
{
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<GearData>().SingleOrDefault(g => g.Name == "BadOptimum"));
}
finally { Directory.Delete(root, true); }
}
[Fact]
public void UpsertRefusesANegativePlateauWidth()
{
var root = NewRoot();
try
{
var catalog = Path.Combine(root, "Aetheria.cc");
using var cache = AetheriaStores.Open(catalog, catalogWritable: true);
cache.Upsert(new VerseGrammar { Revision = 1 });
var ex = Assert.Throws<InvalidOperationException>(() => cache.Upsert(new GearData
{
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 VerseGrammar { Revision = 1 });
var ex = Assert.Throws<InvalidOperationException>(() => 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 VerseGrammar { Revision = 1 });
var ex = Assert.Throws<InvalidOperationException>(() => 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 VerseGrammar { Revision = 1 });
var ex = Assert.Throws<InvalidOperationException>(() => 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]
public void HeldInsidePlateauTakesNoThermalWear()
{
using var cache = OpenCatalogWithThermalGear();
var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { });
var equipped = BuildEquippedAt(cache, items, 50f, out var ship);
Assert.Equal(1f, equipped.ThermalPerformance, 5);
Assert.Equal(0f, equipped.Wear, 5); // no thermal term (Performance == 1) and no deltaTemp (settled)
}
[Fact]
public void HeldOutsidePlateauTakesThermalWear()
{
using var cache = OpenCatalogWithThermalGear();
var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { });
var equipped = BuildEquippedAt(cache, items, 10f, out var ship); // outside 40-60, inside 0-100
Assert.True(equipped.ThermalPerformance < 1f);
Assert.True(equipped.Wear > 0f);
}
// Wear's quality term reads the lot's effective quality: a .9 lot whose one part is .1 wears as a roleless .1 lot, and not
// as a roleless .9 lot, which wears less (better quality wears less).
[Fact]
public void HeldOutsidePlateauWearReadsEffectiveQuality()
{
using var cache = OpenCatalogWithThermalGear();
var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { });
var thermal = cache.RefOf<ItemData>(cache.GetByName<GearData>("Thermal"));
Lot Gear(float assembly, params float[] parts) => new Lot
{
Design = thermal, Origin = new Attributed(), Quality = assembly,
Roles = parts.Select(p => new RoleFill { Role = "core", Quality = p }).ToList()
};
float WearAt10(Lot lot) => BuildEquippedAt(cache, items, 10f, out _, lot).Wear;
var sleeper = WearAt10(Gear(.9f, .1f));
Assert.Equal(WearAt10(Gear(.1f)), sleeper, 5);
Assert.NotEqual(WearAt10(Gear(.9f)), sleeper, 5);
Assert.True(WearAt10(Gear(.9f)) < sleeper);
}
[Fact]
public void AFastSwingAcrossThePlateauStillWears()
{
using var cache = OpenCatalogWithThermalGear();
var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { });
var equipped = BuildEquippedAt(cache, items, 10f, out var ship);
// Jump straight into the plateau in one tick: ThermalPerformance ends at 1 (no thermal term), but the
// swing itself (deltaTemp) still costs.
SetTemperature(equipped, ship, 50f);
equipped.UpdatePerformance();
Assert.Equal(1f, equipped.ThermalPerformance, 5);
Assert.True(equipped.Wear > 0f);
}
}