Faction.ManufacturerOnly (key 19): one territory rule for both galaxies; Pirates allegiance re-runnable

Galaxy.HoldsTerritory is read by the main draw (filtered before the take) and by
ResolveCast for every prelude cast slot. pirates-faction on an existing Pirates
record tops up allegiance under its key instead of returning early; the first
apply and the top-up share AllegianceGaps. The snapshot constants in
MakerOnlyFactionTests are placeholders: this commit's tests do not pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-17 05:51:28 +02:00
commit 570834121e
5 changed files with 293 additions and 10 deletions

View file

@ -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]

View file

@ -106,7 +106,7 @@ public class Galaxy
IsPrelude = false;
Background = background;
Log = log;
var factions = cache.GetAll<Faction>();
var factions = cache.GetAll<Faction>().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.

View file

@ -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>("SectorGenerationSettings");
sector.MegaCount = megaCount;
return new Galaxy(sector, authored.Read<SectorBackgroundSettings>("SectorBackgroundSettings"),
authored.Read<NameGeneratorSettings>("NameGeneratorSettings"), _cache, _ => { }, null, seed);
}
private Galaxy PreludeGalaxy(TutorialGenerationSettings settings)
{
var authored = AuthoredSettings.Load(_repo);
return new Galaxy(settings, authored.Read<SectorBackgroundSettings>("TutorialBackgroundSettings"),
authored.Read<NameGeneratorSettings>("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<Faction>().ToArray();
Assert.True(shipped.Length > 3);
for (var i = 0; i < shipped.Length; i++)
{
var flagged = _cache.GetAll<Faction>().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<Faction>().First();
early.ManufacturerOnly = true;
var late = Maker("Test Maker");
var eligible = _cache.GetAll<Faction>().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<Faction>().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<object[]>(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<Faction>(old, options).ManufacturerOnly);
}
[Fact]
public void APreludeCastRefusesAManufacturerOnlyFaction()
{
var authored = AuthoredSettings.Load(_repo);
TutorialGenerationSettings Settings() => authored.Read<TutorialGenerationSettings>("TutorialGenerationSettings");
PreludeGalaxy(Settings());
var cast = new Func<TutorialGenerationSettings, (string Field, string Name)>[]
{
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<Faction>().First(f => f.Name.StartsWith(name, StringComparison.InvariantCultureIgnoreCase));
member.ManufacturerOnly = true;
try
{
var refusal = Assert.Throws<InvalidOperationException>(() => 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<FactionProductData>().First();
var product = new FactionProductData { Name = "Test Product", Design = seller.Design, Manufacturer = _cache.RefOf(maker) };
_cache.Upsert(product);
var galaxy = MainGalaxy(_cache.GetAll<Faction>().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>("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>(), faction => Assert.False(faction.ManufacturerOnly, faction.Name));
var count = _cache.GetAll<Faction>().Count();
Assert.Equal(count, MainGalaxy(count, 1).Factions.Length);
}
}

View file

@ -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<Faction>().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<Faction>().Single(f => f.Name == "Latecomer"));
var others = late.Cache.GetAll<Faction>().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<Faction>().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]

View file

@ -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<ProductRole>();
@ -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<Faction> 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<Faction>().FirstOrDefault(f => f.Name == "Pirates");
var existing = db.Cache.GetAll<Faction>().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<Faction>());
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<Faction>().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)[]
{