Merge one exit per region: after boss placement each boss zone keeps only its exit-path link into the far side; distances recomputed before faction homes; saves load the borders they stored (aetheria-living-world). Soul held s1 and s2; Unity batch compile clean on Starfire at 2822a583e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-27 21:33:40 +01:00
commit 8fa1fc21b2
3 changed files with 297 additions and 11 deletions

View file

@ -144,13 +144,19 @@ public class Galaxy
if(progressCallback!=null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao
}
// Each zone's index and the links as indices: the graph Provinces reads.
private (Dictionary<GalaxyZone, int> Index, int[][] Adjacency) ZoneGraph()
{
var index = Zones.Select((zone, i) => (zone, i)).ToDictionary(pair => pair.zone, pair => pair.i);
return (index, Zones.Select(zone => zone.AdjacentZones.Select(next => index[next]).ToArray()).ToArray());
}
// Regions, then each region's provinces and their types (Provinces.Regions, Partition, Deal); the one builder, called by
// the generation constructors and the save constructor. A save passes its membership and types: membership is kept, and a
// saved type that still resolves is kept, any other taking the card the region's Deal gives that province's place.
private void BuildProvinces(int zonesPerProvince, int[] savedSeedOf = null, List<SavedProvince> savedTypes = null)
{
var index = Zones.Select((zone, i) => (zone, i)).ToDictionary(pair => pair.zone, pair => pair.i);
var adjacency = Zones.Select(zone => zone.AdjacentZones.Select(next => index[next]).ToArray()).ToArray();
var (index, adjacency) = ZoneGraph();
var regionOf = global::Provinces.Regions(adjacency, BossZones.Values.Select(zone => index[zone]).ToArray(), Exit == null ? -1 : index[Exit]);
var seedOf = savedSeedOf ?? global::Provinces.Partition(adjacency, regionOf, Seed, zonesPerProvince);
var types = savedTypes?.ToDictionary(saved => saved.Seed, saved => saved.Type);
@ -342,6 +348,10 @@ public class Galaxy
BossZones.Values.Aggregate(1, (i, os) => i * os.Distance[z]));
}
// One exit per region (ruling one-exit-per-region): prune the boss zones' extra links, then home placement below reads
// the distances of the graph that is left.
OneExitPerRegion(progressCallback);
// Place boss mega headquarters such that their sphere of influence encompasses their boss zone
// While occupying as much territory as possible
foreach (var mega in bossMegas)
@ -363,6 +373,21 @@ public class Galaxy
}
}
// Each boss zone keeps one link toward the exit, the one on the exit path, so there is one exit to defend where its boss
// parks (Provinces.ExtraExits picks the rest; ruling one-exit-per-region). Links are removed from both zones, and the
// distance matrix follows. A galaxy built from a save never comes here: its saved links are the world.
private void OneExitPerRegion(Action<string> progressCallback)
{
var (index, adjacency) = ZoneGraph();
var exitPath = ExitPath.Select(zone => index[zone]).ToArray();
foreach (var (boss, far) in global::Provinces.ExtraExits(adjacency, BossZones.Values.Select(zone => index[zone]).ToArray(), index[Exit], exitPath))
{
Zones[boss].AdjacentZones.Remove(Zones[far]);
Zones[far].AdjacentZones.Remove(Zones[boss]);
}
CalculateDistanceMatrix(progressCallback);
}
private void CalculateFactionInfluence(Action<string> progressCallback = null)
{
progressCallback?.Invoke("Calculating Faction Influence");

View file

@ -282,20 +282,45 @@ public static class Provinces
// Zones per province when a settings object does not say (SectorGenerationSettings.ProvinceZones): calibration, not a rule.
public const int DefaultZonesPerProvince = 8;
// Each zone's region, named by the region's least zone index. A boss zone sits on the near side of its border: its
// crossing links are its links to zones in the component of exit in the graph without it, and cutting every boss zone's
// crossing links leaves the regions as the connected components. Every zone, boss zones included, is in exactly one.
// exit unset (-1), or a boss zone that is the exit, cuts nothing.
public static int[] Regions(int[][] adjacency, IReadOnlyCollection<int> bossZones, int exit)
// A boss zone sits on the near side of its border: its crossing links are its links to zones in the component of exit in
// the graph without it. The one definition, read by Regions (which cuts them) and ExtraExits (which prunes all but one).
// exit unset (-1) gives none, and a boss zone that is the exit has none. Ordered by boss, then far zone.
public static (int Boss, int Far)[] CrossingLinks(int[][] adjacency, IReadOnlyCollection<int> bossZones, int exit)
{
var cut = new HashSet<(int, int)>();
var links = new List<(int Boss, int Far)>();
if (exit >= 0)
foreach (var boss in bossZones.Distinct().Where(boss => boss != exit))
foreach (var boss in bossZones.Distinct().Where(boss => boss != exit).OrderBy(boss => boss))
{
var exitSide = Flood(adjacency, exit, (a, b) => b != boss);
foreach (var next in adjacency[boss])
if (exitSide[next]) cut.Add((Math.Min(boss, next), Math.Max(boss, next)));
foreach (var next in adjacency[boss].OrderBy(next => next))
if (exitSide[next]) links.Add((boss, next));
}
return links.ToArray();
}
// The crossing links to prune so each boss zone keeps one exit: all of them but the link to the boss zone's successor on
// exitPath (the zones from the entrance to the exit), which is the link the run goes through. The generator's exit path
// passes through every boss zone by one of its crossing links; a boss zone off exitPath, last on it, or followed by a zone
// it has no crossing link to breaks that, and throws naming the boss zone rather than guessing which link to keep.
public static (int Boss, int Far)[] ExtraExits(int[][] adjacency, IReadOnlyCollection<int> bossZones, int exit, int[] exitPath)
{
var extra = new List<(int Boss, int Far)>();
foreach (var links in CrossingLinks(adjacency, bossZones, exit).GroupBy(link => link.Boss))
{
var at = Array.IndexOf(exitPath, links.Key);
var kept = at >= 0 && at + 1 < exitPath.Length ? exitPath[at + 1] : -1;
if (!links.Any(link => link.Far == kept))
throw new InvalidOperationException($"boss zone {links.Key} has no crossing link to its successor on the exit path, so no exit to keep");
extra.AddRange(links.Where(link => link.Far != kept));
}
return extra.ToArray();
}
// Each zone's region, named by the region's least zone index. Cutting every boss zone's crossing links (CrossingLinks)
// leaves the regions as the connected components. Every zone, boss zones included, is in exactly one.
public static int[] Regions(int[][] adjacency, IReadOnlyCollection<int> bossZones, int exit)
{
var cut = CrossingLinks(adjacency, bossZones, exit).Select(link => (Math.Min(link.Boss, link.Far), Math.Max(link.Boss, link.Far))).ToHashSet();
var regionOf = new int[adjacency.Length];
Array.Fill(regionOf, -1);

View file

@ -407,6 +407,242 @@ public sealed class OreGeographyTests : IDisposable
Assert.True(checkedBosses >= 4, $"only {checkedBosses} boss zones were exercised");
}
// ---- one exit per region
private const uint OneExitSeeds = 60;
private static int[][] Without(int[][] adjacency, params (int, int)[] removed) =>
adjacency.Select((next, zone) => next.Where(other => !removed.Contains((zone, other)) && !removed.Contains((other, zone))).ToArray()).ToArray();
private static int[] Hops(int[][] adjacency, int from)
{
var hops = Enumerable.Repeat(-1, adjacency.Length).ToArray();
hops[from] = 0;
var queue = new Queue<int>(new[] {from});
while (queue.Count > 0)
{
var zone = queue.Dequeue();
foreach (var next in adjacency[zone])
if (hops[next] < 0)
{
hops[next] = hops[zone] + 1;
queue.Enqueue(next);
}
}
return hops;
}
private static int[] ExitPathOf(Galaxy galaxy) => galaxy.ExitPath.Select(zone => Array.IndexOf(galaxy.Zones, zone)).ToArray();
private static int[] OtherBosses(Galaxy galaxy) => BossesOf(galaxy).Distinct().Where(boss => boss != ExitOf(galaxy)).ToArray();
private static Galaxy[] SectorGalaxies()
{
// Every seed builds: a generator exception here is a defect in what these tests cover, never a seed to skip.
lock (Built)
return Enumerable.Range(1, (int) OneExitSeeds).Select(seed =>
Built.TryGetValue((false, (uint) seed), out var known) && known != null ? known : Built[(false, (uint) seed)] = Build(Shared, false, (uint) seed)).ToArray();
}
[Fact]
public void EveryBossZoneHasOneExit()
{
var checkedBosses = 0;
foreach (var galaxy in SectorGalaxies())
{
var adjacency = AdjacencyOf(galaxy);
var exit = ExitOf(galaxy);
var entrance = Array.IndexOf(galaxy.Zones, galaxy.Entrance);
var crossing = Provinces.CrossingLinks(adjacency, BossesOf(galaxy), exit);
foreach (var boss in OtherBosses(galaxy))
{
var exits = crossing.Where(link => link.Boss == boss).ToArray();
Assert.Single(exits);
Assert.False(ReachableAvoiding(Without(adjacency, (boss, exits[0].Far)), entrance, -1)[exit]);
checkedBosses++;
}
Assert.Empty(Provinces.ExtraExits(adjacency, BossesOf(galaxy), exit, ExitPathOf(galaxy)));
}
Assert.True(checkedBosses >= 40, $"only {checkedBosses} boss zones were exercised");
}
[Fact]
public void TheFarSideStaysConnected()
{
foreach (var galaxy in SectorGalaxies())
{
var adjacency = AdjacencyOf(galaxy);
var exit = ExitOf(galaxy);
var entrance = Array.IndexOf(galaxy.Zones, galaxy.Entrance);
Assert.All(Hops(adjacency, entrance), hops => Assert.True(hops >= 0));
var path = ExitPathOf(galaxy);
foreach (var boss in OtherBosses(galaxy))
{
// Without the boss zone the graph is exactly two components: the entrance's, and the exit's holding every later boss.
var near = ReachableAvoiding(adjacency, entrance, boss);
var far = ReachableAvoiding(adjacency, exit, boss);
for (var zone = 0; zone < adjacency.Length; zone++)
if (zone != boss) Assert.True(near[zone] ^ far[zone], $"zone {zone} is on neither or both sides of boss zone {boss}");
foreach (var later in OtherBosses(galaxy).Where(other => Array.IndexOf(path, other) > Array.IndexOf(path, boss)))
Assert.True(far[later]);
}
Assert.Equal(OtherBosses(galaxy).Length + 1, RegionsOf(galaxy).Distinct().Count());
}
}
[Fact]
public void LinksStaySymmetric()
{
foreach (var galaxy in SectorGalaxies())
{
var adjacency = AdjacencyOf(galaxy);
for (var zone = 0; zone < adjacency.Length; zone++)
{
Assert.Equal(adjacency[zone].Length, adjacency[zone].Distinct().Count());
Assert.DoesNotContain(zone, adjacency[zone]);
foreach (var next in adjacency[zone]) Assert.Contains(zone, adjacency[next]);
}
}
}
[Fact]
public void ExtraExitsOfHandBuiltGraphs()
{
// A boss zone with two links into the far side lists both, and ExtraExits spares the one on the exit path.
var diamond = Graph(7, (0, 1), (1, 2), (2, 3), (2, 4), (3, 5), (4, 5), (5, 6));
Assert.Equal(new[] {(2, 3), (2, 4)}, Provinces.CrossingLinks(diamond, new[] {2}, 6));
Assert.Equal(new[] {(2, 4)}, Provinces.ExtraExits(diamond, new[] {2}, 6, new[] {0, 1, 2, 3, 5, 6}));
Assert.Equal(new[] {(2, 3)}, Provinces.ExtraExits(diamond, new[] {2}, 6, new[] {0, 1, 2, 4, 5, 6}));
// Three links: the two off the path are extra.
var fan = Graph(8, (0, 1), (1, 2), (2, 3), (2, 4), (2, 5), (3, 6), (4, 6), (5, 6), (6, 7));
Assert.Equal(new[] {(2, 3), (2, 4), (2, 5)}, Provinces.CrossingLinks(fan, new[] {2}, 7));
Assert.Equal(new[] {(2, 3), (2, 5)}, Provinces.ExtraExits(fan, new[] {2}, 7, new[] {0, 1, 2, 4, 6, 7}));
// A branch hanging off a boss zone and the link back to the near side are never crossing links.
var pendant = Graph(6, (0, 1), (1, 2), (2, 3), (3, 4), (2, 5));
Assert.Equal(new[] {(2, 3)}, Provinces.CrossingLinks(pendant, new[] {2}, 4));
Assert.Empty(Provinces.ExtraExits(pendant, new[] {2}, 4, new[] {0, 1, 2, 3, 4}));
// A boss zone that is the exit, or no exit at all, lists nothing; neither does no boss zone. A repeated boss zone counts once.
Assert.Empty(Provinces.CrossingLinks(diamond, new[] {6}, 6));
Assert.Empty(Provinces.CrossingLinks(diamond, new[] {2}, -1));
Assert.Empty(Provinces.ExtraExits(diamond, new[] {2}, -1, new[] {0, 1, 2, 3, 5, 6}));
Assert.Empty(Provinces.CrossingLinks(diamond, new int[0], 6));
Assert.Equal(2, Provinces.CrossingLinks(diamond, new[] {2, 2}, 6).Length);
// Two bosses in a line each keep their one link.
Assert.Empty(Provinces.ExtraExits(Line(7), new[] {2, 4}, 6, Enumerable.Range(0, 7).ToArray()));
// Two diamonds in a row: each boss zone keeps its path link, and pruning again finds nothing.
var chain = Graph(8, (0, 1), (1, 2), (1, 3), (2, 4), (3, 4), (4, 5), (4, 6), (5, 7), (6, 7));
var path = new[] {0, 1, 2, 4, 5, 7};
var extra = Provinces.ExtraExits(chain, new[] {4, 1}, 7, path);
Assert.Equal(new[] {(1, 3), (4, 6)}, extra);
var pruned = Without(chain, extra);
Assert.Empty(Provinces.ExtraExits(pruned, new[] {4, 1}, 7, path));
Assert.Equal(Provinces.Regions(chain, new[] {4, 1}, 7), Provinces.Regions(pruned, new[] {4, 1}, 7));
// Crossing links come ordered by far zone whatever order the adjacency lists them in.
var shuffled = diamond.Select(next => next.Reverse().ToArray()).ToArray();
Assert.Equal(new[] {4, 3, 1}, shuffled[2]);
Assert.Equal(new[] {(2, 3), (2, 4)}, Provinces.CrossingLinks(shuffled, new[] {2}, 6));
Assert.Equal(new[] {(2, 3)}, Provinces.ExtraExits(shuffled, new[] {2}, 6, new[] {0, 1, 2, 4, 5, 6}));
// A boss zone at the start of the exit path keeps the link to its successor too.
var atStart = Graph(4, (0, 1), (0, 2), (1, 3), (2, 3));
Assert.Equal(new[] {(0, 2)}, Provinces.ExtraExits(atStart, new[] {0}, 3, new[] {0, 1, 3}));
Assert.Equal(new[] {(0, 1)}, Provinces.ExtraExits(atStart, new[] {0}, 3, new[] {0, 2, 3}));
// A boss zone off the path, last on it, or followed by a zone it has no crossing link to has no exit to keep: it throws, naming the boss zone.
foreach (var broken in new[] {new[] {0, 1, 5, 6}, new[] {0, 1, 2}, new[] {0, 1, 2, 5, 6}})
Assert.Contains("boss zone 2", Assert.Throws<InvalidOperationException>(() => Provinces.ExtraExits(diamond, new[] {2}, 6, broken)).Message);
}
[Fact]
public void DistancesAreOfTheFinalGraph()
{
// Built here, not shared: a galaxy another test built first would put the generator's lines under that test's coverage alone.
foreach (var galaxy in Enumerable.Range(1, (int) OneExitSeeds).Select(seed => Build(Shared, false, (uint) seed)).ToArray())
{
var adjacency = AdjacencyOf(galaxy);
for (var zone = 0; zone < adjacency.Length; zone++)
{
var hops = Hops(adjacency, zone);
Assert.Equal(hops.Sum(), galaxy.Zones[zone].Isolation);
for (var other = 0; other < adjacency.Length; other++)
Assert.Equal(hops[other], galaxy.Zones[zone].Distance[galaxy.Zones[other]]);
}
// Homes were placed on the pruned graph: each boss mega's boss zone is within its influence of its home.
foreach (var pair in galaxy.BossZones)
Assert.True(Hops(adjacency, Array.IndexOf(galaxy.Zones, galaxy.HomeZones[pair.Key]))[Array.IndexOf(galaxy.Zones, pair.Value)] <= galaxy.InfluenceOf(pair.Key));
}
}
[Fact]
public void TheExitPathSurvivesPruning()
{
foreach (var galaxy in SectorGalaxies())
{
var adjacency = AdjacencyOf(galaxy);
var path = ExitPathOf(galaxy);
for (var i = 0; i + 1 < path.Length; i++) Assert.Contains(path[i + 1], adjacency[path[i]]);
Assert.All(BossesOf(galaxy), boss => Assert.Contains(boss, path));
}
}
[Fact]
public void PruningIsDeterministic()
{
foreach (var seed in new uint[] {1, 7, 23})
{
var a = TryBuild(Shared, false, seed);
var b = TryBuild(Shared, false, seed);
Assert.NotNull(a);
Assert.Equal(AdjacencyOf(a), AdjacencyOf(b));
Assert.Equal(a.Zones.Select(zone => zone.Name), b.Zones.Select(zone => zone.Name));
Assert.Equal(Shape(a), Shape(b));
}
}
[Fact]
public void ASavedBorderIsWhatLoads()
{
var (cache, _, _) = Shipped();
var galaxy = MultiRegionSector(cache);
var bosses = OtherBosses(galaxy);
// Capture then Continue keeps one exit per boss zone.
var (game, zones) = Capture(cache, galaxy);
var loaded = Continue(cache, game, zones);
Assert.Equal(AdjacencyOf(galaxy), AdjacencyOf(loaded));
Assert.Equal(bosses.Length, Provinces.CrossingLinks(AdjacencyOf(loaded), BossesOf(loaded), ExitOf(loaded)).Length);
// A save given a second crossing link loads with it: load never prunes, and the regions are still the bosses and one.
var adjacency = AdjacencyOf(galaxy);
var boss = bosses[0];
var far = Provinces.CrossingLinks(adjacency, BossesOf(galaxy), ExitOf(galaxy)).First(link => link.Boss == boss).Far;
var extra = adjacency[far].First(zone => zone != boss && !BossesOf(galaxy).Contains(zone) && !adjacency[boss].Contains(zone));
var (older, olderZones) = Capture(cache, galaxy);
olderZones[boss].AdjacentZones = olderZones[boss].AdjacentZones.Append(extra).ToArray();
olderZones[extra].AdjacentZones = olderZones[extra].AdjacentZones.Append(boss).ToArray();
var restored = Continue(cache, older, olderZones);
Assert.Equal(2, Provinces.CrossingLinks(AdjacencyOf(restored), BossesOf(restored), ExitOf(restored)).Count(link => link.Boss == boss));
Assert.Contains(restored.Zones[extra], restored.Zones[boss].AdjacentZones);
Assert.Equal(bosses.Length + 1, RegionsOf(restored).Distinct().Count());
}
[Fact]
public void ThePreludeIsNotPruned()
{
var (cache, _, _) = Shipped();
var galaxy = Enumerable.Range(1, 25).Select(seed => TryBuild(cache, true, (uint) seed, Cast(Provinces.DefaultZonesPerProvince))).First(g => g != null);
Assert.Empty(galaxy.BossZones);
var before = AdjacencyOf(galaxy);
galaxy.PlaceGate(galaxy.ResolveFaction(DemoTerminus.Cast.AntagonistFaction));
Assert.Equal(before, AdjacencyOf(galaxy));
}
// ---- provinces
[Fact]