diff --git a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs index fd2f19be8..5546a58a5 100644 --- a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs @@ -35,12 +35,13 @@ public sealed class ProductChoiceTests : IDisposable public CultCache Cache; public ItemManager Items; public Galaxy Galaxy; - public Faction OutA, OutZ, OutBoth, OutNone; + public Faction OutA, OutZ, OutBoth, OutEven, OutNone; - public LoadoutGenerator Outfitter(Faction faction, uint seed) + // zone is the index of the zone the outfitter stands in; -1 stands in none, so distance does not weigh. + public LoadoutGenerator Outfitter(Faction faction, uint seed, int zone = -1) { var random = new Random(seed); - return new LoadoutGenerator(ref random, Items, faction == null ? null : Galaxy, null, faction, .5f); + return new LoadoutGenerator(ref random, Items, faction == null ? null : Galaxy, faction == null || zone < 0 ? null : Galaxy.Zones[zone], faction, .5f); } public FactionProductData Product(string name) => Cache.GetAll().Single(p => p.Name == name); @@ -76,6 +77,9 @@ public sealed class ProductChoiceTests : IDisposable seed.Upsert(new Faction { Name = "OutA", ShortName = "OTA", Allegiance = { { alpha, 1f } } }); seed.Upsert(new Faction { Name = "OutZ", ShortName = "OTZ", Allegiance = { { zeta, 1f } } }); seed.Upsert(new Faction { Name = "OutBoth", ShortName = "OTB", Allegiance = { { alpha, 1f }, { zeta, .1f } } }); + seed.Upsert(new Faction { Name = "OutEven", ShortName = "OTE", Allegiance = { { alpha, 1f }, { zeta, 1f } } }); + seed.Upsert(new Faction { Name = "OutLoose", ShortName = "OTL", Allegiance = { { alpha, 1f }, { zeta, .25f } } }); + seed.Upsert(new Faction { Name = "OutZero", ShortName = "OTO", Allegiance = { { alpha, 0f }, { zeta, 0f } } }); seed.Upsert(new Faction { Name = "OutNone", ShortName = "OTN" }); var shape = new Shape(3, 3); foreach (var cell in shape.AllCoordinates) shape[cell] = true; @@ -102,9 +106,10 @@ public sealed class ProductChoiceTests : IDisposable var outZ = cache.GetByName("OutZ"); var game = new SavedGame { - Factions = new[] { cache.RefOf(outA), cache.RefOf(outZ) }, - Relationships = new[] { FactionRelationship.Neutral, FactionRelationship.Neutral }, - HomeZones = new Dictionary { { 0, 0 }, { 1, 0 } }, + // Alpha's headquarters are in zone 0 and Zeta's in zone 1. + Factions = new[] { cache.RefOf(outA), cache.RefOf(outZ), cache.RefOf(cache.GetByName("Alpha")), cache.RefOf(cache.GetByName("Zeta")) }, + Relationships = new[] { FactionRelationship.Neutral, FactionRelationship.Neutral, FactionRelationship.Neutral, FactionRelationship.Neutral }, + HomeZones = new Dictionary { { 0, 0 }, { 1, 0 }, { 2, 0 }, { 3, 1 } }, BossZones = new Dictionary(), DiscoveredZones = new[] { 0 }, ActionBarBindings = new SavedActionBarBinding[0], @@ -112,12 +117,13 @@ public sealed class ProductChoiceTests : IDisposable }; var zones = new[] { - new SavedZone { Name = "Zone 0", Position = new float2(0, 0), AdjacentZones = Array.Empty(), Factions = new[] { 0, 1 }, Owner = 0, Contents = null } + new SavedZone { Name = "Zone 0", Position = new float2(0, 0), AdjacentZones = new[] { 1 }, Factions = new[] { 0, 1 }, Owner = 0, Contents = null }, + new SavedZone { Name = "Zone 1", Position = new float2(1, 0), AdjacentZones = new[] { 0 }, Factions = new[] { 0, 1 }, Owner = 0, Contents = null } }; RunSave.Commit(cache, game, zones, new ProvenanceLedger()); return new Rig { - Cache = cache, OutA = outA, OutZ = outZ, OutBoth = cache.GetByName("OutBoth"), OutNone = cache.GetByName("OutNone"), + Cache = cache, OutA = outA, OutZ = outZ, OutBoth = cache.GetByName("OutBoth"), OutEven = cache.GetByName("OutEven"), OutNone = cache.GetByName("OutNone"), Items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }), Galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }) }; @@ -227,6 +233,41 @@ public sealed class ProductChoiceTests : IDisposable Assert.Equal("slot 0,0: product Skiff by Alpha is not a product of Lamp", Assert.Single(failures)); } + private static int MakerDraws(Rig rig, Faction faction, int zone, string maker, int n) + { + var lamp = rig.Cache.GetByName("Lamp"); + return Enumerable.Range(1, n).Count(i => rig.Outfitter(faction, Seed(i), zone).ChooseProduct(lamp).Name == maker); + } + + // Two makers an even allegiance weighs the same differ only by distance: standing in a zone, the maker whose + // headquarters is nearer comes up about twice as often (1 against 1/2). + [Fact] + public void ANearerMakerIsFavored() + { + var rig = Build("lamp-z", "lamp-a"); + Assert.InRange(MakerDraws(rig, rig.OutEven, 0, "lamp-a", 300), 165, 235); + Assert.InRange(MakerDraws(rig, rig.OutEven, 1, "lamp-a", 300), 65, 135); + } + + // A weight that read the allegiance alone would give 1 : .25 here, an 80% draw; the zone and allegiance together + // give 1 : .125 for alpha at its own zone, 89%. + [Fact] + public void AnAlliedMakerWeighsByItsAllegiance() + { + var rig = Build("lamp-z", "lamp-a"); + Assert.InRange(MakerDraws(rig, rig.Cache.GetByName("OutLoose"), 0, "lamp-a", 300), 245, 290); + } + + // When every reachable maker weighs 0 the draw is uniform over them, not a pick of the first or last. + [Fact] + public void WhenEveryWeightIsZeroTheDrawIsUniform() + { + var rig = Build("lamp-z", "lamp-a"); + var zero = rig.Cache.GetByName("OutZero"); + Assert.InRange(MakerDraws(rig, zero, 0, "lamp-a", 300), 100, 200); + Assert.InRange(MakerDraws(rig, zero, 0, "lamp-z", 300), 100, 200); + } + // The wire positions of the new fields: a preset written by one build is read by the next. [Fact] public void TheProductRefsSitAtTheirWirePositions() diff --git a/tests/Aetheria.Shared.Tests/RigBrandTests.cs b/tests/Aetheria.Shared.Tests/RigBrandTests.cs index 7f2369cb4..17e05a29a 100644 --- a/tests/Aetheria.Shared.Tests/RigBrandTests.cs +++ b/tests/Aetheria.Shared.Tests/RigBrandTests.cs @@ -11,26 +11,54 @@ public sealed partial class RunStartTests private IEnumerable FittedBrands(Entity entity, string design) => entity.Equipment.Where(item => _items.GetData(item.EquippableItem).Name == design).Select(item => ProductOf(item.EquippableItem)); - // Each rig is staged under eight seeds: an open design would draw its brand from the seed, so a pin that holds under - // all of them is a pin, not a draw that happened to land on the same product. - [Fact] - public void TheRigsKeepTheirBrands() + private const string Sensor = "Compact Sensor", Fca = "Fire Control Array", Missile = "Guided Missile Launcher", Cell = "Cell Blaster", Cargo = "Cargo Bay"; + + // Every product a scenario's fits name, as (design, brand). A rig whose fits name a design where a brand stood would + // draw its brand from the stage's seed; so each scenario is staged under eight seeds, and every pinned design must + // come out as exactly its brand on every ship the scenario fits. A hull that is not a fit's (a generated or bare ship) + // is left out: its gear is not pinned. + public static IEnumerable PinnedRigs() => new[] + { + Rig(new AiFusedDiscipline(), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), + Rig(new Arcs(), (Missile, "GT 3K"), (Cell, "FastBlast+-"), ("Autocannon", "🫳🔫"), ("Charged Scatter Blaster", "ChargeBlast SG"), ("Light Machine Gun", "ClearPath"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), + Rig(new Belt(), (Missile, "GT 3K"), ("Lightning Projector", "DragOnBreath"), (Cell, "FastBlast+-"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), + Rig(new Crossing(), (Missile, "GT 3K"), ("Lightning Projector", "DragOnBreath"), (Cell, "FastBlast+-"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), + Rig(new Duel(), (Missile, "GT 3K"), (Cell, "FastBlast+-"), (Cargo, "Store-All Plus"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), + Rig(new FusedRounds(), ("Cluster Cannon", "DeathCluster"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), + Rig(new LauncherAngles(), (Missile, "GT 3K"), ("Swarm Rocket Pod", "Leonid"), (Cell, "FastBlast+-"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), + Rig(new LockOn(), (Missile, "GT 3K"), (Cell, "FastBlast+-"), (Cargo, "Store-All Plus"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), + Rig(new Pursuit(), ("Mine Launcher", "parting gift"), ("Swarm Rocket Pod", "Leonid"), (Missile, "GT 3K"), (Cell, "FastBlast+-"), (Cargo, "Store-All Plus"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), + Rig(new RefusedRounds(), ("Autocannon", "🫳🔫"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), + Rig(new StarvedReactor(), ("Light Laser", "Spectra"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), + Rig(new WeaponFeel(), ("Charged Scatter Blaster", "ChargeBlast SG"), ("Autocannon", "🫳🔫"), (Cell, "FastBlast+-"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), + }; + + private static object[] Rig(Scenario scenario, params (string design, string brand)[] pins) => new object[] { scenario, pins }; + + [Theory] + [MemberData(nameof(PinnedRigs))] + public void TheRigsKeepTheirBrands(Scenario scenario, (string design, string brand)[] pins) { for (uint seed = 1; seed <= 8; seed++) { - var pursuit = Launch(new Scripted(false, new Pursuit().Stage, seed * 7919)); - Assert.Empty(pursuit.failures); - Assert.Equal(new[] { "parting gift" }, FittedBrands(pursuit.staged.Player, "Mine Launcher")); - Assert.Equal(new[] { "Leonid" }, FittedBrands(pursuit.staged.Player, "Swarm Rocket Pod")); - - var duel = Launch(new Scripted(false, new Duel().Stage, seed * 7919)); - Assert.Empty(duel.failures); - var rival = Assert.Single(duel.staged.Entities); - Assert.Equal(new[] { "Spectra" }, Held(rival).Select(item => _items.Brand(item).Product.Name)); - - var arcs = Launch(new Scripted(false, new Arcs().Stage, seed * 7919)); - Assert.Empty(arcs.failures); - Assert.Equal(new[] { "🫳🔫" }, FittedBrands(arcs.staged.Player, "Autocannon")); + var (_, _, staged, failures) = Launch(new Scripted(false, scenario.Stage, seed * 7919, scenario.Generate)); + Assert.Empty(failures); + var ships = staged.Entities.Append(staged.Player).Where(ship => _items.GetData(ship.Hull).Name is "Longinus" or "Smoke Test Hull"); + foreach (var (design, brand) in pins) + { + var brands = ships.SelectMany(ship => FittedBrands(ship, design)).Distinct().ToArray(); + Assert.True(brands.SequenceEqual(new[] { brand }), $"{scenario.Name} seed {seed}: {design} is built as [{string.Join(", ", brands)}], not {brand}"); + } } } + + // Duel's rival holds Spectra in its cargo. + [Fact] + public void TheDuelRivalHoldsItsBrand() + { + var duel = Launch(new Scripted(false, new Duel().Stage, 7919)); + Assert.Empty(duel.failures); + var rival = Assert.Single(duel.staged.Entities); + Assert.Equal(new[] { "Spectra" }, Held(rival).Select(item => _items.Brand(item).Product.Name)); + } }