Pin Cut 5's tier rules: PowerTierTests.cs
Six tests against the "rules worth pinning" list: critical fed in full while a lower tier starves; two consumers in one tier rationed proportionally to their own request, not split evenly; an empty tier passes its share down instead of swallowing it; reactor throttling moves the line between what starves and what doesn't; a starved tier cannot leak into a lower one; and a tier change on an equipped item takes effect on the very next tick with no unequip/re-equip round trip. 103 -> 109 tests. Each rule has a matching mutation in tests/mutation_tests_stats_power_cut5.py. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
55249beb98
commit
d07652b5bb
1 changed files with 224 additions and 0 deletions
224
tests/Aetheria.Shared.Tests/PowerTierTests.cs
Normal file
224
tests/Aetheria.Shared.Tests/PowerTierTests.cs
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
using CultMath;
|
||||
using GameCult.Caching;
|
||||
|
||||
// Cut 5 (docs/stats-and-power-cut.md §1.3): replaces PowerBus's single shared GrantRatio with priority tiers
|
||||
// (PowerTiers.cs). This file pins Cut 5's own verification bullets plus the "rules worth pinning" the cut brief
|
||||
// names: critical fed in full while a lower tier starves; two items in one tier rationed proportionally
|
||||
// regardless of equip order; a tier with no demand passes its share down; reactor throttling changes which
|
||||
// tiers starve; and a tier boundary does not leak. Each rule has a matching mutation in
|
||||
// tests/mutation_tests_stats_power_cut5.py.
|
||||
//
|
||||
// Fixture shape mirrors PowerBusTests.OpenCatalog/BuildShip (see its comments for why: a 3x3 interior hull, wide
|
||||
// heat bounds, a Reactor whose Charge each test sets directly). EquipDrain here takes an explicit tier so a test
|
||||
// can put two Drains in different tiers without waiting on -- or fighting -- EnergyDraw's own kind default
|
||||
// (PowerTiers.Utility for both), which would otherwise put every Drain in this fixture in the same tier.
|
||||
public sealed class PowerTierTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-powertier-" + Guid.NewGuid().ToString("N"));
|
||||
private string Catalog => Path.Combine(_root, "Aetheria.cc");
|
||||
|
||||
public PowerTierTests() => 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, Rarity = 0, Color = new float3(1, 1, 1) } },
|
||||
QualityPriceModifier = new ExponentialLerp()
|
||||
};
|
||||
|
||||
private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v };
|
||||
|
||||
private CultCache OpenCatalog()
|
||||
{
|
||||
var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
|
||||
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
|
||||
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.FlushAsync().Wait();
|
||||
return cache;
|
||||
}
|
||||
|
||||
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 System.Collections.Generic.List<RoleFill>() })
|
||||
};
|
||||
|
||||
private (Ship ship, ItemManager items, Reactor reactor) BuildShip(CultCache cache, float reactorCharge)
|
||||
{
|
||||
var ledger = new ProvenanceLedger();
|
||||
var items = new ItemManager(cache, ledger, 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)));
|
||||
|
||||
zone.Entities.Add(ship);
|
||||
ship.Activate();
|
||||
|
||||
var reactor = ship.GetBehavior<Reactor>();
|
||||
ship.Update(0f);
|
||||
return (ship, items, reactor);
|
||||
}
|
||||
|
||||
// Every Drain needs its own catalog record and PerformanceStat instance (PowerBusTests.EquipDrain's own
|
||||
// comment explains why: shared resolver entries keyed on the record would otherwise let two Drains fight
|
||||
// over one mutated rate). The tier is set on the minted unit BEFORE equipping it, so EquippedItem's
|
||||
// constructor -- which only ever seeds a kind default onto PowerTiers.Unassigned -- never overwrites it;
|
||||
// this is exactly the "player choice, not a derivation" path §1.3 describes, just exercised directly instead
|
||||
// of through a schematic-UI click.
|
||||
private EquippedItem EquipDrain(CultCache cache, Ship ship, float rate, int tier)
|
||||
{
|
||||
var perItemData = cache.Upsert(new GearData
|
||||
{
|
||||
Name = "Drain " + Guid.NewGuid(), Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
|
||||
MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400,
|
||||
Behaviors = { new EnergyDrawData { EnergyDraw = Constant(rate), PerSecond = true } }
|
||||
});
|
||||
cache.FlushAsync().Wait();
|
||||
ship.Deactivate();
|
||||
var unit = Mint(cache, ship.ItemManager, cache.Get(perItemData));
|
||||
unit.PowerTier = tier;
|
||||
Assert.True(ship.TryEquip(unit));
|
||||
ship.Activate();
|
||||
return ship.Equipment.Last();
|
||||
}
|
||||
|
||||
// --- Cut 5 verification bullet 1 / "rules worth pinning" #1: "with generation short of total demand, tier 0
|
||||
// --- is satisfied in full before tier 1 receives anything." Generation (30) covers Critical's own demand
|
||||
// --- (30) exactly, leaving nothing at all for Utility (50) -- the map's own "starves first" framing, pinned
|
||||
// --- at its sharpest: full satisfaction for the fed tier, total starvation for the other. ---
|
||||
[Fact]
|
||||
public void CriticalTierIsFedInFullWhileLowerTierStarves()
|
||||
{
|
||||
using var cache = OpenCatalog();
|
||||
var (ship, _, _) = BuildShip(cache, reactorCharge: 30);
|
||||
var critical = EquipDrain(cache, ship, rate: 30, tier: PowerTiers.Critical);
|
||||
var utility = EquipDrain(cache, ship, rate: 50, tier: PowerTiers.Utility);
|
||||
|
||||
ship.Update(1f);
|
||||
|
||||
Assert.Equal(1f, critical.PowerSupply, 3);
|
||||
Assert.Equal(0f, utility.PowerSupply, 3);
|
||||
Assert.Equal(1f, ship.PowerBus.TierGrantRatio[PowerTiers.Critical], 3);
|
||||
Assert.Equal(0f, ship.PowerBus.TierGrantRatio[PowerTiers.Utility], 3);
|
||||
}
|
||||
|
||||
// --- "Rules worth pinning" #2: "two items in one tier are rationed proportionally regardless of equip
|
||||
// --- order." Two Medium consumers, unequal requests (20 and 40), generation covering half the tier's total
|
||||
// --- demand (30 of 60): both must land at the same .5 ratio, and their absolute grants (10 and 20) must
|
||||
// --- still be proportional to their own requests, not equal to each other. ---
|
||||
[Fact]
|
||||
public void TwoConsumersInOneTierAreRationedProportionally()
|
||||
{
|
||||
using var cache = OpenCatalog();
|
||||
var (ship, _, _) = BuildShip(cache, reactorCharge: 30);
|
||||
var small = EquipDrain(cache, ship, rate: 20, tier: PowerTiers.Medium);
|
||||
var large = EquipDrain(cache, ship, rate: 40, tier: PowerTiers.Medium);
|
||||
|
||||
ship.Update(1f);
|
||||
|
||||
Assert.Equal(.5f, small.PowerSupply, 3);
|
||||
Assert.Equal(.5f, large.PowerSupply, 3);
|
||||
Assert.Equal(10f, 20f * small.PowerSupply, 3); // the small request's absolute grant: half of 20
|
||||
Assert.Equal(20f, 40f * large.PowerSupply, 3); // the large request's absolute grant: half of 40, not equal to the small one's
|
||||
}
|
||||
|
||||
// --- "Rules worth pinning" #3: "a tier with no demand passes its share down." Only a Utility consumer is
|
||||
// --- equipped -- Critical, High, Medium and Low all sit at zero demand -- under a shortfall (generation 10
|
||||
// --- against a request of 40). If an empty tier silently swallowed its share instead of passing it on, this
|
||||
// --- ratio would come out far below the true .25 the single active tier is owed. ---
|
||||
[Fact]
|
||||
public void ATierWithNoDemandPassesItsShareDown()
|
||||
{
|
||||
using var cache = OpenCatalog();
|
||||
var (ship, _, _) = BuildShip(cache, reactorCharge: 10);
|
||||
var utility = EquipDrain(cache, ship, rate: 40, tier: PowerTiers.Utility);
|
||||
|
||||
ship.Update(1f);
|
||||
|
||||
Assert.Equal(.25f, utility.PowerSupply, 3);
|
||||
Assert.Equal(.25f, ship.PowerBus.GrantRatio, 3);
|
||||
for (var tier = 0; tier < PowerTiers.Utility; tier++)
|
||||
Assert.Equal(1f, ship.PowerBus.TierGrantRatio[tier], 3); // empty tiers read as fully met, never starved
|
||||
}
|
||||
|
||||
// --- "Rules worth pinning" #4: "reactor throttling changes what is available and therefore which tiers
|
||||
// --- starve." Same two-tier loadout (Critical 30, Utility 50) at two different reactor charges: a weak
|
||||
// --- reactor starves Utility entirely (as CriticalTierIsFedInFullWhileLowerTierStarves already pins); a
|
||||
// --- strong one (80, covering both) starves nothing. The player's own reactor-tier choice is what moves a
|
||||
// --- subsystem across that line, exactly the ruling's "reactor throttling is a player choice." ---
|
||||
[Fact]
|
||||
public void ReactorThrottlingChangesWhichTiersStarve()
|
||||
{
|
||||
using var cache = OpenCatalog();
|
||||
var (ship, _, _) = BuildShip(cache, reactorCharge: 80);
|
||||
var critical = EquipDrain(cache, ship, rate: 30, tier: PowerTiers.Critical);
|
||||
var utility = EquipDrain(cache, ship, rate: 50, tier: PowerTiers.Utility);
|
||||
|
||||
ship.Update(1f);
|
||||
|
||||
Assert.Equal(1f, critical.PowerSupply, 3);
|
||||
Assert.Equal(1f, utility.PowerSupply, 3); // unlike the 30-charge case, generation now covers both in full
|
||||
}
|
||||
|
||||
// --- "Rules worth pinning" #5: "a tier boundary does not leak." Critical alone outweighs generation (demand
|
||||
// --- 100 against 40), so Critical itself is starved (.4). A lower tier must still receive exactly zero --
|
||||
// --- not some leftover sliver reasoned from Critical's own shortfall -- because remaining hits 0 once
|
||||
// --- Critical's grant is committed, before Utility is ever considered. ---
|
||||
[Fact]
|
||||
public void LowerTierGetsExactlyZeroWhenAHigherTierAloneStarves()
|
||||
{
|
||||
using var cache = OpenCatalog();
|
||||
var (ship, _, _) = BuildShip(cache, reactorCharge: 40);
|
||||
var critical = EquipDrain(cache, ship, rate: 100, tier: PowerTiers.Critical);
|
||||
var utility = EquipDrain(cache, ship, rate: 50, tier: PowerTiers.Utility);
|
||||
|
||||
ship.Update(1f);
|
||||
|
||||
Assert.Equal(.4f, critical.PowerSupply, 3);
|
||||
Assert.Equal(0f, utility.PowerSupply, 3);
|
||||
}
|
||||
|
||||
// --- Cut 5's own verification bullet: "a tier change on an equipped item takes effect on the next tick with
|
||||
// --- no re-equip." §1.3 calls the tier "a stored player choice, not a derivation" -- this proves the store
|
||||
// --- is live, not latched at equip time. A fixed Critical consumer (5) and generation of 15 leave 10 for a
|
||||
// --- Utility mover requesting 20 (.5 ratio); moving the mover into Critical alongside the fixed one changes
|
||||
// --- the tier's own total demand to 25 against the same 15 supply (.6 ratio) on the very next Step, with no
|
||||
// --- unequip/re-equip round trip -- proof the read is live, not a value latched once at equip time.
|
||||
[Fact]
|
||||
public void ATierChangeOnAnEquippedItemTakesEffectNextTickWithNoReEquip()
|
||||
{
|
||||
using var cache = OpenCatalog();
|
||||
var (ship, _, _) = BuildShip(cache, reactorCharge: 15);
|
||||
EquipDrain(cache, ship, rate: 5, tier: PowerTiers.Critical);
|
||||
var mover = EquipDrain(cache, ship, rate: 20, tier: PowerTiers.Utility);
|
||||
|
||||
ship.Update(1f);
|
||||
Assert.Equal(.5f, mover.PowerSupply, 3);
|
||||
|
||||
mover.EquippableItem.PowerTier = PowerTiers.Critical;
|
||||
ship.Update(1f);
|
||||
|
||||
Assert.Equal(.6f, mover.PowerSupply, 3);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue