diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 42f43033e..f2fd1c955 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -198,7 +198,7 @@ public class LoadoutGenerator // Hardpoint gear that no hull in the catalog can mount has no home (operator, 2026-09-30: "we'll need to author a // bunch more hulls before all the gear variety in the game has a home"): generation never offers it, so no station // stocks it. It gains one the moment a hull with a hardpoint that takes it exists. Tool gear goes in any interior. - private static bool HasHome(EquippableItemData design, HullData[] hulls) => + public static bool HasHome(EquippableItemData design, HullData[] hulls) => design.HardpointType == HardpointType.Tool || design.HardpointType == HardpointType.Hull || hulls.Any(hull => hull.Hardpoints.Any(hardpoint => hardpoint.Takes(design))); diff --git a/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs index 577296ac3..1f282f48a 100644 --- a/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs +++ b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs @@ -9,30 +9,16 @@ using CultMath; using Xunit; // The demo scenario (aetheria-release, cut demo-galaxy): a prelude galaxy of a fixed cast whose antagonist's home is the -// boss zone and the exit. The shipped catalog holds no Pirates faction yet, so each test that needs one adds it to its -// scratch copy of the catalog. +// boss zone and the exit. The cast, the Pirates included, is in the shipped catalog. public sealed partial class RunStartTests { private static readonly uint[] DemoSeeds = { 1, 2, 3, 4, 5 }; - // The catalog record the demo's protagonist needs, until the shipped catalog has one. Zone names need a name file, - // and the entrance's station needs a faction whose allegiance reaches some manufacturer, so it reaches every one. - private void AddPirates() - { - var pirates = new Faction - { - Name = "Pirates", ShortName = "Pirates", GeonameFile = _cache.RefOf(_cache.GetAll().First()) - }; - foreach (var faction in _cache.GetAll().ToArray()) pirates.Allegiance[_cache.RefOf(faction)] = 1; - _cache.Upsert(pirates); - } - private Galaxy DemoGalaxy(uint seed) => RunStart.Generate(new DemoTerminus(), Inputs(() => seed)); [Fact] public void DemoTerminus_fixed_cast() { - AddPirates(); foreach (var seed in DemoSeeds) Assert.Equal(new[] { "Pirates", "Zhestokost", "Lucent Media", "Aeronautics Unlimited" }, DemoGalaxy(seed).Factions.Select(faction => faction.Name)); @@ -41,7 +27,6 @@ public sealed partial class RunStartTests [Fact] public void DemoTerminus_gate_at_antagonist_home() { - AddPirates(); foreach (var seed in DemoSeeds) { var galaxy = DemoGalaxy(seed); @@ -60,7 +45,6 @@ public sealed partial class RunStartTests [Fact] public void DemoTerminus_standing_survives_continue() { - AddPirates(); var (galaxy, arena, staged, failures) = Launch(new DemoTerminus(), Inputs(() => 1)); Assert.True(failures.Count == 0, string.Join("; ", failures)); galaxy.Entrance.Contents = arena; @@ -83,11 +67,16 @@ public sealed partial class RunStartTests restored.BossZones.Select(boss => (boss.Key.Name, Array.IndexOf(restored.Zones, boss.Value)))); } - // On the shipped catalog, which has no Pirates, the run does not start: the galaxy refuses, naming the cast field, - // and the scenario never reaches staging. + // A catalog without the Pirates (the shipped record and its products removed from this scratch copy) does not start the + // run: the galaxy refuses, naming the cast field, and the scenario never reaches staging. [Fact] public void DemoTerminus_missing_cast_refused() { + var pirates = _cache.GetAll().Single(faction => faction.Name == "Pirates"); + var piratesRef = _cache.RefOf(pirates); + foreach (var product in _cache.GetAll().Where(product => product.Manufacturer.Equals(piratesRef)).ToArray()) + Assert.True(_cache.Remove(_cache.RefOf(product).Key)); + Assert.True(_cache.Remove(piratesRef.Key)); Assert.DoesNotContain(_cache.GetAll(), faction => faction.Name.StartsWith("Pirates", StringComparison.Ordinal)); var refusal = Assert.Throws(() => DemoGalaxy(1)); Assert.Contains("Pirates", refusal.Message); @@ -108,7 +97,6 @@ public sealed partial class RunStartTests [Fact] public void DemoTerminus_player_flies_a_non_pirates_brand_hull_the_longinus() { - AddPirates(); foreach (var seed in DemoSeeds) { var (galaxy, _, staged, failures) = Launch(new DemoTerminus(), Inputs(() => seed)); @@ -130,7 +118,6 @@ public sealed partial class RunStartTests [Fact] public void DemoTerminus_player_carries_the_longinus_default_fit() { - AddPirates(); string Fit(Entity ship) => string.Join(";", ship.Equipment .Select(item => $"{item.Data.Name}@{item.Position}/{item.EquippableItem.Rotation}") .OrderBy(entry => entry, StringComparer.Ordinal)); @@ -151,7 +138,6 @@ public sealed partial class RunStartTests [Fact] public void DemoTerminus_region_is_sixty_four_zones_linked_at_half_density() { - AddPirates(); string Links(Galaxy g) => string.Join(";", g.Zones.Select(zone => string.Join(",", zone.AdjacentZones.Select(adjacent => Array.IndexOf(g.Zones, adjacent)).OrderBy(index => index)))); Galaxy Reference(uint seed, float density) @@ -177,7 +163,6 @@ public sealed partial class RunStartTests [Fact] public void ACastNamingNoQuestFactionPlacesNoQuestZone() { - AddPirates(); var galaxy = DemoGalaxy(1); Assert.Equal(4, galaxy.HomeZones.Count); Assert.Equal(4, galaxy.Factions.Length); diff --git a/tests/Aetheria.Shared.Tests/PiratesRecordTests.cs b/tests/Aetheria.Shared.Tests/PiratesRecordTests.cs new file mode 100644 index 000000000..7a865d1e4 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PiratesRecordTests.cs @@ -0,0 +1,67 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Linq; +using Xunit; + +// The Pirates record (aetheria-release, cut pirates-record): the shipped catalog's faction of rebrands, named in emoji. +public sealed partial class RunStartTests +{ + private Faction ShippedPirates() => _cache.GetAll().Single(faction => faction.Name == "Pirates"); + + private FactionProductData[] PiratesProducts() + { + var pirates = _cache.RefOf(ShippedPirates()); + return _cache.GetAll().Where(product => product.Manufacturer.Equals(pirates)).ToArray(); + } + + // The demo's allied faction exists as catalog data with eight generatable rebrands of other makers' designs. + [Fact] + public void PiratesAreInTheShippedCatalog() + { + var pirates = ShippedPirates(); + var others = _cache.GetAll().Where(faction => faction != pirates).Select(faction => _cache.RefOf(faction)).ToArray(); + Assert.NotEmpty(others); + Assert.All(others, other => Assert.True(pirates.Allegiance.ContainsKey(other))); + Assert.NotNull(_cache.Get(pirates.GeonameFile)); + + var products = PiratesProducts(); + Assert.Equal(8, products.Length); + var hulls = _cache.GetAll().ToArray(); + foreach (var product in products) + { + var design = _cache.Get(product.Design) as EquippableItemData; + Assert.NotNull(design); + Assert.True(LoadoutGenerator.HasHome(design, hulls), $"{design.Name} has no home"); + Assert.True(design.Price > 0, $"{design.Name} has no price"); + Assert.Contains(_cache.GetAll(), other => + other.Design.Key.Equals(product.Design.Key) && !other.Manufacturer.Equals(product.Manufacturer)); + } + } + + // The Pirates' products are named in emoji and nothing else: no ASCII letter or digit survives in a name. + [Fact] + public void PiratesProductsAreNamedInEmoji() + { + foreach (var product in PiratesProducts()) + { + Assert.False(string.IsNullOrEmpty(product.Name)); + Assert.DoesNotContain(product.Name, character => character < 128 && char.IsLetterOrDigit(character)); + Assert.Contains(product.Name.EnumerateRunes(), rune => rune.Value >= 0x1F000); + } + } + + // Brand finds a lot's product by (maker, design), so a maker may sell one product per design. + [Fact] + public void NoMakerSellsTwoProductsOfOneDesign() + { + var twice = _cache.GetAll() + .GroupBy(product => (Maker: product.Manufacturer.Key.Value, Design: product.Design.Key.Value)) + .Where(group => group.Count() > 1) + .Select(group => string.Join(", ", group.Select(product => product.Name))) + .ToArray(); + Assert.Empty(twice); + } +} diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index da7ff39ba..afdaa658a 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -41,7 +41,6 @@ public sealed partial class RunStartTests [Fact] public void Boss_spawns_once_in_boss_zone() { - AddPirates(); foreach (var seed in DemoSeeds.Take(3)) { var galaxy = DemoGalaxy(seed); @@ -65,7 +64,6 @@ public sealed partial class RunStartTests [Fact] public void Boss_spawns_in_a_zone_that_is_not_ambient() { - AddPirates(); var galaxy = DemoGalaxy(1); var pack = BossZonePack(galaxy, _items, galaxy.Exit, ambient: false); Assert.Single(pack.Entities, entity => entity.Boss); @@ -75,7 +73,6 @@ public sealed partial class RunStartTests [Fact] public void Boss_hull_from_BossHull_else_fallback() { - AddPirates(); var galaxy = DemoGalaxy(1); var antagonist = Zhestokost(galaxy); var hulls = SoldShipHulls().OrderByDescending(hull => hull.Price).ThenBy(hull => hull.Name, StringComparer.Ordinal).ToArray(); @@ -103,7 +100,6 @@ public sealed partial class RunStartTests [Fact] public void Boss_hull_nobody_sells_falls_back() { - AddPirates(); var galaxy = DemoGalaxy(1); var antagonist = Zhestokost(galaxy); var sold = SoldShipHulls(); @@ -121,7 +117,6 @@ public sealed partial class RunStartTests [Fact] public void Exit_sealed_until_boss_dies() { - AddPirates(); var galaxy = DemoGalaxy(1); var exit = ZoneOf(galaxy, galaxy.Exit); var boss = Assert.Single(exit.Entities, entity => entity.IsBoss); @@ -143,7 +138,6 @@ public sealed partial class RunStartTests [Fact] public void Boss_mark_survives_continue() { - AddPirates(); var (galaxy, arena, staged, failures) = Launch(new DemoTerminus(), Inputs(() => 1)); Assert.True(failures.Count == 0, string.Join("; ", failures)); galaxy.Entrance.Contents = arena; @@ -185,7 +179,6 @@ public sealed partial class RunStartTests [Fact] public void Exit_sealed_and_logged_when_the_boss_cannot_be_generated() { - AddPirates(); var galaxy = DemoGalaxy(1); galaxy.BossZones.Clear(); var log = new List(); @@ -201,7 +194,6 @@ public sealed partial class RunStartTests [Fact] public void Exit_open_after_save_of_a_slain_boss() { - AddPirates(); var galaxy = DemoGalaxy(1); var exit = ZoneOf(galaxy, galaxy.Exit); Assert.Single(exit.Entities, entity => entity.IsBoss).HeatstrokeDeath.OnNext(Unit.Default); @@ -215,7 +207,6 @@ public sealed partial class RunStartTests [Fact] public void Exit_gate_shares_no_point_with_a_wormhole() { - AddPirates(); var ratio = float.Parse(File.ReadLines(Path.Combine(FindRepoRoot(), "Assets", "Resources", "Settings.asset")) .Select(line => Regex.Match(line, @"^ WormholeDistanceRatio: (.+)$")).First(match => match.Success).Groups[1].Value.Trim(), System.Globalization.CultureInfo.InvariantCulture); @@ -244,7 +235,6 @@ public sealed partial class RunStartTests [InlineData(new[] { 0.0, 45.0, 90.0, 135.0, 180.0, -135.0, -90.0, -45.0 })] public void Exit_gate_sits_in_the_widest_gap_at_the_wormhole_radius(double[] neighbourDegrees) { - AddPirates(); var galaxy = DemoGalaxy(1); var zone = ZoneOf(galaxy, galaxy.Exit); const float ratio = 0.75f; diff --git a/tests/Aetheria.Shared.Tests/ScenarioTests.cs b/tests/Aetheria.Shared.Tests/ScenarioTests.cs index 51554904e..a9e3b5678 100644 --- a/tests/Aetheria.Shared.Tests/ScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/ScenarioTests.cs @@ -96,7 +96,6 @@ public sealed partial class RunStartTests [Fact] public void EveryScenarioStages() { - AddPirates(); Assert.Equal(EveryScenario.Count(), EveryScenario.Select(scenario => scenario.Name).Distinct().Count()); foreach (var scenario in EveryScenario) { @@ -523,7 +522,6 @@ public sealed partial class RunStartTests [Fact] public void EveryUnsoldDesignIsAScenarioTestDesign() { - AddPirates(); var runs = EveryScenario.Select(scenario => Launch(scenario, Inputs(() => GalaxySeed)).staged).ToList(); Assert.Empty(UnsoldAndUnplaced(runs));