diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index 64e371c07..95dbacd1f 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -24,6 +24,9 @@ public sealed partial class MiningCut3Tests : IDisposable private readonly List _caches = new List(); private int _fixtureCount; + // A run seed for the scenes built next: nonzero gives the zone a galaxy carrying it (so Zone.DropSeed differs by it). + internal uint GalaxySeed; + public MiningCut3Tests() => Directory.CreateDirectory(_root); public void Dispose() @@ -163,7 +166,8 @@ public sealed partial class MiningCut3Tests : IDisposable pack.Planets.Add(new CultRecordRef(key)); scene.Belts.Add(key); } - scene.Zone = new Zone(items, PlanetSettings(lightRadius), pack, new GalaxyZone { Name = "MiningCut3", Owner = null }, null); + scene.Zone = new Zone(items, PlanetSettings(lightRadius), pack, new GalaxyZone { Name = "MiningCut3", Owner = null }, + GalaxySeed == 0 ? null : RunSeedFixture.Continue(cache, "MiningCut3", GalaxySeed)); return scene; } diff --git a/tests/Aetheria.Shared.Tests/MiningCut4bTests.cs b/tests/Aetheria.Shared.Tests/MiningCut4bTests.cs index 8502e7d7d..abf4f0dc1 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut4bTests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut4bTests.cs @@ -58,8 +58,9 @@ public sealed class MiningCut4bTests : IDisposable // `precision` 1 gives sigma 1 cell; accuracy 1 and a resolution that asks little of the info make PSensor 1. internal Rig Build(bool sensor = true, bool launcher = false, float damage = 10f, float velocity = 0f, float spread = 0f, float precision = 1f, float effective = float.PositiveInfinity, WeaponFuse? fuse = null, float blastRadius = 0f, - float rockSize = 0f, float crossSection = 20f, bool target = true, float tracking = 1000000f, FieldKindData kind = null) + float rockSize = 0f, float crossSection = 20f, bool target = true, float tracking = 1000000f, FieldKindData kind = null, uint galaxySeed = 0) { + _fixtures.GalaxySeed = galaxySeed; kind ??= MiningCut3Tests.Kind("Asteroid", crossSection); var scene = _fixtures.BuildScene(new[] { kind }, MiningCut3Tests.Belt(kind, MiningCut3Tests.Rock(Distance, .25f, rockSize))); var gear = MiningCut3Tests.Gear("Targeting", new TargetingSystemData diff --git a/tests/Aetheria.Shared.Tests/MiningCut5aTests.cs b/tests/Aetheria.Shared.Tests/MiningCut5aTests.cs index cd93ca233..d71607031 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut5aTests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut5aTests.cs @@ -56,10 +56,10 @@ public sealed class MiningCut5aTests : IDisposable // A rig whose rock (size .5: 4 units, whole) yields `ores`, shooter holding one bay. private (MiningCut4bTests.Rig Rig, EquippedCargoBay Bay) Mining(int bayWidth = 4, int bayHeight = 4, float damage = 1000f, - WeaponFuse? fuse = null, float velocity = 0f, params (SimpleCommodityData, float)[] ores) + WeaponFuse? fuse = null, float velocity = 0f, uint galaxySeed = 0, float rockSize = .5f, params (SimpleCommodityData, float)[] ores) { var kind = MiningCut3Tests.Kind("Asteroid", 20f); - var rig = _rigs.Build(damage: damage, fuse: fuse, blastRadius: fuse == null ? 0f : 6f, precision: 1000f, rockSize: .5f, velocity: velocity, kind: kind); + var rig = _rigs.Build(damage: damage, fuse: fuse, blastRadius: fuse == null ? 0f : 6f, precision: 1000f, rockSize: rockSize, velocity: velocity, kind: kind, galaxySeed: galaxySeed); Stock(rig.Scene, kind, ores.Length > 0 ? ores : new[] { (Commodity("Rock"), 1f) }); return (rig, Bay(rig.Scene, rig.Shooter, bayWidth, bayHeight, "Hold")); } @@ -242,4 +242,70 @@ public sealed class MiningCut5aTests : IDisposable Assert.True(mixes.Max() - mixes.Min() > 100, "rocks differ in mix when variance is above zero"); Assert.Equal(mixes, Break(scene2, varied(), ship2, 12, 5).Select(m => m.Rock)); } + + // ==== the shot's own dice: ShotId and the zone's DropSeed key the ore ==== + + // A rig whose rock outlives many shots and pays hundreds of units a hit, three ores, so a changed key shows in the mix. The rock is + // wider than the blast, so every blast wears it by the same share and only the dice can tell two shots apart. + private (MiningCut4bTests.Rig Rig, EquippedCargoBay Bay) Rich(WeaponFuse? fuse, float damage, uint galaxySeed = 0) + { + var (rig, bay) = Mining(bayWidth: 8, bayHeight: 8, damage: damage, fuse: fuse, galaxySeed: galaxySeed, rockSize: 1f, + ores: new[] { (Commodity("Rock"), 1f), (Commodity("Gold"), 1f), (Commodity("Ice"), 1f) }); + rig.Zone.Settings.AsteroidYield = new ExponentialLerp { Minimum = 6000f, Maximum = 6000f, Exponent = 1f }; + // Composition hashes the belt's record key, which differs per scene; variance 0 leaves the dice alone to vary the ore. + rig.Zone.Settings.ChunkCompositionVariance = 0f; + return (rig, bay); + } + + // Fires until `hits` shots have landed, returning each landed shot's payout (bay and floats together) as (Rock, Gold, Ice). + private static (int, int, int)[] Payouts(MiningCut4bTests.Rig rig, EquippedCargoBay bay, int hits) + { + IEnumerable Held() => bay.Cargo.Keys.Concat(rig.Zone.FloatingItems.Select(f => f.Item)); + var paid = new List<(int, int, int)>(); + var before = (0, 0, 0); + for (var shot = 0; shot < 100 && paid.Count < hits; shot++) + { + var landed = rig.Resolved.Count(o => o.Hit); + FireControl.Fire(rig.Gun, rig.GunItem, rig.Shooter); + MiningCut4bTests.Run(rig, .05f); + if (rig.Resolved.Count(o => o.Hit) == landed) continue; + Assert.True(rig.Zone.ChunkExists(rig.Rock), "the rock must outlive the shots under test"); + var held = Held().ToArray(); + var now = (Units(held, "Rock", rig.Scene), Units(held, "Gold", rig.Scene), Units(held, "Ice", rig.Scene)); + paid.Add((now.Item1 - before.Item1, now.Item2 - before.Item2, now.Item3 - before.Item3)); + before = now; + } + Assert.Equal(hits, paid.Count); + return paid.ToArray(); + } + + // Which ore lands in which slot follows each scene's catalog keys, so scenes compare by the multiset of counts. + private static int[][] Mix(IEnumerable<(int, int, int)> paid) => paid.Select(p => new[] { p.Item1, p.Item2, p.Item3 }.OrderBy(n => n).ToArray()).ToArray(); + + [Theory] + [InlineData(null, 8f)] + [InlineData(WeaponFuse.Contact, 40f)] + public void TwoShotsInOneZonePayDifferentOre(WeaponFuse? fuse, float damage) + { + var (rig, bay) = Rich(fuse, damage); + var paid = Payouts(rig, bay, 2); + Assert.All(paid, p => Assert.True(p.Item1 + p.Item2 + p.Item3 > 100, "a shot pays hundreds of units")); + Assert.NotEqual(paid[0], paid[1]); + } + + [Theory] + [InlineData(null, 8f)] + [InlineData(WeaponFuse.Contact, 40f)] + public void ADifferentRunSeedPaysDifferentOreForTheSameShot(WeaponFuse? fuse, float damage) + { + var (a, bayA) = Rich(fuse, damage, galaxySeed: 7); + var (b, bayB) = Rich(fuse, damage, galaxySeed: 8); + Assert.NotEqual(a.Zone.DropSeed, b.Zone.DropSeed); + Assert.Equal(a.Zone.CombatSeed, b.Zone.CombatSeed); + Assert.NotEqual(Mix(Payouts(a, bayA, 1)), Mix(Payouts(b, bayB, 1))); + // The same seed repeats it. + var (c, bayC) = Rich(fuse, damage, galaxySeed: 7); + var (d, bayD) = Rich(fuse, damage, galaxySeed: 7); + Assert.Equal(Mix(Payouts(c, bayC, 1)), Mix(Payouts(d, bayD, 1))); + } } diff --git a/tests/Aetheria.Shared.Tests/StationServicesTests.cs b/tests/Aetheria.Shared.Tests/StationServicesTests.cs index 173f00069..824ef7158 100644 --- a/tests/Aetheria.Shared.Tests/StationServicesTests.cs +++ b/tests/Aetheria.Shared.Tests/StationServicesTests.cs @@ -192,6 +192,29 @@ public sealed class StationServicesTests : IDisposable Assert.Empty(station.CargoBays[0].Cargo.Keys.OfType()); } + // A commodity the seller does not hold sells for nothing: not one never stored, and not a stale copy of a stack the sale + // already took. The held stack is untouched, and no credit is paid. + [Fact] + public void TrySell_commodity_not_in_the_hold_pays_nothing() + { + var from = Ship().CargoBays.Single(); + var held = Ore(10); + Assert.True(from.TryStore(held)); + var station = Station(); + var credits = 500; + + Assert.False(StationServices.TrySell(from, station, Ore(10), ref credits)); + Assert.Equal(500, credits); + Assert.Empty(station.CargoBays.Single().Cargo.Keys.OfType()); + Assert.Equal(10, Assert.Single(from.Cargo.Keys.OfType()).Quantity); + + Assert.True(StationServices.TrySell(from, station, held, ref credits)); + var paid = credits; + Assert.False(StationServices.TrySell(from, station, held, ref credits)); + Assert.Equal(paid, credits); + Assert.Equal(10, station.CargoBays.Single().Cargo.Keys.OfType().Sum(c => c.Quantity)); + } + [Fact] public void Repair_restores_and_charges() {