diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs index e8c7164eb..d568f2d1e 100644 --- a/tests/Aetheria.Shared.Tests/StationKindTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -86,6 +86,14 @@ public sealed class StationKindTests : IDisposable return hull; } + // Takes a sold hull's price to zero, or its product's maker away: the hull is still on a product but no hull draw can reach it. + public void Unsellable(HullData hull, bool unsetMaker) + { + var product = Cache.GetAll().Single(p => Cache.Get(p.Design) == hull); + if (unsetMaker) { product.Manufacturer = default; Cache.Upsert(product); } + else { hull.Price = 0; Cache.Upsert(hull); } + } + public void StandIn(string kindId, Faction faction, string asKindId) { var kind = Cache.Get(Kind(kindId)); @@ -386,11 +394,14 @@ public sealed class StationKindTests : IDisposable var w = World.Make(this, zenithOffered: true); w.Sell(w.Au, "mining"); w.Sell(w.Au, "shipyard"); + w.StandIn("depot", w.Lucent, "power"); + w.StandIn("depot", w.Zh, "power"); var authored = AuthoredSettings.Load(RunStartTests.FindRepoRoot()); var galaxy = new Galaxy(authored.Read("TutorialGenerationSettings"), authored.Read("TutorialBackgroundSettings"), w.Cache, new PlayerSettings(), Directory.CreateDirectory(Path.Combine(_root, "zones", "Narrative")), _ => { }, null, 5); var stations = 0; + var fallbacks = 0; foreach (var galaxyZone in galaxy.Zones.Take(8)) { var pack = ZoneGenerator.GenerateZone(w.Items, authored.Read("ZoneSettings"), @@ -401,13 +412,100 @@ public sealed class StationKindTests : IDisposable Assert.True(station.Kind.IsSet(), "a placed station has a kind"); Assert.Equal(station.Kind.Key, w.HullOf(station).Kind.Key); var faction = w.Cache.Get(station.Faction); - var kinds = StationKinds.FieldableKindsOf(w.Cache, faction); - if (kinds.Length > 0) Assert.Contains(station.Kind.Key.Value, kinds.Select(kind => kind.Key.Value)); + var fieldable = OracleFieldableKinds(w.Cache, faction); + if (fieldable.Count > 0) Assert.Contains(station.Kind.Key.Value, fieldable); + else + { + fallbacks++; + Assert.Contains(station.Kind.Key.Value, OracleOfferedKinds(w.Cache)); + } } } + Assert.True(fallbacks > 0, "the sample seated a faction that could field none of its kinds, by the fallback"); Assert.True(stations > 0, "the sample generated stations"); } + // An oracle for what a hull draw can reach, read straight off the records and independent of StationKinds and LoadoutGenerator: + // a station hull with a kind and a price, sold by an unbinned product that names a maker and is not off the market. + private static IEnumerable<(CultRecordKey maker, string kind)> OracleSoldStationKinds(CultCache cache) => + cache.GetAll() + .Where(p => !p.NotOffered && p.Bin == null && p.Manufacturer.IsSet()) + .Select(p => (p, hull: cache.Get(p.Design) as HullData)) + .Where(e => e.hull != null && e.hull.HullType == HullType.Station && e.hull.Kind.IsSet() && e.hull.Price > 0) + .Select(e => (e.p.Manufacturer.Key, e.hull.Kind.Key.Value)); + + private static HashSet OracleOfferedKinds(CultCache cache) => OracleSoldStationKinds(cache).Select(e => e.kind).ToHashSet(); + + // The kinds a faction can field: those it has its own model of, and those it stands in on whose stand-in kind some maker sells. + private static HashSet OracleFieldableKinds(CultCache cache, Faction faction) + { + var offered = OracleOfferedKinds(cache); + var own = OracleSoldStationKinds(cache).Where(e => e.maker.Equals(cache.RefOf(faction).Key)).Select(e => e.kind).ToHashSet(); + foreach (var kind in cache.GetAll()) + foreach (var standIn in kind.StandIns.Where(entry => cache.Get(entry.Faction) == faction)) + if (offered.Contains(standIn.Kind.Key.Value)) own.Add(cache.RefOf(kind).Key.Value); + return own; + } + + // Choose keeps a kind exactly when GenerateStationLoadout can draw a hull of it: a hull sold at Price 0, or by a product with no + // maker, is no hull a draw reaches, so a faction standing in on its kind is not seated on it. The tutorial entrance still gets a station. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AKindIsKeptExactlyWhenAHullOfItCanBeDrawn(bool unsetMaker) + { + var world = World.Make(this, zenithOffered: false); + world.Sell(world.Au, "mining"); + world.Unsellable(world.Sell(world.Au, "shipyard"), unsetMaker); + world.StandIn("mining", world.Lucent, "shipyard"); + world.StandIn("mining", world.Zh, "shipyard"); + + foreach (var faction in new[] { world.Au, world.Lucent, world.Zh }) + for (var site = 0; site < 12; site++) + { + var kind = StationKinds.Choose(world.Cache, faction, "site-" + site); + Assert.Equal(Kind("mining").Key, kind.Key); + Assert.NotNull(world.Generator(faction).GenerateStationLoadout(kind)); + } + + var authored = AuthoredSettings.Load(RunStartTests.FindRepoRoot()); + var galaxy = new Galaxy(authored.Read("TutorialGenerationSettings"), + authored.Read("TutorialBackgroundSettings"), world.Cache, new PlayerSettings(), + Directory.CreateDirectory(Path.Combine(_root, "galaxy" + _worlds++, "Narrative")), _ => { }, null, 5); + var entrance = ZoneGenerator.GenerateZone(world.Items, authored.Read("ZoneSettings"), + authored.Read("PlanetSettings"), galaxy, galaxy.Entrance, galaxy.IsPrelude, true); + Assert.Contains(entrance.Entities.OfType(), e => world.HullOf(e).HullType == HullType.Station); + } + + // A faction with one kind it can field and one it cannot places only the one it can, and never takes the fallback. + [Fact] + public void AFactionWithSomeKindsFieldablePlacesOnlyThose() + { + var world = World.Make(this, zenithOffered: false); + world.Sell(world.Au, "shipyard"); + world.StandIn("mining", world.Lucent, "shipyard"); + world.StandIn("depot", world.Lucent, "power"); + Assert.Equal(2, StationKinds.KindsOf(world.Cache, world.Lucent).Length); + + var logged = new List(); + for (var site = 0; site < 40; site++) + Assert.Equal(Kind("mining").Key, StationKinds.Choose(world.Cache, world.Lucent, "site-" + site, logged.Add).Key); + Assert.Empty(logged); + } + + // The fallback pool is every kind some maker offers, spread over sites, not the first of them. + [Fact] + public void TheFallbackSpreadsSitesOverEveryOfferedKind() + { + var world = World.Make(this, zenithOffered: false); + world.Sell(world.Au, "mining"); + world.Sell(world.Au, "shipyard"); + world.Sell(world.Au, "depot"); + var chosen = Enumerable.Range(0, 40).Select(site => StationKinds.Choose(world.Cache, world.Lucent, "site-" + site).Key.Value).ToHashSet(); + Assert.Equal(new[] { Kind("depot"), Kind("mining"), Kind("shipyard") }.Select(kind => kind.Key.Value).OrderBy(k => k, StringComparer.Ordinal), + chosen.OrderBy(k => k, StringComparer.Ordinal)); + } + [Fact] public void KindSurvivesSave() { @@ -440,30 +538,34 @@ public sealed class StationKindTests : IDisposable Assert.Equal(StationKindData.KeyOf("trade-hub").Value, cache.GetAll().Single(h => h.Name == "Zenith").Kind.Key.Value); } - // Factions whose first station model has not landed (ruling faction-station-models): their stand-ins name the kind that model - // will be and are not yet live. The generator seats such a faction by the any-maker fallback (StationKinds.Choose). Each is - // checked to still own no station hull, so the exemption ends the day a model lands. - private static readonly string[] AwaitingFirstModel = { "Zhestokost" }; - - private static void AssertStandInsLive(CultCache cache) + // Every stand-in is judged from the catalog: its target is a real kind; the faction owns no model of the kind it stands in for; and + // the stand-in is live (the faction can field the kind) exactly when some maker sells a hull of the target kind. A stand-in + // no maker can field is not a defect: the generator drops the kind from the faction's pool and seats it by the any-maker + // fallback, logged (StationKinds.Choose). Returns how many stand-ins were live, so a caller can see the catalog's state. + private static int AssertStandInsLive(CultCache cache) { + var live = 0; foreach (var kind in cache.GetAll()) foreach (var standIn in kind.StandIns) { var faction = cache.Get(standIn.Faction); - if (AwaitingFirstModel.Contains(faction.Name)) + var owning = cache.RefOf(kind).Key.Value; + Assert.NotNull(cache.Get(standIn.Kind)); + Assert.DoesNotContain(owning, OracleSoldStationKinds(cache).Where(e => e.maker.Equals(cache.RefOf(faction).Key)).Select(e => e.kind)); + var carried = OracleOfferedKinds(cache).Contains(standIn.Kind.Key.Value); + var fieldable = StationKinds.FieldableKindsOf(cache, faction).Select(k => k.Key.Value).Contains(owning); + Assert.Equal(carried, fieldable); + if (carried) { live++; continue; } + if (StationKinds.FieldableKindsOf(cache, faction).Length > 0) continue; + var logged = new List(); + var seated = StationKinds.Choose(cache, faction, "site", logged.Add); + if (OracleOfferedKinds(cache).Count > 0) { - Assert.DoesNotContain(cache.GetAll(), p => cache.Get(p.Manufacturer) == faction && LoadoutGenerator.IsOffered(cache, p) - && cache.Get(p.Design) is HullData { HullType: HullType.Station }); - continue; + Assert.Single(logged); + Assert.Contains(seated.Key.Value, OracleOfferedKinds(cache)); } - var models = StationKinds.KindsOf(cache, faction); - var own = cache.GetAll().Where(p => cache.Get(p.Manufacturer) == faction && LoadoutGenerator.IsOffered(cache, p)) - .Select(p => cache.Get(p.Design) as HullData).Where(h => h != null && h.HullType == HullType.Station).Select(h => h.Kind.Key).ToArray(); - Assert.DoesNotContain(cache.RefOf(kind).Key, own); - Assert.Contains(standIn.Kind.Key, own); - Assert.NotEmpty(models); } + return live; } [Fact] @@ -473,16 +575,17 @@ public sealed class StationKindTests : IDisposable AssertStandInsLive(cache); } - // The retirement check is not vacuous: given a stand-in it passes when the faction offers the kind it stands in with and - // not the owning kind, and fails when the faction has gained its own model of the owning kind or has no model to use. + // The stand-in check is not vacuous: a stand-in passes live when the faction can field the kind it stands in with and has no model + // of the owning kind; fails when the faction has gained its own model of the owning kind; is dead, with the fallback observed, + // when no maker sells the target kind (a retarget to a kind no hull carries included); and is caught if its target is no kind. [Fact] - public void StandInRetirementCheckCanFail() + public void StandInCheckCanFail() { var live = World.Make(this, zenithOffered: false); live.Sell(live.Au, "mining"); live.StandIn("shipyard", live.Au, "mining"); Assert.Single(live.Cache.GetAll().SelectMany(kind => kind.StandIns), standIn => live.Cache.Get(standIn.Faction) == live.Au); - AssertStandInsLive(live.Cache); + Assert.Equal(1, AssertStandInsLive(live.Cache)); var retired = World.Make(this, zenithOffered: false); retired.Sell(retired.Au, "mining"); @@ -490,8 +593,19 @@ public sealed class StationKindTests : IDisposable retired.StandIn("shipyard", retired.Au, "mining"); Assert.ThrowsAny(() => AssertStandInsLive(retired.Cache)); - var hollow = World.Make(this, zenithOffered: false); - hollow.StandIn("shipyard", hollow.Au, "mining"); - Assert.ThrowsAny(() => AssertStandInsLive(hollow.Cache)); + var nowhere = World.Make(this, zenithOffered: false); + nowhere.Sell(nowhere.Au, "mining"); + nowhere.StandIn("shipyard", nowhere.Lucent, "power"); + Assert.Equal(0, AssertStandInsLive(nowhere.Cache)); + var logged = new List(); + Assert.Equal(Kind("mining").Key, StationKinds.Choose(nowhere.Cache, nowhere.Lucent, "site", logged.Add).Key); + Assert.Single(logged); + + var missing = World.Make(this, zenithOffered: false); + missing.Sell(missing.Au, "mining"); + missing.StandIn("shipyard", missing.Lucent, "mining"); + var kind = missing.Cache.Get(Kind("shipyard")); + kind.StandIns[0].Kind = Kind("no-such-kind"); + Assert.ThrowsAny(() => AssertStandInsLive(missing.Cache)); } }