Aetheria/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs
Meta Vens b0e67cb36f Shield reserve is authored: Capacity/RefillDuration/RestoreDuration, and a broken state
Operator ruling, 2026-09-19, supersedes Cut 4's derived reserve (EnergyUsage refilling
every second, flagged as a guess). ShieldData now authors the reserve size and two
recharge speeds directly; EnergyUsage stays the per-damage-point cost multiplier.

A hit the reserve cannot fully cover now breaks the shield instead of a silent,
charge-preserving refusal. Two follow-up rulings settle the fork the first left open:
the breaking hit passes through in full (no partial absorption -- TakeHit never runs
for it), and the reserve empties to zero on break so RestoreDuration means the same
thing every time regardless of how full the shield was.

InputCapacitorTests' shield fixture now authors the three new fields explicitly --
without them the reserve defaults to zero capacity and breaks on every hit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:46:17 +02:00

285 lines
14 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using CultMath;
using GameCult.Caching;
using Xunit;
// Cut 4 (docs/stats-and-power-cut.md, Cut 4): closes Cut 3's named, temporary exception for the four instant
// draws (a burst, a shot, a ping, a hit taken) -- each now spends from its own InputCapacitor, fed continuously
// by PowerBus like every other consumer, instead of the entity's shared bus capacitors directly. This file pins
// Cut 4's own verification bullets ("no item ever receives a fraction of a shot"; halving the grant halves the
// sustained rate of fire without changing damage per shot) plus the atomicity and half-charge-under-brownout
// rules the map's per-file notes require. Each rule has a matching mutation in
// tests/mutation_tests_stats_power_cut4.py.
public sealed class InputCapacitorTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-inputcap-" + Guid.NewGuid().ToString("N"));
private string Catalog => Path.Combine(_root, "Aetheria.cc");
public InputCapacitorTests() => 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 };
// Same fixture shape as PowerBusTests.OpenCatalog (see its comment for why): a 3x3 interior hull, wide heat
// bounds so ThermalOnline never has to be exercised, and a Reactor whose Charge this method leaves at 0 --
// BuildShip below sets the actual generation per test.
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)
} }
});
// BurstCount and Cooldown must be authored: InstantWeapon.UpdateStats divides Damage/Heat/Energy by
// (int)BurstCount and Execute divides dt by Cooldown, so the catalog's own zero-valued PerformanceStat
// default is not a usable fixture value here (unlike Reactor/Capacitor above, which tolerate it).
cache.Upsert(new GearData
{
Name = "Gun", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400,
Behaviors = { new InstantWeaponData
{
Count = Constant(1), Cooldown = Constant(1), Energy = Constant(10), MagazineSize = 0
} }
});
cache.Upsert(new GearData
{
Name = "Shield", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400,
// Capacity/RefillDuration/RestoreDuration authored explicitly (shield reserve ruling, 2026-09-19):
// ShieldData no longer derives its reserve size from EnergyUsage, so a fixture that only authors
// Efficiency/EnergyUsage now gets a zero-capacity reserve that breaks on every hit. Capacity = 1
// reproduces this fixture's pre-ruling reserve size exactly (EnergyUsage was 1); durations are
// unused by the tests below (they never provoke a break here) but must be nonzero so RefreshReserve
// never divides by a zero duration.
Behaviors = { new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(1), RefillDuration = Constant(1), RestoreDuration = Constant(1) } }
});
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 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();
return (ship, items, ship.GetBehavior<Reactor>());
}
private InstantWeapon EquipGun(CultCache cache, Ship ship)
{
var gunData = cache.GetByName<GearData>("Gun");
ship.Deactivate();
Assert.True(ship.TryEquip(Mint(cache, ship.ItemManager, gunData)));
ship.Activate();
return ship.GetBehavior<InstantWeapon>();
}
private Shield EquipShield(CultCache cache, Ship ship)
{
var shieldData = cache.GetByName<GearData>("Shield");
ship.Deactivate();
Assert.True(ship.TryEquip(Mint(cache, ship.ItemManager, shieldData)));
ship.Activate();
return ship.GetBehavior<Shield>();
}
// Cut 4's own verification bullet: "a weapon whose input capacitor is partly filled does not fire a
// partial shot." Gun costs 10 energy/shot with a 1s cooldown (Rate = 10/s); a 5-generation reactor fills
// the capacitor to exactly half (5) after one second -- not enough to fire, and not a rejected-but-costly
// attempt either: ammo/heat/cooldown state must be completely untouched.
[Fact]
public void WeaponDoesNotFireOnAPartialCharge()
{
using var cache = OpenCatalog();
var (ship, _, _) = BuildShip(cache, reactorCharge: 5);
var gun = EquipGun(cache, ship);
var fired = false;
gun.OnFire += () => fired = true;
// Trigger() reads BurstCount/Cooldown off cached properties that only exist after UpdateStats has run
// once (inside Execute) -- a zero-dt warm-up tick populates them without spending any time or charge,
// the same role PowerBusTests.BuildShip's own warm-up tick plays for Capacitor.Capacity.
ship.Update(0f);
// Arm the burst BEFORE the tick that charges the capacitor: InstantWeapon.Execute adds this tick's
// grant and then runs its burst loop in the same call, so a single dt=1 tick both fills the capacitor
// to exactly half (5 of 10) and attempts the shot against that same half charge -- proving the failure
// is read from the charge actually banked this tick, not a stale or optimistic value.
gun.Activate();
ship.Update(1f); // capacitor request = 10, granted 5 -> half charge, then the burst loop's own attempt
Assert.False(fired);
}
// The other half of the same rule: once the reactor can cover a full second's worth (10 generation), the
// capacitor reaches exactly Capacity in the same tick the burst loop runs, and the shot goes through.
[Fact]
public void WeaponFiresOnceFullyCharged()
{
using var cache = OpenCatalog();
var (ship, _, _) = BuildShip(cache, reactorCharge: 10);
var gun = EquipGun(cache, ship);
var fired = false;
gun.OnFire += () => fired = true;
ship.Update(0f); // warm-up: populates BurstCount/Cooldown before Trigger() reads them
gun.Activate();
ship.Update(1f); // capacitor request = 10, granted 10 -> full charge, then the burst loop fires
Assert.True(fired);
}
// Cut 4's other verification bullet: "halving the grant halves the refill rate ... without changing damage
// per shot." Pinned directly on InputCapacitor -- the mechanism PowerBus and every owning behaviour's
// Execute drive identically (RequestedFill(dt) for the request, AddCharge(request * PowerSupply) for the
// fill) -- rather than on a full ship simulation, whose demand naturally shrinks as a capacitor tops up and
// so does not hold a constant grant fraction across ticks.
[Fact]
public void HalvingTheGrantHalvesTheChargeBankedWithoutChangingCapacity()
{
var full = new InputCapacitor();
full.UpdateStats(energy: 10f, cooldown: 1f); // Capacity = 10, Rate = 10/s
var half = new InputCapacitor();
half.UpdateStats(energy: 10f, cooldown: 1f);
var fullRequest = full.RequestedFill(.1f);
var halfRequest = half.RequestedFill(.1f);
Assert.Equal(fullRequest, halfRequest, 5); // identical ask -- only the grant differs
full.AddCharge(fullRequest * 1f); // PowerSupply == 1: full grant
half.AddCharge(halfRequest * .5f); // PowerSupply == .5: half grant
Assert.Equal(2f * half.Charge, full.Charge, 4); // half the grant banks exactly half the charge
Assert.Equal(10f, full.Capacity, 3);
Assert.Equal(10f, half.Capacity, 3); // per-shot cost (Capacity) is unaffected by the grant
}
// A half-charged item's own reserve neither drains nor advances on its own when the bus grants nothing
// (Item.PowerSupply == 0, e.g. under total brownout): AddCharge(0) must be a true no-op, not a slow leak or
// a forced top-up. Exercised directly on InputCapacitor -- the unit under test PowerBus.Step and every
// owning behaviour's Execute both call into.
[Fact]
public void HalfChargedCapacitorHoldsItsChargeWhenNothingIsGranted()
{
var capacitor = new InputCapacitor();
capacitor.UpdateStats(energy: 10f, cooldown: 1f);
capacitor.AddCharge(5f);
Assert.Equal(5f, capacitor.Charge, 3);
capacitor.AddCharge(0f); // the bus granted nothing this tick
Assert.Equal(5f, capacitor.Charge, 3);
Assert.False(capacitor.TrySpend(capacitor.Capacity)); // still can't fire on half charge
Assert.Equal(5f, capacitor.Charge, 3); // and the refused attempt did not touch it
}
// AddCharge must clamp to Capacity -- the bus over-filling by a rounding error, or a Capacity that shrank
// under a stat change after charge had already accumulated, must never leave Charge reporting more energy
// than a single activation could ever need.
[Fact]
public void AddChargeNeverExceedsCapacity()
{
var capacitor = new InputCapacitor();
capacitor.UpdateStats(energy: 10f, cooldown: 1f);
capacitor.AddCharge(999f);
Assert.Equal(10f, capacitor.Charge, 3);
}
// InputCapacitor.TrySpend must be atomic -- Entity.TrySpendCapacitorCharge's old contract, now scoped to
// one behaviour's own buffer: either the cost is covered in full, or Charge is untouched.
[Fact]
public void TrySpendMovesNothingWhenTheCostCannotBeFullyCovered()
{
var capacitor = new InputCapacitor();
capacitor.UpdateStats(energy: 10f, cooldown: 1f);
capacitor.AddCharge(6f);
Assert.False(capacitor.TrySpend(10f));
Assert.Equal(6f, capacitor.Charge, 3);
Assert.True(capacitor.TrySpend(6f));
Assert.Equal(0f, capacitor.Charge, 3);
}
// Q4 (operator ruling): capacity/rate derive from Energy and Cooldown by default, but an authored override
// replaces the rate -- ChargedWeapon's own mechanism (RateOverride => ChargeEnergy). Pinned directly on
// InputCapacitor: a nonzero rateOverride wins over the Capacity/cooldown default.
[Fact]
public void RateOverrideReplacesTheDerivedDefault()
{
var derived = new InputCapacitor();
derived.UpdateStats(energy: 10f, cooldown: 2f); // default rate = 10/2 = 5
Assert.Equal(5f, derived.Rate, 3);
var overridden = new InputCapacitor();
overridden.UpdateStats(energy: 10f, cooldown: 2f, rateOverride: 100f);
Assert.Equal(100f, overridden.Rate, 3);
}
// Shield is the map's named "odd one out": a hit is not a chosen activation, so its reserve is a continuous
// one, not an activation buffer -- CanTakeHit/TakeHit may draw an arbitrary fraction of the reserve (never
// "must be exactly Capacity" the way a weapon's shot must). But the underlying spend is still atomic: a hit
// costing more than the whole reserve holds is refused entirely, not partially absorbed.
[Fact]
public void ShieldReserveAllowsAPartialDrawButRefusesAHitItCannotFullyCover()
{
using var cache = OpenCatalog();
var (ship, _, _) = BuildShip(cache, reactorCharge: 10);
var shield = EquipShield(cache, ship);
ship.Update(1f); // EnergyUsage = 1 -> reserve Capacity = Rate = 1, granted in full -> Charge = 1
// A small hit (cost 0.5, well under the 1-unit reserve) is a legitimate partial draw, unlike a weapon
// shot, which is only ever spent whole.
Assert.True(shield.CanTakeHit(DamageType.Kinetic, .5f));
shield.TakeHit(DamageType.Kinetic, .5f);
// With .5 left, a smaller hit (0.3) is still absorbed -- this is the assertion that actually
// distinguishes "may draw a partial amount" from "requires the reserve to be at full Capacity", the
// rule the map draws between Shield and the weapon/sensor buffers above.
Assert.True(shield.CanTakeHit(DamageType.Kinetic, .3f));
// A hit needing more than what's left (0.5 remains, hit costs 1) is refused outright, not partially
// absorbed.
Assert.False(shield.CanTakeHit(DamageType.Kinetic, 1f));
}
}