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:
parent
ae300c6a84
commit
570834121e
5 changed files with 293 additions and 10 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
210
tests/Aetheria.Shared.Tests/MakerOnlyFactionTests.cs
Normal file
210
tests/Aetheria.Shared.Tests/MakerOnlyFactionTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)[]
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue