- 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>
415 lines
25 KiB
C#
415 lines
25 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using CultMath;
|
|
using GameCult.Caching;
|
|
using Xunit;
|
|
|
|
// Cut 6 (docs/stats-and-power-cut.md): "the power-supply term and the request-independence rule." Purely
|
|
// additive -- StatSource.PowerSupply already existed as an identity everywhere (Cut 2), and the bus already wrote
|
|
// EquippedItem.PowerSupply (Cut 3/5). This file pins the two things Cut 6 actually adds: a stat that declares a
|
|
// PowerSupply term now really degrades as the grant falls, and a stat that decides a power request can never
|
|
// have its OWN evaluation depend on that same term. Each rule has a matching mutation in
|
|
// tests/mutation_tests_stats_power_cut6.py.
|
|
//
|
|
// Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19) narrowed the second half: a
|
|
// PowerRequest implementation now reads a request field through EquippedItem.EvaluateNominalPower (Entity.cs),
|
|
// which pins that stat's own PowerSupplyFactor to 1 -- so a direct PowerSupply term on a request field can no
|
|
// longer make the request depend on its own answer, and StatValidation.ValidateNoPowerSupplyOnRequest (the
|
|
// static check that used to refuse it) is deleted; the "OpenAccepts.../UpsertAccepts..." tests below pin that.
|
|
// What EvaluateNominalPower does NOT pin is ScaleModifier/ConstantModifier (forwarded to the item's real,
|
|
// non-nominal resolver entries, same as ConditionRatio's own NominalContext) -- a modifier CHAIN that reaches a
|
|
// power-tainted magnitude stat still corrupts a nominal read too, and that half
|
|
// (StatModifier.ValidateNoPowerSupplyChain) is unchanged; ActivateRefusesAModifierChainThatWouldCorruptAPowerRequest
|
|
// below still pins it.
|
|
public sealed class PowerCurveTests : IDisposable
|
|
{
|
|
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-powercurve-" + Guid.NewGuid().ToString("N"));
|
|
private string Catalog => Path.Combine(_root, "Aetheria.cc");
|
|
private static readonly int2 HardpointCell = new int2(0, 0);
|
|
private static readonly int2 SecondHardpointCell = new int2(1, 0);
|
|
|
|
public PowerCurveTests() => 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 };
|
|
|
|
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>() })
|
|
};
|
|
|
|
// Mirrors PowerBusTests/PowerTierTests' own fixture: a 5x5 hull (a real interior for Tool-hardpoint items),
|
|
// wide heat bounds around the default start temperature so ThermalOnline never has to be driven separately,
|
|
// and a Reactor whose Charge each test sets directly (dt=1 in every Update call here, so reactorCharge reads
|
|
// straight as this tick's generation, same as PowerBusTests). The consumer is an EnergyDrawData -- exactly
|
|
// PowerBusTests' own "Drain" -- rather than Thruster: Thruster's own request is gated on its Axis, which Ship
|
|
// itself recomputes from MovementDirection every tick (Ship.cs, its own thruster-axis pass), so an axis set
|
|
// directly on the behaviour is clobbered before PowerBus.Step ever reads it. EnergyDraw's request is a plain
|
|
// authored constant, with nothing else deciding it -- deterministic demand is the whole point of this
|
|
// fixture, not thruster flight. curveStat is a second PerformanceStat on the same item (CapacitorData's own
|
|
// Capacity, which no IPowerConsumer ever requests through) declaring the PowerSupply term under test: reading
|
|
// it through the item's Evaluate proves the resolver's own curve, independent of what any particular
|
|
// behaviour does with a partial grant.
|
|
private CultCache OpenCatalog(PerformanceStat curveStat)
|
|
{
|
|
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 = "Engine", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
|
|
MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400,
|
|
Behaviors =
|
|
{
|
|
new EnergyDrawData { EnergyDraw = Constant(100), PerSecond = true },
|
|
new CapacitorData { Capacity = curveStat, Efficiency = Constant(1) }
|
|
}
|
|
});
|
|
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)));
|
|
Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName<GearData>("Engine"))));
|
|
|
|
zone.Admit(ship, piloted: false);
|
|
return ship;
|
|
}
|
|
|
|
// --- Cut 6 verification bullet 1: "a thruster at half grant produces less thrust, by the authored exponent" --
|
|
// --- generalised to any PowerSupply-termed stat, since the resolver does not care which behaviour declared
|
|
// --- it. curveStat's exponent-2 term must degrade to pow(.5, 2) == .25 of its range, not the linear .5 a
|
|
// --- careless implementation (or a mutation that drops the exponent) would produce.
|
|
// ---
|
|
// --- Reads the stat once at full grant BEFORE dropping the reactor's own charge, so the resolver has a real
|
|
// --- cached (generation-stamped) value in hand when the grant actually changes -- a fixture that only ever
|
|
// --- reads once, after the fact, can never tell "recomputed correctly" from "never cached, so it happened to
|
|
// --- read fresh": the missing invalidate half of this cut's own wiring (PowerBus.AllocateTiers) only shows up
|
|
// --- against a stat that was already cached under the old grant. ---
|
|
[Fact]
|
|
public void APowerSupplyTermedStatAtHalfGrantDegradesByItsAuthoredExponent()
|
|
{
|
|
var curve = new PerformanceStat { Min = 0, Max = 100, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 2 } } };
|
|
using var cache = OpenCatalog(curve);
|
|
var ship = BuildShip(cache, reactorCharge: 1000); // over-supplied: ratio 1, establishes a real cache entry
|
|
|
|
ship.Update(1f);
|
|
var engine = ship.Equipment.Single(e => e.Data.Name == "Engine");
|
|
Assert.Equal(1f, engine.PowerSupply, 3);
|
|
Assert.Equal(100f, engine.Evaluate(curve), 3); // cached at full grant
|
|
|
|
var reactor = (ReactorData) ship.Equipment.Single(e => e.Data.Name == "Reactor").Data.Behaviors[0];
|
|
reactor.Charge = Constant(50); // request 100, generation 50 -> ratio .5
|
|
ship.Update(1f);
|
|
|
|
Assert.Equal(100f, ship.PowerBus.TotalDemand, 3);
|
|
Assert.Equal(.5f, engine.PowerSupply, 3);
|
|
Assert.Equal(25f, engine.Evaluate(curve), 2); // lerp(0, 100, pow(.5, 2)) == 25, not the stale 100
|
|
}
|
|
|
|
// --- The other half of the same rule, named explicitly in the ruling ("unchanged at full supply"): a
|
|
// --- PowerSupply term is the identity at grant 1 regardless of its exponent -- pow(1, n) == 1 -- so an item
|
|
// --- that is never starved reads exactly as if it had never declared the term. ---
|
|
[Fact]
|
|
public void APowerSupplyTermedStatAtFullGrantIsUnaffectedByItsExponent()
|
|
{
|
|
var curve = new PerformanceStat { Min = 0, Max = 100, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 5 } } };
|
|
using var cache = OpenCatalog(curve);
|
|
var ship = BuildShip(cache, reactorCharge: 1000); // generation far exceeds the 100 request
|
|
|
|
ship.Update(1f);
|
|
|
|
var engine = ship.Equipment.Single(e => e.Data.Name == "Engine");
|
|
Assert.Equal(1f, engine.PowerSupply, 3);
|
|
Assert.Equal(100f, engine.Evaluate(curve), 3);
|
|
}
|
|
|
|
// --- F1 (docs/stats-and-power-cut.md, operator ruling 2026-09-19): "power supply multiplies, it does not
|
|
// --- interpolate." Before this fix PowerSupply was one more term feeding PerformanceStat's Min/Max lerp, so
|
|
// --- zero supply bottomed out at Min, not 0 -- Soul measured a radiator still pumping 19-25% of full heat at
|
|
// --- zero power. Min here is 1000 (nonzero), so lerp(1000, 4000, pow(0, 2)) would read 1000 under the old
|
|
// --- code; the multiplier form must read exactly 0 regardless of Min. ---
|
|
[Fact]
|
|
public void APowerSupplyTermedStatWithNonzeroMinResolvesToExactlyZeroAtZeroSupply()
|
|
{
|
|
var curve = new PerformanceStat { Min = 1000, Max = 4000, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 2 } } };
|
|
using var cache = OpenCatalog(curve);
|
|
var ship = BuildShip(cache, reactorCharge: 0); // no generation, no stored charge -> demand of 100 is unmet
|
|
|
|
ship.Update(1f);
|
|
|
|
var engine = ship.Equipment.Single(e => e.Data.Name == "Engine");
|
|
Assert.Equal(0f, engine.PowerSupply, 4);
|
|
Assert.Equal(0f, engine.Evaluate(curve), 3); // NOT 1000 (Min) -- the old lerp-based bug's exact number
|
|
}
|
|
|
|
// --- The ruling's other named consequence: "a stat whose Min equals its Max still responds" -- three of Cut
|
|
// --- 7's eight authored terms were inert for exactly this reason, because lerp(x, x, anything) == x. The
|
|
// --- multiplier form has no interpolation to degenerate, so it must still move. ---
|
|
[Fact]
|
|
public void AMinEqualsMaxStatStillRespondsToItsPowerSupplyTerm()
|
|
{
|
|
var curve = new PerformanceStat { Min = 2500, Max = 2500, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } };
|
|
using var cacheStarved = OpenCatalog(curve);
|
|
var starved = BuildShip(cacheStarved, reactorCharge: 0);
|
|
starved.Update(1f);
|
|
var starvedEngine = starved.Equipment.Single(e => e.Data.Name == "Engine");
|
|
|
|
var curve2 = new PerformanceStat { Min = 2500, Max = 2500, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } };
|
|
using var cacheFed = OpenCatalog(curve2);
|
|
var fed = BuildShip(cacheFed, reactorCharge: 1000);
|
|
fed.Update(1f);
|
|
var fedEngine = fed.Equipment.Single(e => e.Data.Name == "Engine");
|
|
|
|
Assert.Equal(0f, starvedEngine.Evaluate(curve), 3); // was inert (always 2500) before this fix
|
|
Assert.Equal(2500f, fedEngine.Evaluate(curve2), 3);
|
|
Assert.NotEqual(fedEngine.Evaluate(curve2), starvedEngine.Evaluate(curve));
|
|
}
|
|
|
|
// A small, deliberately fake context (StatResolverTests' own pattern) so the "no cost when undeclared" rule
|
|
// is provable without any catalog, entity or item plumbing -- and so it can count exactly how many times the
|
|
// resolver ever asked for the power-supply factor.
|
|
private sealed class CountingContext : IStatContext
|
|
{
|
|
public Lot Lot { get; set; }
|
|
public int PowerSupplyFactorCalls;
|
|
public int HeatFactorCalls;
|
|
public float HeatFactor(float exponent) { HeatFactorCalls++; return 1f; }
|
|
public float DurabilityFactor(float exponent) => 1f;
|
|
public float ConsumableProgressFactor(float exponent) => 1f;
|
|
public float PowerSupplyFactor(float exponent) { PowerSupplyFactorCalls++; return 1f; }
|
|
public float ScaleModifier(PerformanceStat stat) => 1f;
|
|
public float ConstantModifier(PerformanceStat stat) => 0f;
|
|
}
|
|
|
|
// --- Cut 6 verification bullet 4 / the operator's own "don't pay that cost for every stat evaluation" as an
|
|
// --- assertion: a stat with no PowerSupply term never asks the context for one, at all, cache or no cache --
|
|
// --- proving the term list genuinely is not walked for a source the stat never declared, not merely that the
|
|
// --- answer happens to be 1. ---
|
|
[Fact]
|
|
public void AStatWithNoPowerSupplyTermNeverAsksForOne()
|
|
{
|
|
var resolver = new StatResolver();
|
|
var owner = new object();
|
|
var context = new CountingContext();
|
|
var stat = new PerformanceStat { Min = 0, Max = 10, Terms = { new StatTerm { Source = StatSource.Heat, Exponent = 1 } } };
|
|
|
|
resolver.Resolve(owner, stat, context);
|
|
resolver.InvalidateSource(owner, StatSource.PowerSupply); // the bus's own per-tick call, unconditional
|
|
resolver.Resolve(owner, stat, context);
|
|
|
|
Assert.Equal(1, context.HeatFactorCalls); // recomputed once, for its own declared source
|
|
Assert.Equal(0, context.PowerSupplyFactorCalls); // never once asked -- this stat has no PowerSupply term
|
|
}
|
|
|
|
// --- The same bullet's other half: a stat that DOES declare the term recomputes "at most once per tick," not
|
|
// --- once per read and not once per grant change. PowerBus invalidates PowerSupply unconditionally, exactly
|
|
// --- once per draw per Step (mirroring Heat/Durability's own "every tick, not only when the value moved"
|
|
// --- scheme) -- this pins that shape directly against the resolver, independent of PowerBus's own plumbing.
|
|
// --- ---
|
|
[Fact]
|
|
public void APowerTermedStatRecomputesAtMostOncePerTick()
|
|
{
|
|
var resolver = new StatResolver();
|
|
var owner = new object();
|
|
var context = new CountingContext();
|
|
var stat = new PerformanceStat { Min = 0, Max = 10, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } };
|
|
|
|
// Tick 1: the bus invalidates once; three reads inside the tick must share one evaluation.
|
|
resolver.InvalidateSource(owner, StatSource.PowerSupply);
|
|
resolver.Resolve(owner, stat, context);
|
|
resolver.Resolve(owner, stat, context);
|
|
resolver.Resolve(owner, stat, context);
|
|
Assert.Equal(1, context.PowerSupplyFactorCalls);
|
|
|
|
// Tick 2: the bus invalidates again (PowerBus.AllocateTiers does this every tick, moved or not).
|
|
resolver.InvalidateSource(owner, StatSource.PowerSupply);
|
|
resolver.Resolve(owner, stat, context);
|
|
Assert.Equal(2, context.PowerSupplyFactorCalls); // exactly one more -- not zero, not three again
|
|
}
|
|
|
|
// --- Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19), superseding Cut 6
|
|
// --- verification bullet 2: a request field's own Terms naming PowerSupply directly used to be refused here
|
|
// --- (StatValidation.ValidateNoPowerSupplyOnRequest, now deleted). PowerRequest now reads every
|
|
// --- PowerRequestFields stat through EquippedItem.EvaluateNominalPower, which pins that stat's own
|
|
// --- PowerSupplyFactor to 1 regardless of its Terms, so the request can no longer depend on its own answer
|
|
// --- -- a direct term on a request field is legal again, and Open must accept it (round-tripping through the
|
|
// --- raw UpsertAsync, bypassing CultRecordRefs.Upsert's own validation, the same reach-disk pattern the
|
|
// --- refusal version of this test used). ---
|
|
[Fact]
|
|
public void OpenAcceptsAThrusterWhoseEnergyUsageCarriesAPowerSupplyTerm()
|
|
{
|
|
var goodEnergy = new PerformanceStat { Min = 1, Max = 1, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } };
|
|
using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true))
|
|
{
|
|
cache.Upsert(new VerseGrammar { Revision = 1 });
|
|
cache.UpsertAsync(new GearData
|
|
{
|
|
Name = "CurvedThruster", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
|
|
MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400,
|
|
Behaviors = { new ThrusterData { EnergyUsage = goodEnergy } }
|
|
}).Wait();
|
|
cache.FlushAsync().Wait();
|
|
}
|
|
|
|
using var reopened = AetheriaStores.Open(Catalog);
|
|
Assert.NotNull(reopened.GetByName<GearData>("CurvedThruster"));
|
|
}
|
|
|
|
// The same acceptance at Upsert (the write path every tool, test and migration script actually goes through).
|
|
[Fact]
|
|
public void UpsertAcceptsAThrusterWhoseEnergyUsageCarriesAPowerSupplyTerm()
|
|
{
|
|
var goodEnergy = new PerformanceStat { Min = 1, Max = 1, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } };
|
|
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
|
|
cache.Upsert(new VerseGrammar { Revision = 1 });
|
|
|
|
cache.Upsert(new GearData
|
|
{
|
|
Name = "CurvedThruster", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
|
|
MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400,
|
|
Behaviors = { new ThrusterData { EnergyUsage = goodEnergy } }
|
|
});
|
|
Assert.NotNull(cache.GetByName<GearData>("CurvedThruster"));
|
|
}
|
|
|
|
// --- F6 (docs/stats-and-power-cut.md, Soul pass 2026-09-19) named PumpedHeat as a Radiator request field
|
|
// --- (PowerRequest's own early-out gate reads it, the exact shape Soul reproduced against the shipped
|
|
// --- catalog's "Heat Pump": SOUL_RadiatorRequestDependsOnItsOwnGrantAndOscillates) and, at the time,
|
|
// --- refused a PowerSupply term there for exactly that reason. The nominal-request ruling above is what
|
|
// --- actually fixes the oscillation (the request no longer reads the live grant at all), so this shape is
|
|
// --- accepted now -- the six shipped records this ruling exists to make legal again include exactly this
|
|
// --- one (RadiatorData.PumpedHeat). BrownoutTests' own RunRadiator fixture exercises the resulting behaviour
|
|
// --- end to end; this pins acceptance at the catalog-write boundary. ---
|
|
[Fact]
|
|
public void UpsertAcceptsARadiatorWhosePumpedHeatCarriesAPowerSupplyTerm()
|
|
{
|
|
var curvedPumpedHeat = new PerformanceStat { Min = 1000, Max = 4000, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } };
|
|
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
|
|
cache.Upsert(new VerseGrammar { Revision = 1 });
|
|
|
|
cache.Upsert(new GearData
|
|
{
|
|
Name = "CurvedRadiator", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
|
|
MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400,
|
|
Behaviors = { new RadiatorData
|
|
{
|
|
PumpedHeat = curvedPumpedHeat, WasteHeat = Constant(100), EnergyUsage = Constant(6),
|
|
Emissivity = Constant(.5f), ThermalMass = Constant(100), TemperatureFloor = 0
|
|
} }
|
|
});
|
|
Assert.NotNull(cache.GetByName<GearData>("CurvedRadiator"));
|
|
}
|
|
|
|
// --- The same acceptance on the other consumer the original refusal named by field: Shield.PowerRequest
|
|
// --- calls RefreshReserve, which reads RefillDuration (or RestoreDuration while broken) to size the
|
|
// --- reserve's own fill rate -- now read through EvaluateNominalPower, so a PowerSupply term there no
|
|
// --- longer makes the reserve's own refill speed depend on how much of it was already granted. ---
|
|
[Fact]
|
|
public void UpsertAcceptsAShieldWhoseRefillDurationCarriesAPowerSupplyTerm()
|
|
{
|
|
var curvedRefillDuration = new PerformanceStat { Min = 2, Max = 2, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } };
|
|
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
|
|
cache.Upsert(new VerseGrammar { Revision = 1 });
|
|
|
|
cache.Upsert(new GearData
|
|
{
|
|
Name = "CurvedShield", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
|
|
MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400,
|
|
Behaviors = { new ShieldData
|
|
{
|
|
Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(100),
|
|
RefillDuration = curvedRefillDuration, RestoreDuration = Constant(5)
|
|
} }
|
|
});
|
|
Assert.NotNull(cache.GetByName<GearData>("CurvedShield"));
|
|
}
|
|
|
|
// --- Cut 6 verification bullet 3: "the same refusal through a modifier chain, at equip time, naming both
|
|
// --- items." Drain's own EnergyDraw declares no PowerSupply term (so Upsert/Open both accept it on its own),
|
|
// --- but Booster's StatModifier targets it with a magnitude that DOES carry one -- the request would inherit
|
|
// --- the dependency the moment the modifier attaches. This is dynamic (StatValidation's static check has no
|
|
// --- entity to resolve the modifier's target against), so it is refused at Activate, mirroring
|
|
// --- StatResolverTests.AModifierThatTargetsItsOwnMagnitudeStatIsRefusedAtEquip's own equip-time pattern. ---
|
|
[Fact]
|
|
public void ActivateRefusesAModifierChainThatWouldCorruptAPowerRequest()
|
|
{
|
|
var request = new PerformanceStat { Min = 10, Max = 10 }; // Drain's own request: no PowerSupply term
|
|
var magnitude = new PerformanceStat { Min = 1, Max = 1, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } };
|
|
var modifier = new StatModifierData
|
|
{
|
|
Stat = new StatReference { Target = nameof(EnergyDrawData), Stat = nameof(EnergyDrawData.EnergyDraw) },
|
|
Modifier = magnitude,
|
|
Type = StatModifierType.Constant
|
|
};
|
|
|
|
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
|
|
cache.Upsert(new VerseGrammar { Revision = 1 });
|
|
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, Durability = 10,
|
|
Hardpoints =
|
|
{
|
|
new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() },
|
|
new HardpointData { Type = HardpointType.Sensors, Position = SecondHardpointCell, Shape = new Shape() }
|
|
}
|
|
});
|
|
cache.Upsert(new GearData
|
|
{
|
|
Name = "Drain", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 10,
|
|
MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400,
|
|
Behaviors = { new EnergyDrawData { EnergyDraw = request, PerSecond = true } }
|
|
});
|
|
cache.Upsert(new GearData
|
|
{
|
|
Name = "Booster", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 10,
|
|
MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400,
|
|
Behaviors = { modifier }
|
|
});
|
|
cache.FlushAsync().Wait();
|
|
|
|
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 hullItem = Mint(cache, items, cache.GetByName<HullData>("Skiff"));
|
|
var ship = new Ship(items, zone, hullItem, new EntitySettings());
|
|
Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName<GearData>("Drain")), HardpointCell));
|
|
Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName<GearData>("Booster")), SecondHardpointCell));
|
|
|
|
var error = Assert.Throws<InvalidOperationException>(() => zone.Admit(ship, piloted: false));
|
|
Assert.Contains("Drain", error.Message); // the item whose request would be corrupted
|
|
Assert.Contains("Booster", error.Message); // the item whose modifier would corrupt it
|
|
}
|
|
}
|