Aetheria/tests/Aetheria.Shared.Tests/BrownoutTests.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

470 lines
26 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using CultMath;
using GameCult.Caching;
using Xunit;
using static CultMath.math;
// Cut 7 (docs/stats-and-power-target.md): "Continuous consumers brown out through a power supply curve on
// their performance stats. An exponent is enough." Cut 6 wired the curve itself (PowerCurveTests.cs) and the
// bus already writes a real fractional grant (PowerBusTests.cs, PowerTierTests.cs), but every continuous
// consumer still gated its own effect on Item.PowerSupply >= 1f (Thruster.Execute,
// Radiator.Execute, ConstantWeapon.Execute, EnergyDraw.Execute) -- so a partial grant read exactly like none,
// the flicking-off bug the ruling names. This file pins each behaviour's own gate removal: a partial grant now
// produces a reduced effect, a full grant is unchanged, a zero grant still produces nothing, the degradation
// follows the authored exponent (not accidentally linear), and an instant item (InputCapacitor's all-or-nothing
// rule, Cut 4) is untouched by any of it. Each rule has a matching mutation in
// tests/mutation_tests_stats_power_cut7.py.
//
// F6 (docs/stats-and-power-cut.md, Soul pass 2026-09-19) surfaced a real conflict this file's Radiator
// fixture happened to sit on top of, unnoticed because the pre-F6 registry only named each
// consumer's own top-level request field. Radiator.PowerRequest's own early-out reads PumpedHeat AND WasteHeat
// (to decide whether the pump can keep up this tick). Cut 7 asks for the brownout curve to live on exactly
// the stats the behaviour actually curves (PumpedHeat), but F6 forbade a PowerSupply term on any
// stat a request reads at all, on pain of the exact oscillation Soul measured against the shipped catalog's own
// "Heat Pump" radiator (SOUL_RadiatorRequestDependsOnItsOwnGrantAndOscillates, docs/stats-and-power-cut.md).
// The six tests below that authored one were marked Skip rather than deleted or silently reworked to hide the
// gap, naming the conflict as real, scoped work for a future pass rather than a fix a registry correction should
// smuggle in.
//
// Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19) is that future pass: a power
// request is evaluated nominally -- what the item wants at full supply, not what it is currently managing
// (EquippedItem.EvaluateNominalPower, Entity.cs). Radiator.PowerRequest now reads
// PumpedHeat (and every other field in StatValidation.PowerRequestFields) through that nominal read, so
// the same stat can carry a PowerSupply term (curved for Execute's real effect) AND be a request field, without
// the request depending on its own answer -- there is no more oscillation to reproduce, because the request no
// longer reads the live grant at all. StatValidation.ValidateNoPowerSupplyOnRequest (the static check F6 added
// to forbid this) is deleted; only the dynamic modifier-chain half (StatModifier.ValidateNoPowerSupplyChain)
// still refuses anything, and it was never what these six tests tripped. Un-skipped below. Thruster and
// ConstantWeapon were never affected by F6 -- their own PowerRequest reads only EnergyUsage/Energy, never the
// separate Thrust/Damage field their tests curve -- but now also read that field nominally, for the same reason
// every other IPowerConsumer does.
public sealed class BrownoutTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-brownout-" + Guid.NewGuid().ToString("N"));
private string Catalog => Path.Combine(_root, "Aetheria.cc");
public BrownoutTests() => Directory.CreateDirectory(_root);
public void Dispose() => Directory.Delete(_root, true);
private static GameplaySettings Settings() => new GameplaySettings
{
DefaultEntitySettings = new EntitySettings(),
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp()
};
private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v };
// Min=0 (not Min=Max like Constant) is the point: a term can only ever be observed to degrade something
// when the stat actually has room to move between its floor and its authored ceiling.
private static PerformanceStat Curved(float max, float exponent) =>
new PerformanceStat { Min = 0, Max = max, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = exponent } } };
private static EquippableItem Mint(CultCache cache, ItemManager items, ItemData design) => new EquippableItem
{
Data = cache.RefOf<ItemData>(design),
Durability = design is EquippableItemData equippable ? equippable.Durability : 0,
Lot = items.Lots.Add(new Lot { Design = cache.RefOf<ItemData>(design), Origin = new Attributed(), Quality = .5f, Roles = new List<RoleFill>() })
};
// PowerBusTests/PowerCurveTests' own fixture: a 5x5 hull, a Reactor with no capacitor on the ship (so this
// tick's grant ratio is a clean min(1, generation/demand), nothing smoothed by stored charge), and one named
// consumer behaviour under test.
private CultCache OpenCatalog(BehaviorData consumer)
{
var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
cache.Upsert(new VerseGrammar { Revision = 1 });
var hullShape = new Shape(5, 5);
foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true;
cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10, Mass = 1000 });
cache.Upsert(new GearData
{
Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400,
Behaviors = { new ReactorData
{
Charge = Constant(0), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2)
} }
});
cache.Upsert(new GearData
{
Name = "Consumer", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400,
Behaviors = { consumer }
});
cache.FlushAsync().Wait();
return cache;
}
private Ship BuildShip(CultCache cache, float reactorCharge)
{
var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { });
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null);
var hull = Mint(cache, items, cache.GetByName<HullData>("Skiff"));
var ship = new Ship(items, zone, hull, new EntitySettings());
var reactorData = cache.GetByName<GearData>("Reactor");
((ReactorData) reactorData.Behaviors[0]).Charge = Constant(reactorCharge);
Assert.True(ship.TryEquip(Mint(cache, items, reactorData)));
// The Skiff's centre column: a thruster there has no torque, so the allocator can fire it alone. These tests pin power,
// not flight, and a lone off-centre thruster could not fire without turning.
Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName<GearData>("Consumer")), new int2(2, 1)));
zone.Admit(ship, piloted: false);
// Ship.Turn stays 0, so Ship.Update commands no rotation: no incidental torque thrust from the
// ship trying to turn. Aim steers nothing, so this one is only a plausible aim.
ship.Aim = float3(0, 0, 1);
return ship;
}
// --- Thruster: a reverse thruster (default EquippableItem.Rotation == None) driven by MovementDirection.y
// --- (Ship.cs's own axis pass), producing a measurable, unidirectional velocity change with no rotation
// --- component to disentangle (Velocity is updated from the pre-rotation Direction within the same tick). ---
private (float velocityDelta, Thruster thruster) RunThruster(float reactorCharge, float exponent = 1)
{
using var cache = OpenCatalog(new ThrusterData
{
Thrust = Curved(100, exponent), Visibility = Constant(0), Heat = Constant(0), EnergyUsage = Constant(100)
});
var ship = BuildShip(cache, reactorCharge);
Assert.True(ship.GetBehavior<Thruster>().Torque == 0f, "the lone thruster turns nothing");
ship.Equipment.Single(e => e.Data.Name == "Consumer").UpdatePerformance(); // live from its first performance update
ship.MovementDirection = float2(0, -1); // reverse-thruster axis += -MovementDirection.y == 1
ship.Update(1f);
return (-ship.Velocity.y, ship.GetBehavior<Thruster>());
}
// A throttle the old .01 gate cut off still asks for its share of the power (the allocator balances a drive pair
// with throttles of a thousandth).
[Fact]
public void ASmallThrottleStillAsksForItsPower()
{
using var cache = OpenCatalog(new ThrusterData
{
Thrust = Constant(100), Visibility = Constant(0), Heat = Constant(0), EnergyUsage = Constant(100)
});
var thruster = BuildShip(cache, reactorCharge: 1000).GetBehavior<Thruster>();
thruster.Axis = .005f;
Assert.Equal(.5f, thruster.PowerRequest(1f), 4);
}
// The evasion envelope is the box extremes of the columns at live, granted thrust (cut envelope-from-columns): the
// allocator solves with the full-grant column, the envelope reports what the grant lets the ship do. A reverse
// thruster at half grant adds half its nominal push to the envelope's reverse; with no grant at all it adds nothing
// even when its thrust stat carries no power term, because an unpowered thruster does not fire.
[Fact]
public void TheEnvelopeIsWhatTheGrantAllows()
{
using var cache = OpenCatalog(new ThrusterData
{
Thrust = Curved(100, 1), Visibility = Constant(0), Heat = Constant(0), EnergyUsage = Constant(100)
});
var ship = BuildShip(cache, reactorCharge: 50);
var thruster = ship.GetBehavior<Thruster>();
ship.Equipment.Single(e => e.Data.Name == "Consumer").UpdatePerformance();
ship.MovementDirection = float2(0, -1);
ship.Update(1f);
ship.Update(1f); // the envelope is derived before the power bus steps, from the grant the last tick left
Assert.True(thruster.Thrust > 1f && thruster.Thrust < .9f * thruster.NominalThrust, "fixture: the grant is partial");
var expected = thruster.Thrust / ship.Mass;
Assert.InRange(ship.Envelope.Reverse, expected * .9999f, expected * 1.0001f);
Assert.Equal(0f, ship.Envelope.Forward);
}
[Fact]
public void AnUnpoweredThrusterAddsNothingToTheEnvelope()
{
using var cache = OpenCatalog(new ThrusterData
{
Thrust = Constant(100), Visibility = Constant(0), Heat = Constant(0), EnergyUsage = Constant(100)
});
var ship = BuildShip(cache, reactorCharge: 0);
var thruster = ship.GetBehavior<Thruster>();
ship.MovementDirection = float2(0, -1);
ship.Update(1f);
ship.Update(1f);
Assert.Equal(0f, thruster.Item.PowerSupply, 3);
Assert.True(thruster.Thrust > 1f, "fixture: the stat carries no power term, so the thruster's stat is not zero");
Assert.Equal(0f, ship.Envelope.Reverse);
}
// TA6: a column promises what the thruster does at a full grant, never what the grant currently allows. Mutations:
// the allocator reads Column(Thrust) (live, power-curved) for Column(NominalThrust), and Column returns nothing
// while the grant is nil. With no power at all the thruster's live thrust is zero, yet the allocator still asks
// it for its throttle (Execute then gates on the grant, and the thruster does nothing).
[Fact]
public void ATotalBrownoutStillAllocatesTheThrusterItsNominalColumn()
{
using var cache = OpenCatalog(new ThrusterData
{
Thrust = Curved(100, 1), Visibility = Constant(0), Heat = Constant(0), EnergyUsage = Constant(100)
});
var ship = BuildShip(cache, reactorCharge: 0);
var thruster = ship.GetBehavior<Thruster>();
ship.MovementDirection = float2(0, -1);
ship.Update(1f);
ship.Update(1f);
Assert.Equal(0f, thruster.Item.PowerSupply, 3);
Assert.Equal(0f, thruster.Thrust, 3);
Assert.True(thruster.Column(thruster.NominalThrust).y < 0f, "the nominal column pushes the reverse thruster's way");
Assert.True(thruster.Axis > .5f, "the allocator asks for the throttle whatever the grant");
}
[Fact]
public void ThrusterAtHalfGrantProducesReducedNotZeroNotFullThrust()
{
var (full, _) = RunThruster(reactorCharge: 1000); // demand 100, generation far exceeds it -> ratio 1
var (half, _) = RunThruster(reactorCharge: 50); // demand 100, generation 50 -> ratio .5
Assert.True(half > 0f && half < full, $"half={half} should sit strictly between 0 and full={full}");
}
[Fact]
public void ThrusterAtFullGrantIsUnchangedFromToday()
{
var (full, thruster) = RunThruster(reactorCharge: 1000);
Assert.Equal(1f, thruster.Item.PowerSupply, 3);
Assert.Equal(.1f, full, 3); // 100 thrust / 1000 mass * dt 1, exactly as it read before this cut
}
[Fact]
public void ThrusterAtZeroGrantProducesNothing()
{
var (zero, thruster) = RunThruster(reactorCharge: 0);
Assert.Equal(0f, thruster.Item.PowerSupply, 3);
Assert.Equal(0f, zero, 3);
}
// --- The degradation must follow the authored exponent, not the linear response a careless "just multiply
// --- by PowerSupply" implementation (or a mutation dropping the exponent) would produce. Mirrors
// --- PowerCurveTests' own exponent-2 bullet, but through a real gated behaviour instead of an inert stat. ---
[Fact]
public void ThrusterAtHalfGrantDegradesByItsAuthoredExponentNotLinearly()
{
var (half, _) = RunThruster(reactorCharge: 50, exponent: 2);
// Thrust = lerp(0, 100, pow(.5, 2)) = 25; velocity delta = 25 / 1000 mass * dt 1 = .025, not the
// linear-response .05 a dropped exponent would produce.
Assert.Equal(.025f, half, 3);
}
// --- Radiator: no explicit power gate survives Cut 7 at all -- PumpedHeat above is a plain Evaluate() read,
// --- so a curved PumpedHeat already pumps less under a partial grant, and waste heat is unaffected, so a
// --- starved radiator falls behind (the reduced-performance failure the ruling asks for) instead of the
// --- pump simply refusing to run. Measured via RadiatorTemperature's own rise, which is exactly
// --- pumpedHeat / ThermalMass * dt. ---
private (float temperatureDelta, Radiator radiator) RunRadiator(float reactorCharge, float exponent = 1)
{
using var cache = OpenCatalog(new RadiatorData
{
Emissivity = Constant(0), PumpedHeat = Curved(10, exponent), TemperatureFloor = 0,
WasteHeat = Constant(.1f), EnergyUsage = Constant(50), ThermalMass = Constant(1000)
});
var ship = BuildShip(cache, reactorCharge);
var radiator = ship.GetBehavior<Radiator>();
var before = radiator.RadiatorTemperature;
ship.Update(1f);
return (radiator.RadiatorTemperature - before, radiator);
}
// Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19, see file header):
// PumpedHeat and WasteHeat are registered Radiator request fields (PowerRequest's own early-out reads both),
// but PowerRequest now reads them through EvaluateNominalPower, so Curved(...) on PumpedHeat is a legal
// catalog shape again -- Execute's own real, curved Evaluate is what actually degrades the pumping below.
[Fact]
public void RadiatorAtHalfGrantPumpsReducedNotZeroNotFullHeat()
{
var (full, _) = RunRadiator(reactorCharge: 1000); // demand 50, generation far exceeds it -> ratio 1
var (half, _) = RunRadiator(reactorCharge: 25); // demand 50, generation 25 -> ratio .5
Assert.True(half > 0f && half < full, $"half={half} should sit strictly between 0 and full={full}");
}
[Fact]
public void RadiatorAtFullGrantIsUnchangedFromToday()
{
var (full, radiator) = RunRadiator(reactorCharge: 1000);
Assert.Equal(1f, radiator.Item.PowerSupply, 3);
Assert.True(full > 0f);
}
[Fact]
public void RadiatorAtZeroGrantProducesNothing()
{
var (zero, radiator) = RunRadiator(reactorCharge: 0);
Assert.Equal(0f, radiator.Item.PowerSupply, 3);
Assert.Equal(0f, zero, 4);
}
// --- ConstantWeapon: the exact bug the ruling names -- a partial grant used to safe the weapon off
// --- entirely (_firing = false), not merely fire it for less. base.Execute(dt) re-reads Damage through
// --- Evaluate() every tick regardless of _firing, so a curved Damage stat already degrades; this proves the
// --- weapon also keeps firing (Firing stays true) at a partial grant, and only a true-zero grant stops it. ---
private (float damage, bool firing) RunConstantWeapon(float reactorCharge, float exponent = 1)
{
using var cache = OpenCatalog(new ConstantWeaponData
{
DamageType = DamageType.Kinetic, Damage = Curved(100, exponent), Penetration = Constant(0),
DamageSpread = Constant(0), MinRange = Constant(0), Range = Constant(0), Energy = Constant(50),
Heat = Constant(0), Visibility = Constant(0), MagazineSize = 0
});
var ship = BuildShip(cache, reactorCharge);
var weapon = ship.GetBehavior<ConstantWeapon>();
weapon.Activate();
ship.Update(1f);
return (weapon.Damage, weapon.Firing);
}
[Fact]
public void ConstantWeaponAtHalfGrantKeepsFiringWithReducedDamage()
{
var (full, fullFiring) = RunConstantWeapon(reactorCharge: 1000); // demand 50, ratio 1
var (half, halfFiring) = RunConstantWeapon(reactorCharge: 25); // demand 50, ratio .5
Assert.True(fullFiring);
Assert.True(halfFiring); // the flicking-off bug: this used to be false
Assert.True(half > 0f && half < full, $"half={half} should sit strictly between 0 and full={full}");
}
[Fact]
public void ConstantWeaponAtFullGrantIsUnchangedFromToday()
{
var (full, firing) = RunConstantWeapon(reactorCharge: 1000);
Assert.True(firing);
Assert.Equal(100f, full, 3);
}
[Fact]
public void ConstantWeaponAtZeroGrantStopsFiringAndProducesNothing()
{
var (zero, firing) = RunConstantWeapon(reactorCharge: 0);
Assert.False(firing);
Assert.Equal(0f, zero, 3);
}
[Fact]
public void ConstantWeaponDamageDegradesByItsAuthoredExponentNotLinearly()
{
var (half, firing) = RunConstantWeapon(reactorCharge: 25, exponent: 2);
Assert.True(firing);
Assert.Equal(25f, half, 3); // lerp(0, 100, pow(.5, 2)) == 25, not the linear-response 50
}
// --- EnergyDraw: named explicitly (docs/stats-and-power-target.md) as the one continuous consumer whose
// --- effect is not a performance stat at all -- its Execute return value IS the effect (Entity.cs's
// --- per-BehaviorGroup Execute chain reads it to decide whether later behaviours in the group run). There
// --- is nothing here for a curve to degrade continuously, so a partial grant still passes and only a true
// --- zero grant closes the gate. Execute is public and side-effect-free (a plain read of Item.PowerSupply),
// --- so it is safe to call directly for inspection after the same tick the group loop already ran it in. ---
private bool RunEnergyDraw(float reactorCharge)
{
using var cache = OpenCatalog(new EnergyDrawData { EnergyDraw = Constant(50), PerSecond = true });
var ship = BuildShip(cache, reactorCharge);
ship.Update(1f);
return ship.GetBehavior<EnergyDraw>().Execute(1f);
}
[Fact]
public void EnergyDrawAtHalfGrantStillPasses()
{
Assert.True(RunEnergyDraw(reactorCharge: 25)); // demand 50, ratio .5 -- used to fail this gate entirely
}
[Fact]
public void EnergyDrawAtFullGrantPasses()
{
Assert.True(RunEnergyDraw(reactorCharge: 1000));
}
[Fact]
public void EnergyDrawAtZeroGrantFails()
{
Assert.False(RunEnergyDraw(reactorCharge: 0));
}
// --- Regression: Cut 4's InputCapacitor all-or-nothing rule for instant items must survive Cut 7 completely
// --- untouched -- an input capacitor half full still refuses a whole-shot spend, because InstantWeapon,
// --- Sensor and Shield are unaffected by anything in this cut. Unit-level (no Ship/catalog needed at all):
// --- InputCapacitor is a plain class. ---
[Fact]
public void AnInputCapacitorStillRefusesToSpendOnAPartialCharge()
{
var capacitor = new InputCapacitor();
capacitor.UpdateStats(energy: 10, cooldown: 1); // Capacity = 10, Rate = 10
capacitor.AddCharge(5); // half charge -- real, but not a whole shot
Assert.True(capacitor.CanSpend(5));
Assert.False(capacitor.TrySpend(10)); // the whole-shot cost must still be refused outright
capacitor.AddCharge(5); // now full
Assert.True(capacitor.TrySpend(10)); // and only now does it fire, for the whole cost
}
// --- Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19): a direct probe of
// --- EquippedItem.EvaluateNominalPower itself, decoupled from any behaviour's own physical state (Radiator's
// --- temperature) so the only thing that can make it diverge from the ordinary Evaluate
// --- is the item's own PowerSupply grant. Thruster's EnergyUsage carries the term here purely as a probe --
// --- not a shape BrownoutTests' other fixtures author -- because Thruster has no other tick-to-tick state
// --- that could confound the comparison the way Radiator's RadiatorTemperature would.
// --- Backs tests/mutation_tests_stats_power_nominal_request.py's own mutation of this exact method. ---
[Fact]
public void EvaluateNominalPowerIgnoresTheItemsCurrentPowerSupplyGrant()
{
var energyUsage = Curved(100, exponent: 1);
using var cache = OpenCatalog(new ThrusterData
{
Thrust = Constant(100), Visibility = Constant(0), Heat = Constant(0), EnergyUsage = energyUsage
});
var ship = BuildShip(cache, reactorCharge: 25); // demand 100 (nominal), generation 25 -> ratio .25
// The allocator fires a thruster that can push; it is live from its first performance update.
ship.Equipment.Single(e => e.Data.Name == "Consumer").UpdatePerformance();
ship.MovementDirection = float2(0, -1);
ship.Update(1f);
var thruster = ship.GetBehavior<Thruster>();
// The fixture must actually land a partial grant, or this probe proves nothing.
Assert.True(thruster.Item.PowerSupply > 0f && thruster.Item.PowerSupply < 1f,
$"expected a partial grant, got {thruster.Item.PowerSupply}");
Assert.Equal(100f, thruster.Item.EvaluateNominalPower(energyUsage), 3); // full-supply value, always
Assert.True(thruster.Item.Evaluate(energyUsage) < 100f); // the real, curved value the grant actually shrank
}
// --- Wiring regression for Radiator.PowerRequest specifically: EvaluateNominalPowerIgnoresTheItemsCurrentPowerSupplyGrant
// --- above proves the underlying mechanism, but a single ship.Update never actually exercises it here --
// --- PumpedHeat/WasteHeat's own ratio (100/5, nominally) so outstrips tempRatio (pinned at 1 on a radiator
// --- that started at ambient) that a single tick's PowerRequest returns the same gate-open answer whether or
// --- not PowerRequest reads PumpedHeat nominally. Two ticks exposes it: tick 1 is identical either way (the
// --- item's PowerSupply has not been written yet, so Evaluate and EvaluateNominalPower agree), landing the
// --- same partial grant (ratio1 = 20/50 = .4) and so the same RadiatorTemperature going into tick 2 in both
// --- cases. Only starting at tick 2's own PowerRequest can the two implementations diverge: nominally,
// --- PumpedHeat/WasteHeat is still 10/5 = 2, comfortably above tempRatio, so tick 2 still requests (demand
// --- unchanged from tick 1); read through the ordinary curved Evaluate instead, PumpedHeat is now
// --- pow(.4, 1)*10 = 4, so PumpedHeat/WasteHeat drops to 4/5 = .8, BELOW tempRatio -- PowerRequest's own
// --- early-out fires and demand collapses to 0. A wiring regression (PowerRequest reverted to the ordinary
// --- Evaluate) is exactly what tests/mutation_tests_stats_power_nominal_request.py mutates and this catches.
[Fact]
public void RadiatorSecondTickDemandIsUnaffectedByTheFirstTicksPartialGrant()
{
using var cache = OpenCatalog(new RadiatorData
{
Emissivity = Constant(0), PumpedHeat = Curved(10, exponent: 1), TemperatureFloor = 0,
WasteHeat = Constant(5), EnergyUsage = Constant(50), ThermalMass = Constant(1000)
});
var ship = BuildShip(cache, reactorCharge: 20); // demand 50 (nominal, both ticks) -> ratio1 = .4
ship.Update(1f);
var firstDemand = ship.PowerBus.TotalDemand;
Assert.Equal(50f, firstDemand, 3); // tick 1: PowerSupply defaults to 1, so both readings agree
ship.Update(1f);
var secondDemand = ship.PowerBus.TotalDemand;
// Still the gate-open, EnergyUsage*tempRatio figure (tempRatio has crept up slightly from the radiator's
// own tick-1 heating, which is real and expected -- not the gate-closed 0 a reverted, non-nominal
// PowerRequest would produce once Item.PowerSupply < 1 curves PumpedHeat/WasteHeat below tempRatio).
Assert.True(secondDemand > 0f,
$"expected tick 2's gate to stay open on the nominal PumpedHeat/WasteHeat ratio, got demand {secondDemand}");
}
}