Galaxy.OneExitPerRegion: sector generation prunes each boss zone's extra exits, then recomputes distances (cut one-exit)

Ruling one-exit-per-region: one exit to defend per region. Called once in
PlaceFactionsMain after the boss loop and before home placement; BuildProvinces
and the pruning share ZoneGraph. Saves and the prelude never prune. Tests pin the
rule on sector seeds 1-60, the pure helpers on hand-built graphs, and save/load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-27 17:28:09 +01:00
commit 84acc9e563
2 changed files with 251 additions and 2 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

@ -407,6 +407,230 @@ 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()
{
var galaxies = Galaxies(false, OneExitSeeds).ToArray();
Assert.True(galaxies.Length >= 50, $"only {galaxies.Length} of {OneExitSeeds} sector galaxies were built");
return galaxies;
}
[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));
// A boss zone off the path, last on it, or followed by a zone it has no crossing link to is never left with fewer links.
Assert.Empty(Provinces.ExtraExits(diamond, new[] {2}, 6, new[] {0, 1, 5, 6}));
Assert.Empty(Provinces.ExtraExits(diamond, new[] {2}, 6, new[] {0, 1, 2}));
Assert.Empty(Provinces.ExtraExits(diamond, new[] {2}, 6, new[] {0, 1, 2, 5, 6}));
}
[Fact]
public void DistancesAreOfTheFinalGraph()
{
foreach (var galaxy in SectorGalaxies())
{
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]