diff --git a/Assets/Scripts/ServerShared/Corporations.cs b/Assets/Scripts/ServerShared/Corporations.cs index c1e69fcca..6a4cf4ab0 100644 --- a/Assets/Scripts/ServerShared/Corporations.cs +++ b/Assets/Scripts/ServerShared/Corporations.cs @@ -63,6 +63,12 @@ public class Faction // The faction's rules of engagement; null fights as FactionDoctrine.Default. [Inspectable, JsonProperty("doctrine"), Key(17)] public FactionDoctrine Doctrine; + + // Key 18 is reserved for Livery (follow-up livery-key-collision). + + // A manufacturer-only faction sells through other factions' allegiance and never holds territory (ruling minor-power-brands). + [Inspectable, JsonProperty("manufacturerOnly"), Key(19)] + public bool ManufacturerOnly; } [CultDocument("aetheria.namefile", "1"), Inspectable, MessagePackObject] diff --git a/Assets/Scripts/ServerShared/Galaxy.cs b/Assets/Scripts/ServerShared/Galaxy.cs index 7026d8a52..fad931ade 100644 --- a/Assets/Scripts/ServerShared/Galaxy.cs +++ b/Assets/Scripts/ServerShared/Galaxy.cs @@ -106,7 +106,7 @@ public class Galaxy IsPrelude = false; Background = background; Log = log; - var factions = cache.GetAll(); + var factions = cache.GetAll().Where(HoldsTerritory); Seed = seed == 0 ? (uint) (DateTime.Now.Ticks % uint.MaxValue) : seed; var random = new Random(Seed); Factions = factions.OrderBy(x => random.NextFloat()).Take(settings.MegaCount).ToArray(); @@ -143,8 +143,15 @@ public class Galaxy } // A cast member of a prelude galaxy, by name. A name the catalog does not hold refuses the galaxy, naming the field. - private Faction ResolveCast(string field, string name) => - ResolveFaction(name) ?? throw new InvalidOperationException($"cast faction {name} ({field}) is not in the catalog"); + private Faction ResolveCast(string field, string name) + { + var faction = ResolveFaction(name) ?? throw new InvalidOperationException($"cast faction {name} ({field}) is not in the catalog"); + return HoldsTerritory(faction) ? faction + : throw new InvalidOperationException($"cast faction {name} ({field}) is manufacturer-only and holds no territory"); + } + + // The one territory rule: a manufacturer-only faction sells through allegiance and is placed in no galaxy. + private static bool HoldsTerritory(Faction faction) => !faction.ManufacturerOnly; // Gives a prelude galaxy its gate: the antagonist's home is the boss zone and the exit. The only writer of Exit // besides the constructors. diff --git a/tests/Aetheria.Shared.Tests/MakerOnlyFactionTests.cs b/tests/Aetheria.Shared.Tests/MakerOnlyFactionTests.cs new file mode 100644 index 000000000..3a2b8dba4 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/MakerOnlyFactionTests.cs @@ -0,0 +1,210 @@ +/* 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.IO; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using Xunit; + +// Faction.ManufacturerOnly records ruling minor-power-brands' "no galaxy presence": both generating Galaxy constructors +// refuse to place such a faction (the main draw filters it, the prelude cast rejects it), so it holds no territory and its +// products reach buyers through allegiance alone (rulings gear-without-presence, design-only-weighted-draw). +public sealed class MakerOnlyFactionTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-makeronly-" + Guid.NewGuid().ToString("N")); + private readonly CultCache _cache; + private readonly string _repo = RunStartTests.FindRepoRoot(); + + public MakerOnlyFactionTests() + { + Directory.CreateDirectory(_root); + var catalog = Path.Combine(_root, "Aetheria.cc"); + File.Copy(TestCatalog.Repo, catalog); + _cache = new CultCache(TestCatalog.Registry()); + _cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalog), AetheriaStores.CatalogTypes); + _cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(_root, "run.cc")), AetheriaStores.RunTypes); + } + + public void Dispose() + { + _cache.Dispose(); + Directory.Delete(_root, true); + } + + private Galaxy MainGalaxy(int megaCount, uint seed) + { + var authored = AuthoredSettings.Load(_repo); + var sector = authored.Read("SectorGenerationSettings"); + sector.MegaCount = megaCount; + return new Galaxy(sector, authored.Read("SectorBackgroundSettings"), + authored.Read("NameGeneratorSettings"), _cache, _ => { }, null, seed); + } + + private Galaxy PreludeGalaxy(TutorialGenerationSettings settings) + { + var authored = AuthoredSettings.Load(_repo); + return new Galaxy(settings, authored.Read("TutorialBackgroundSettings"), + authored.Read("NameGeneratorSettings"), _cache, new PlayerSettings(), + Directory.CreateDirectory(Path.Combine(_root, "Narrative")), _ => { }, null, 1); + } + + private Faction Maker(string name) + { + var maker = new Faction { Name = name, ShortName = name.Replace(" ", ""), ManufacturerOnly = true }; + _cache.Upsert(maker); + return maker; + } + + private static void AssertAbsent(Galaxy galaxy, Faction maker) + { + Assert.DoesNotContain(maker, galaxy.Factions); + Assert.False(galaxy.HomeZones.ContainsKey(maker)); + Assert.False(galaxy.BossZones.ContainsKey(maker)); + Assert.False(galaxy.FactionRelationships.ContainsKey(maker)); + foreach (var zone in galaxy.Zones) + { + Assert.False(ReferenceEquals(maker, zone.Owner)); + Assert.DoesNotContain(maker, zone.Factions ?? new Faction[0]); + } + } + + // Flag each shipped faction in turn, so no position in the catalog's order is exempt from the rule. + [Fact] + public void EveryFlaggedFactionIsLeftOut() + { + var shipped = _cache.GetAll().ToArray(); + Assert.True(shipped.Length > 3); + for (var i = 0; i < shipped.Length; i++) + { + var flagged = _cache.GetAll().ElementAt(i); + flagged.ManufacturerOnly = true; + try + { + var galaxy = MainGalaxy(shipped.Length, 1); + Assert.Equal(shipped.Where(f => f != flagged).Select(f => f.Name).OrderBy(n => n, StringComparer.Ordinal), + galaxy.Factions.Select(f => f.Name).OrderBy(n => n, StringComparer.Ordinal)); + AssertAbsent(galaxy, flagged); + } + finally { flagged.ManufacturerOnly = false; } + } + } + + [Theory] + [InlineData(3)] + [InlineData(12)] + [InlineData(40)] + public void TwoMakersAtSmallAndLargeMegaCount(int megaCount) + { + var early = _cache.GetAll().First(); + early.ManufacturerOnly = true; + var late = Maker("Test Maker"); + var eligible = _cache.GetAll().Count(f => !f.ManufacturerOnly); + Assert.True(eligible > 3); + + for (uint seed = 1; seed <= 4; seed++) + { + var galaxy = MainGalaxy(megaCount, seed); + Assert.Equal(Math.Min(megaCount, eligible), galaxy.Factions.Length); + AssertAbsent(galaxy, early); + AssertAbsent(galaxy, late); + } + } + + // Filtering before the draw consumes no extra randomness, so no shipped seed changes its galaxy. The order was taken from + // the unfiltered draw at base aa12cbac. + [Fact] + public void UnflaggedDrawIsUnchanged() + { + var count = _cache.GetAll().Count(); + for (uint seed = 1; seed <= 4; seed++) + Assert.Equal(DrawAtBase[seed - 1].Split('|'), MainGalaxy(count, seed).Factions.Select(f => f.Name)); + } + + private static readonly string[] DrawAtBase = + { + "SNAPSHOT1", "SNAPSHOT2", "SNAPSHOT3", "SNAPSHOT4", + }; + + // The wire shape: ManufacturerOnly rides at slot 19, slot 18 is reserved for Livery, and a record written before the slot + // existed decodes false. + [Fact] + public void Key19WirePin() + { + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(Faction).Assembly); + var slots = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize( + new Faction { Name = "Maker", ManufacturerOnly = true }, options)); + + Assert.Equal(20, slots.Length); + Assert.Null(slots[18]); + Assert.Equal(true, slots[19]); + + var old = MessagePackSerializer.Serialize(slots.Take(19).ToArray(), MessagePackSerializerOptions.Standard); + Assert.False(MessagePackSerializer.Deserialize(old, options).ManufacturerOnly); + } + + [Fact] + public void APreludeCastRefusesAManufacturerOnlyFaction() + { + var authored = AuthoredSettings.Load(_repo); + TutorialGenerationSettings Settings() => authored.Read("TutorialGenerationSettings"); + PreludeGalaxy(Settings()); + + var cast = new Func[] + { + s => (nameof(s.ProtagonistFaction), s.ProtagonistFaction), + s => (nameof(s.AntagonistFaction), s.AntagonistFaction), + s => (nameof(s.BufferFaction), s.BufferFaction), + s => (nameof(s.NeutralFactions), s.NeutralFactions[0]), + }; + foreach (var pick in cast) + { + var settings = Settings(); + var (field, name) = pick(settings); + var member = _cache.GetAll().First(f => f.Name.StartsWith(name, StringComparison.InvariantCultureIgnoreCase)); + member.ManufacturerOnly = true; + try + { + var refusal = Assert.Throws(() => PreludeGalaxy(settings)); + Assert.Contains(field, refusal.Message); + Assert.Contains("manufacturer-only", refusal.Message); + } + finally { member.ManufacturerOnly = false; } + } + } + + [Fact] + public void MakerOnlyGearReachesAlliedBuyers() + { + var maker = Maker("Test Maker"); + var seller = _cache.GetAll().First(); + var product = new FactionProductData { Name = "Test Product", Design = seller.Design, Manufacturer = _cache.RefOf(maker) }; + _cache.Upsert(product); + var galaxy = MainGalaxy(_cache.GetAll().Count(), 1); + var ally = galaxy.Factions.First(); + var stranger = galaxy.Factions.Last(); + ally.Allegiance[_cache.RefOf(maker)] = .6f; + Assert.False(stranger.Allegiance.ContainsKey(_cache.RefOf(maker))); + + var authored = AuthoredSettings.Load(_repo); + var items = new ItemManager(_cache, new ProvenanceLedger(), authored.Read("GameplaySettings"), _ => { }); + var random = new Random(1); + var allied = new LoadoutGenerator(ref random, items, galaxy, galaxy.Entrance, ally, .5f); + var strange = new LoadoutGenerator(ref random, items, galaxy, galaxy.Entrance, stranger, .5f); + + Assert.True(allied.IsAvailable(product)); + Assert.False(strange.IsAvailable(product)); + Assert.Equal(.6f / (2 + galaxy.Entrance.Distance.Values.Max()), allied.ManufacturerPreference(_cache.RefOf(maker)), 5); + } + + [Fact] + public void ShippedFactionsArePresent() + { + Assert.All(_cache.GetAll(), faction => Assert.False(faction.ManufacturerOnly, faction.Name)); + var count = _cache.GetAll().Count(); + Assert.Equal(count, MainGalaxy(count, 1).Factions.Length); + } +} diff --git a/tests/Aetheria.Shared.Tests/PiratesFactionCommandTests.cs b/tests/Aetheria.Shared.Tests/PiratesFactionCommandTests.cs index 7ccce7cc8..67e91bde1 100644 --- a/tests/Aetheria.Shared.Tests/PiratesFactionCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/PiratesFactionCommandTests.cs @@ -105,17 +105,52 @@ public sealed class PiratesFactionCommandTests : IDisposable Assert.Equal(landed, File.ReadAllBytes(CatalogPath)); } + // An existing hand-authored Pirates record is topped up, never rewritten (follow-up pirates-allegiance-and-identity): it keeps + // its description, short name and record key and gains no products; after a faction is added, apply gives allegiance 1.0 to + // that faction alone; a second apply leaves the file bytes unchanged and a dry run writes nothing. [Fact] - public void An_existing_Pirates_faction_is_left_as_it_is() + public void An_existing_Pirates_faction_is_topped_up_with_allegiance_and_nothing_else() { var db = AetherDb.Open(catalogWritable: true, root: _root); db.Cache.Commit(batch => batch.Upsert(typeof(Faction), new Faction { Name = "Pirates", ShortName = "Mine", Description = "authored by hand" })); + var key = db.Cache.RefOf(db.Cache.GetAll().Single(f => f.Name == "Pirates")).Key; db.Cache.Dispose(); + var beforeDry = File.ReadAllBytes(CatalogPath); + Assert.Equal(0, Program.PiratesFaction(apply: false, root: _root)); + Assert.Equal(beforeDry, File.ReadAllBytes(CatalogPath)); + Assert.Equal(0, Program.PiratesFaction(apply: true, root: _root)); var (pirates, products) = Read(); - Assert.Equal("authored by hand", Assert.Single(pirates).Description); + var record = Assert.Single(pirates); + Assert.Equal("authored by hand", record.Description); + Assert.Equal("Mine", record.ShortName); Assert.Empty(products); + var topped = File.ReadAllBytes(CatalogPath); + + var late = AetherDb.Open(catalogWritable: true, root: _root); + late.Cache.Commit(batch => batch.Upsert(typeof(Faction), new Faction { Name = "Latecomer", ShortName = "Late" })); + var lateRef = late.Cache.RefOf(late.Cache.GetAll().Single(f => f.Name == "Latecomer")); + var others = late.Cache.GetAll().Count(f => f.Name != "Pirates"); + late.Cache.Dispose(); + + Assert.Equal(0, Program.PiratesFaction(apply: true, root: _root)); + var check = AetherDb.Open(root: _root); + try + { + var after = check.Cache.GetAll().Single(f => f.Name == "Pirates"); + Assert.Equal(key, check.Cache.RefOf(after).Key); + Assert.Equal("authored by hand", after.Description); + Assert.Equal(others, after.Allegiance.Count); + Assert.Equal(1f, after.Allegiance[lateRef]); + Assert.All(after.Allegiance.Values, value => Assert.Equal(1f, value)); + } + finally { check.Cache.Dispose(); } + + var settled = File.ReadAllBytes(CatalogPath); + Assert.NotEqual(topped, settled); + Assert.Equal(0, Program.PiratesFaction(apply: true, root: _root)); + Assert.Equal(settled, File.ReadAllBytes(CatalogPath)); } [Fact] diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 61691d180..9cb5e93a1 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -2219,7 +2219,7 @@ public static class Program foreach (var product in products.Where(p => p.Design.Key.Equals(db.Cache.RefOf(design).Key)).OrderBy(p => p.Name, StringComparer.Ordinal)) { var maker = db.Cache.Get(product.Manufacturer); - var profile = maker?.Name == "Pirates" ? PirateProfile + var profile = maker?.Name == PiratesName ? PirateProfile : maker != null && MakerProfiles.TryGetValue(maker.ShortName, out var found) ? found : throw new InvalidOperationException($"No maker profile for the seller of \"{design.Name}\" ({maker?.ShortName ?? "no maker"})."); product.Roles ??= new List(); @@ -2260,14 +2260,39 @@ public static class Program // only, written here as \U escapes so no editor or shell mangles them; the dry run prints them raw and as code points. // // Dry run unless passed "apply", same contract as the other *-migrate commands. + private const string PiratesName = "Pirates"; + + // The factions the Pirates do not yet hold allegiance to: every faction but themselves and those already named. The one + // rule of both the first apply and the top-up, so they cannot diverge. + private static Faction[] AllegianceGaps(AetherDb db, Faction pirates, IEnumerable factions) => + factions.Where(f => !ReferenceEquals(f, pirates) && !pirates.Allegiance.ContainsKey(db.Cache.RefOf(f))).ToArray(); + public static int PiratesFaction(bool apply, string root = null) { var db = AetherDb.Open(catalogWritable: apply, root: root); - var existing = db.Cache.GetAll().FirstOrDefault(f => f.Name == "Pirates"); + var existing = db.Cache.GetAll().FirstOrDefault(f => string.Equals(f.Name, PiratesName, StringComparison.Ordinal)); if (existing != null) { - Console.WriteLine("A faction named \"Pirates\" already exists; nothing to do."); + // A Pirates record already in the catalog is topped up, never rewritten: it gains allegiance to the factions + // added since, under its existing key (ruling faction-identity-is-record-key), and nothing else changes. + var gaps = AllegianceGaps(db, existing, db.Cache.GetAll()); + if (gaps.Length == 0) + { + Console.WriteLine("The Pirates' allegiance already covers every faction; nothing to do."); + return 0; + } + Console.WriteLine($"The Pirates lack allegiance to {gaps.Length} faction(s): {string.Join(", ", gaps.Select(f => f.Name))}."); + if (!apply) + { + Console.WriteLine(" +Dry run. Pass \"apply\" to give them allegiance 1.0 to each."); + return 0; + } + foreach (var gap in gaps) existing.Allegiance[db.Cache.RefOf(gap)] = 1f; + db.Cache.Commit(batch => batch.Upsert(typeof(Faction), existing, db.Cache.RefOf(existing).Key)); + Console.WriteLine($" +Gave the Pirates allegiance 1.0 to {gaps.Length} faction(s) in Aetheria.cc"); return 0; } @@ -2286,7 +2311,7 @@ public static class Program var others = db.Cache.GetAll().ToArray(); var pirates = new Faction { - Name = "Pirates", + Name = PiratesName, ShortName = "Pirates", Description = "A loose coalition of crews and hidden docks moving people and goods outside official control. No command, no common law: possession has a maintenance schedule.", PrimaryColor = new float3(.42f, .40f, .37f), @@ -2294,7 +2319,7 @@ public static class Program GeonameFile = db.Cache.RefOf(nameFile), InfluenceDistance = 3, }; - foreach (var other in others) pirates.Allegiance[db.Cache.RefOf(other)] = 1f; + foreach (var other in AllegianceGaps(db, pirates, others)) pirates.Allegiance[db.Cache.RefOf(other)] = 1f; var rebrands = new (string Design, string Name, string Description)[] {