- 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>
327 lines
18 KiB
C#
327 lines
18 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using CultMath;
|
|
using GameCult.Caching;
|
|
using Xunit;
|
|
|
|
// Operator ruling, 2026-09-19 (docs/stats-and-power-cut.md, shield reserve ruling block): supersedes Cut 4's
|
|
// derived reserve sizing. ShieldData now authors Capacity, RefillDuration and RestoreDuration directly; the
|
|
// shield gains a Broken state a hit it cannot fully cover falls into, rather than being silently refused with
|
|
// its charge intact. Two further operator rulings on the fork the first left open: the breaking hit passes
|
|
// through in full (no partial absorption -- TakeHit is never called for it), and the reserve empties to zero on
|
|
// break so the restore duration means the same thing every time regardless of how full the shield was when it
|
|
// broke. This file pins all of that; InputCapacitorTests.cs keeps pinning the surviving Cut 4 rules (a hit
|
|
// within the reserve may draw a partial amount; TrySpend/AddCharge atomicity) against the same class. Each rule
|
|
// here has a matching mutation in tests/mutation_tests_shield_reserve.py.
|
|
//
|
|
// F5 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): CanTakeHit no longer breaks the shield as a side
|
|
// effect of the query -- it did, on the false premise that every caller queries it exactly once per hit, but
|
|
// RaycastAll hands every real caller both the shield and the hull collider for one shot, so it was queried
|
|
// twice. Every test below that means to simulate a breaking hit now calls Shield.Break() explicitly, the same
|
|
// way Assets/Scripts/Gameplay/Weapons/* now does at the point it decides to route a hit past the shield.
|
|
public sealed class ShieldReserveTests : IDisposable
|
|
{
|
|
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-shieldreserve-" + Guid.NewGuid().ToString("N"));
|
|
private string Catalog => Path.Combine(_root, "Aetheria.cc");
|
|
|
|
public ShieldReserveTests() => 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 };
|
|
|
|
// Same fixture shape as InputCapacitorTests.OpenCatalog: a Reactor whose generation BuildShip sets per test,
|
|
// and a Shield authoring Capacity = 10, RefillDuration = 2 (Rate = 5/s while up), RestoreDuration = 5
|
|
// (Rate = 2/s while broken) -- the two durations deliberately far apart so a test that used the wrong one
|
|
// would be caught well outside rounding noise. EnergyUsage = 1 keeps damage == reserve cost, so test
|
|
// arithmetic reads directly in damage units.
|
|
private CultCache OpenCatalog()
|
|
{
|
|
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 = "Shield", 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(10), RefillDuration = Constant(2), RestoreDuration = Constant(5)
|
|
} }
|
|
});
|
|
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, Shield shield) BuildShipWithShield(CultCache cache, float reactorGeneration)
|
|
{
|
|
var ledger = new ProvenanceLedger();
|
|
var items = new ItemManager(1u, 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(reactorGeneration);
|
|
Assert.True(ship.TryEquip(Mint(cache, items, reactorData)));
|
|
|
|
var shieldData = cache.GetByName<GearData>("Shield");
|
|
Assert.True(ship.TryEquip(Mint(cache, items, shieldData)));
|
|
|
|
zone.Admit(ship, piloted: false);
|
|
|
|
return (ship, ship.GetBehavior<Shield>());
|
|
}
|
|
|
|
// Rule: a hit within the reserve is absorbed and spends charge. Capacity = 10, RefillDuration = 2 -> Rate =
|
|
// 5/s; a generous reactor (20/s) grants the full request every tick, so a 1s warm-up tick banks 5 charge.
|
|
// A 3-damage hit (cost 3, EnergyUsage = 1) is well inside that -- absorbed -- and spends charge rather than
|
|
// leaving it untouched: a second hit that needs more than the 2 left (2.5) is refused, proving the first
|
|
// hit actually drew the reserve down instead of being a free pass.
|
|
[Fact]
|
|
public void HitWithinReserveIsAbsorbedAndSpendsCharge()
|
|
{
|
|
using var cache = OpenCatalog();
|
|
var (ship, shield) = BuildShipWithShield(cache, reactorGeneration: 20);
|
|
ship.Update(1f); // reserve Rate = 5/s, granted in full -> Charge = 5
|
|
|
|
Assert.True(shield.CanTakeHit(DamageType.Kinetic, 3f));
|
|
shield.TakeHit(DamageType.Kinetic, 3f); // Charge: 5 -> 2
|
|
|
|
// Proves the hit actually drew the reserve down (not a free absorb that left Charge at 5): a second hit
|
|
// that would have fit inside the original 5 (4, with clear margin either side of the exact 2 left, to
|
|
// stay clear of float rounding at the boundary) no longer fits inside what remains. Per the ruling that
|
|
// any refusal is itself a break, Break() (the caller's own next step, F5) puts the shield into Broken.
|
|
Assert.False(shield.CanTakeHit(DamageType.Kinetic, 4f));
|
|
shield.Break();
|
|
Assert.True(shield.Broken);
|
|
}
|
|
|
|
// Rule: a hit beyond the reserve breaks the shield.
|
|
[Fact]
|
|
public void HitBeyondReserveBreaksTheShield()
|
|
{
|
|
using var cache = OpenCatalog();
|
|
var (ship, shield) = BuildShipWithShield(cache, reactorGeneration: 20);
|
|
ship.Update(1f); // Charge = 5
|
|
|
|
Assert.False(shield.Broken);
|
|
Assert.False(shield.CanTakeHit(DamageType.Kinetic, 6f)); // exceeds the 5 held
|
|
shield.Break();
|
|
Assert.True(shield.Broken);
|
|
}
|
|
|
|
// Rule: the breaking hit passes through in full -- no partial absorption. Operator's second ruling on the
|
|
// fork the first left open: TakeHit must never be called for this hit at all (every caller treats
|
|
// CanTakeHit's false branch as "route the whole hit to the hull instead"), so the reserve must not have
|
|
// spent, or banked, any part of the 5 it held. Pinned by timing rather than a single large tick (a big
|
|
// enough single Update always tops up to Capacity regardless of where it started, because RequestedFill
|
|
// caps at Capacity-Charge and hides the difference): if TakeHit had run for the breaking hit, its own
|
|
// TrySpend is atomic and cost (6) exceeds the charge held (5), so TrySpend would refuse and leave that 5
|
|
// charge in place, un-emptied -- restoring measurably faster than a genuinely emptied reserve.
|
|
[Fact]
|
|
public void BreakingHitIsNotPartiallyAbsorbed()
|
|
{
|
|
using var cache = OpenCatalog();
|
|
var (ship, shield) = BuildShipWithShield(cache, reactorGeneration: 20);
|
|
ship.Update(1f); // Charge = 5
|
|
Assert.False(shield.CanTakeHit(DamageType.Kinetic, 6f)); // would break; TakeHit must not run for this hit
|
|
shield.Break();
|
|
|
|
ship.Update(3f); // from zero: 6 of 10 -- not yet full. From a surviving 5 (TakeHit ran, refused, kept it
|
|
// untouched): 5 + 6 clamped to 10 -- already full.
|
|
Assert.True(shield.Broken);
|
|
}
|
|
|
|
// Rule: a broken shield absorbs nothing -- gated on Broken itself, not merely on the reserve happening to
|
|
// read empty (a break always leaves it empty right away, so a hit against a *freshly* broken shield would
|
|
// still fail on reserve arithmetic alone and would not catch a missing Broken check). Restore partway (1s
|
|
// at Rate = 2/s -> 2 of 10) so the reserve genuinely holds enough to cover a small hit on arithmetic alone,
|
|
// and confirm the shield still refuses it while Broken remains true.
|
|
[Fact]
|
|
public void BrokenShieldAbsorbsNothing()
|
|
{
|
|
using var cache = OpenCatalog();
|
|
var (ship, shield) = BuildShipWithShield(cache, reactorGeneration: 20);
|
|
ship.Update(1f); // Charge = 5
|
|
Assert.False(shield.CanTakeHit(DamageType.Kinetic, 6f)); // would break, reserve now emptied by Break()
|
|
shield.Break();
|
|
Assert.True(shield.Broken);
|
|
|
|
ship.Update(1f); // restoring at 2/s -> 2 of 10, still short of full -- still Broken
|
|
Assert.True(shield.Broken);
|
|
Assert.False(shield.CanTakeHit(DamageType.Kinetic, 1f)); // reserve arithmetic alone would allow this
|
|
}
|
|
|
|
// Rule: a break empties the reserve to zero -- the restore duration means the same thing every time,
|
|
// regardless of how full the shield was when the breaking hit landed. Pinned by timing, not by a boundary
|
|
// CanTakeHit call (which would short-circuit on Broken alone and not actually distinguish the two
|
|
// hypotheses): break with 5 of 10 charge still in play, at RestoreDuration = 5 (Rate = 2/s). If that
|
|
// leftover 5 survived the break, only (10-5)/2 = 2.5s more would be needed to reach Capacity, so by 3s
|
|
// post-break the shield would already have restored. If the break truly zeroed the reserve, 3s only banks
|
|
// 6 of 10 and the shield is still Broken; a further 2s (5s total, RestoreDuration exactly) is what actually
|
|
// restores it from zero.
|
|
[Fact]
|
|
public void BreakEmptiesTheReserveRegardlessOfChargeHeldAtTheMoment()
|
|
{
|
|
using var cache = OpenCatalog();
|
|
var (ship, shield) = BuildShipWithShield(cache, reactorGeneration: 20);
|
|
ship.Update(1f); // Charge = 5
|
|
Assert.False(shield.CanTakeHit(DamageType.Kinetic, 6f)); // would break with 5 charge in play
|
|
shield.Break();
|
|
|
|
ship.Update(3f); // from zero: 6 of 10 -- not yet full. From a surviving 5: would already be full.
|
|
Assert.True(shield.Broken);
|
|
|
|
ship.Update(2f); // total 5s since the break -- exactly RestoreDuration, from zero
|
|
Assert.False(shield.Broken);
|
|
}
|
|
|
|
// Rule: a broken shield returns to full on the restore duration, not the refill duration. RefillDuration =
|
|
// 2 (Rate 5/s) is far shorter than RestoreDuration = 5 (Rate 2/s); ticking for exactly RefillDuration's
|
|
// span while broken must NOT be enough to restore (that would mean the refill rate leaked into the broken
|
|
// path), but ticking for the full RestoreDuration must be.
|
|
[Fact]
|
|
public void BrokenShieldRestoresOnRestoreDurationNotRefillDuration()
|
|
{
|
|
using var cache = OpenCatalog();
|
|
var (ship, shield) = BuildShipWithShield(cache, reactorGeneration: 20);
|
|
ship.Update(1f); // Charge = 5
|
|
Assert.False(shield.CanTakeHit(DamageType.Kinetic, 6f)); // would break, reserve now emptied by Break()
|
|
shield.Break();
|
|
|
|
ship.Update(2f); // RefillDuration's span -- must not be enough to restore at RestoreDuration's rate
|
|
Assert.True(shield.Broken);
|
|
|
|
ship.Update(3f); // total 5s since the break -- exactly RestoreDuration
|
|
Assert.False(shield.Broken);
|
|
Assert.True(shield.CanTakeHit(DamageType.Kinetic, 10f)); // and genuinely full, not just "not broken"
|
|
}
|
|
|
|
// Rule: a partial grant from the bus stretches both durations proportionally -- the bus still rations
|
|
// everything by one fraction (no tiers yet). A reactor generating half the reserve's Rate * 1 demand grants
|
|
// exactly half every tick, so the reserve should read as having banked half of what a fully-supplied
|
|
// reactor would over the same span. Pinned by comparing two ships built with different reactor generation
|
|
// rather than reading raw charge (Shield exposes no such accessor -- CanTakeHit's own boundary is the
|
|
// observable this suite uses throughout).
|
|
[Fact]
|
|
public void PartialGrantStretchesTheRefillDurationProportionally()
|
|
{
|
|
using var fullCache = OpenCatalog();
|
|
var (fullShip, fullShield) = BuildShipWithShield(fullCache, reactorGeneration: 20); // covers demand (5/s) twice over
|
|
fullShip.Update(1f); // granted in full -> Charge = 5
|
|
|
|
using var halfCache = OpenCatalog();
|
|
var (halfShip, halfShield) = BuildShipWithShield(halfCache, reactorGeneration: 2.5f); // half of the 5/s request
|
|
halfShip.Update(1f); // granted half -> Charge = 2.5
|
|
|
|
// F5: CanTakeHit is a pure query now, so calling it more than once no longer risks the shield breaking
|
|
// itself out from under a later assertion -- these three reads can be in any order. The half-grant
|
|
// reserve affords roughly its half share (2, with margin either side of the exact 2.5 to stay clear of
|
|
// float rounding at the boundary); the final check is a hit big enough that even the full-grant reserve
|
|
// could not have covered it more than four-fold over, to show the two really do differ.
|
|
Assert.True(halfShield.CanTakeHit(DamageType.Kinetic, 2f));
|
|
Assert.True(fullShield.CanTakeHit(DamageType.Kinetic, 4f));
|
|
Assert.False(halfShield.CanTakeHit(DamageType.Kinetic, 4f));
|
|
}
|
|
|
|
// --- F5 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): "make the query a query." Calling CanTakeHit
|
|
// --- any number of times, in any mix of true/false answers, must never move the reserve or set Broken --
|
|
// --- exactly what every real caller under Assets/Scripts/Gameplay/Weapons now relies on (RaycastAll hands
|
|
// --- it both the shield and the hull collider for one shot, so it is queried twice per hit). ---
|
|
[Fact]
|
|
public void CanTakeHitNeverMutatesRegardlessOfHowManyTimesItIsCalled()
|
|
{
|
|
using var cache = OpenCatalog();
|
|
var (ship, shield) = BuildShipWithShield(cache, reactorGeneration: 20);
|
|
ship.Update(1f); // Charge = 5
|
|
|
|
Assert.True(shield.CanTakeHit(DamageType.Kinetic, 3f));
|
|
Assert.True(shield.CanTakeHit(DamageType.Kinetic, 3f)); // asked again -- still true, nothing spent
|
|
Assert.False(shield.Broken);
|
|
|
|
Assert.False(shield.CanTakeHit(DamageType.Kinetic, 6f)); // would refuse -- exceeds the 5 held
|
|
Assert.False(shield.CanTakeHit(DamageType.Kinetic, 6f)); // asked again -- still false, still not broken
|
|
Assert.False(shield.Broken);
|
|
|
|
// The reserve itself never moved either: a hit well within the original 5 still fits.
|
|
Assert.True(shield.CanTakeHit(DamageType.Kinetic, 4f));
|
|
}
|
|
|
|
// --- The mutation side of the same split: Break() is what a caller now invokes explicitly, and it must be
|
|
// --- safe to call more than once for one hit (a caller structure that cannot guarantee single-call
|
|
// --- discipline, per the six weapon files' own double-query shape) -- idempotent, not a double-drain or a
|
|
// --- crash on an already-empty reserve. ---
|
|
[Fact]
|
|
public void BreakIsIdempotentAcrossRepeatedCalls()
|
|
{
|
|
using var cache = OpenCatalog();
|
|
var (ship, shield) = BuildShipWithShield(cache, reactorGeneration: 20);
|
|
ship.Update(1f); // Charge = 5
|
|
|
|
shield.Break();
|
|
shield.Break(); // called again for the same hit (e.g. both the shield- and hull-collider branches)
|
|
shield.Break();
|
|
Assert.True(shield.Broken);
|
|
|
|
// Restores exactly on RestoreDuration from zero, same as a single Break() call -- proving the repeats
|
|
// did not re-empty an already-restoring reserve or otherwise disturb the clock.
|
|
ship.Update(3f);
|
|
Assert.True(shield.Broken);
|
|
ship.Update(2f); // total 5s -- exactly RestoreDuration
|
|
Assert.False(shield.Broken);
|
|
}
|
|
|
|
// --- F4 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): "restoration must not depend on the item being
|
|
// --- active." A hit that breaks the shield usually also cooks it, which can knock the item Active.Value
|
|
// --- false in the same moment (thermal/durability shutdown) -- Entity only runs Execute (and, pre-F4, the
|
|
// --- unbreak check that lived inside it) while Active is true, so the shield could never come back no
|
|
// --- matter how long it stayed offline. Simulate that here directly (Enabled = false covers every reason
|
|
// --- Active can go false; the mechanism doesn't care which) and confirm the reserve keeps restoring and
|
|
// --- clears Broken on schedule regardless. ---
|
|
[Fact]
|
|
public void RestorationProceedsWhileTheItemIsInactive()
|
|
{
|
|
using var cache = OpenCatalog();
|
|
var (ship, shield) = BuildShipWithShield(cache, reactorGeneration: 20);
|
|
ship.Update(1f); // Charge = 5
|
|
Assert.False(shield.CanTakeHit(DamageType.Kinetic, 6f)); // would break
|
|
shield.Break();
|
|
Assert.True(shield.Broken);
|
|
|
|
shield.Item.Enabled.Value = false;
|
|
Assert.False(shield.Item.Active.Value);
|
|
|
|
// RestoreDuration (5s) entirely while inactive -- if restoration depended on Active (Execute-gated, the
|
|
// pre-F4 shape), none of this would move the reserve at all and Broken would still be true.
|
|
ship.Update(5f);
|
|
Assert.False(shield.Broken);
|
|
}
|
|
}
|