From 4f93c0de681965ca9a2c8cf3e05d8a63c0605b54 Mon Sep 17 00:00:00 2001 From: soul Date: Tue, 27 Oct 2026 17:25:19 +0100 Subject: [PATCH 1/7] Provinces.CrossingLinks: one definition of a boss zone's crossing link, Regions reads it (cut one-exit) Moves Regions' inline exit-side loop into a public CrossingLinks so the pruning that follows reads the same definition. Behaviour unchanged. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ZoneData.cs | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/Assets/Scripts/ServerShared/ZoneData.cs b/Assets/Scripts/ServerShared/ZoneData.cs index 890b2dfef..f27eabc8b 100644 --- a/Assets/Scripts/ServerShared/ZoneData.cs +++ b/Assets/Scripts/ServerShared/ZoneData.cs @@ -282,20 +282,27 @@ 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 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 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(); + } + + // 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 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); From 5df996034316b0c03b7f37d538d34ae9fb28223f Mon Sep 17 00:00:00 2001 From: soul Date: Tue, 27 Oct 2026 17:28:08 +0100 Subject: [PATCH 2/7] Provinces.ExtraExits: the crossing links to prune so a boss zone keeps the one on the exit path (cut one-exit) Pure. Reads CrossingLinks; nothing calls it yet. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ZoneData.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Assets/Scripts/ServerShared/ZoneData.cs b/Assets/Scripts/ServerShared/ZoneData.cs index f27eabc8b..67419e00a 100644 --- a/Assets/Scripts/ServerShared/ZoneData.cs +++ b/Assets/Scripts/ServerShared/ZoneData.cs @@ -298,6 +298,19 @@ public static class Provinces 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. A boss zone that is not on + // exitPath, or whose successor is not one of its crossing links, keeps all of its links: it is never left with none. + public static (int Boss, int Far)[] ExtraExits(int[][] adjacency, IReadOnlyCollection bossZones, int exit, int[] exitPath) => + CrossingLinks(adjacency, bossZones, exit).GroupBy(link => link.Boss) + .SelectMany(links => + { + var at = Array.IndexOf(exitPath, links.Key); + var kept = at >= 0 && at + 1 < exitPath.Length ? exitPath[at + 1] : -1; + return links.Any(link => link.Far == kept) ? links.Where(link => link.Far != kept) : Enumerable.Empty<(int Boss, int Far)>(); + }) + .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 bossZones, int exit) From 84acc9e563fb05c6e1dd42730c79e6ff8011a1f2 Mon Sep 17 00:00:00 2001 From: soul Date: Tue, 27 Oct 2026 17:28:09 +0100 Subject: [PATCH 3/7] 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 --- Assets/Scripts/ServerShared/Galaxy.cs | 29 ++- .../OreGeographyTests.cs | 224 ++++++++++++++++++ 2 files changed, 251 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/ServerShared/Galaxy.cs b/Assets/Scripts/ServerShared/Galaxy.cs index 022c2fdc4..08512f8af 100644 --- a/Assets/Scripts/ServerShared/Galaxy.cs +++ b/Assets/Scripts/ServerShared/Galaxy.cs @@ -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 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 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 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 progressCallback = null) { progressCallback?.Invoke("Calculating Faction Influence"); diff --git a/tests/Aetheria.Shared.Tests/OreGeographyTests.cs b/tests/Aetheria.Shared.Tests/OreGeographyTests.cs index 20c5f820e..de4c7fa7f 100644 --- a/tests/Aetheria.Shared.Tests/OreGeographyTests.cs +++ b/tests/Aetheria.Shared.Tests/OreGeographyTests.cs @@ -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(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] From 018257020072b839437e7371fdb39ec29b6f7a08 Mon Sep 17 00:00:00 2001 From: soul Date: Tue, 27 Oct 2026 18:50:45 +0100 Subject: [PATCH 4/7] DistancesAreOfTheFinalGraph builds its own galaxies so the pruning's distance recompute is under its coverage (cut one-exit) Stryker's per-test coverage credited the shared galaxy cache's first builder alone with Galaxy.OneExitPerRegion's CalculateDistanceMatrix call, so deleting the call survived. Fixture fix, no rule change. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/OreGeographyTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/OreGeographyTests.cs b/tests/Aetheria.Shared.Tests/OreGeographyTests.cs index de4c7fa7f..b8522f8ef 100644 --- a/tests/Aetheria.Shared.Tests/OreGeographyTests.cs +++ b/tests/Aetheria.Shared.Tests/OreGeographyTests.cs @@ -551,7 +551,8 @@ public sealed class OreGeographyTests : IDisposable [Fact] public void DistancesAreOfTheFinalGraph() { - foreach (var galaxy in SectorGalaxies()) + // 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 => TryBuild(Shared, false, (uint) seed)).Where(galaxy => galaxy != null)) { var adjacency = AdjacencyOf(galaxy); for (var zone = 0; zone < adjacency.Length; zone++) From 9fd0fe96e72c4e43b3a0915fe3137732b0a0de0a Mon Sep 17 00:00:00 2001 From: soul Date: Tue, 27 Oct 2026 20:23:32 +0100 Subject: [PATCH 5/7] One-exit tests require every sector seed to build; a generator exception fails instead of skipping the seed (cut one-exit, finding trybuild-masks-generator-crash) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/OreGeographyTests.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/OreGeographyTests.cs b/tests/Aetheria.Shared.Tests/OreGeographyTests.cs index b8522f8ef..5e950d34d 100644 --- a/tests/Aetheria.Shared.Tests/OreGeographyTests.cs +++ b/tests/Aetheria.Shared.Tests/OreGeographyTests.cs @@ -438,9 +438,10 @@ public sealed class OreGeographyTests : IDisposable 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; + // 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] @@ -552,7 +553,7 @@ public sealed class OreGeographyTests : IDisposable 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 => TryBuild(Shared, false, (uint) seed)).Where(galaxy => galaxy != null)) + 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++) From 97aa3718720183c773d3f77b1d439f99dc33ae33 Mon Sep 17 00:00:00 2001 From: soul Date: Tue, 27 Oct 2026 20:23:47 +0100 Subject: [PATCH 6/7] Provinces.ExtraExits throws when a boss zone has no exit-path link to keep instead of silently keeping every link (cut one-exit, finding silent-keep-all-fallback) Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ZoneData.cs | 27 +++++++++++-------- .../OreGeographyTests.cs | 7 +++-- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/Assets/Scripts/ServerShared/ZoneData.cs b/Assets/Scripts/ServerShared/ZoneData.cs index 67419e00a..a08895aaa 100644 --- a/Assets/Scripts/ServerShared/ZoneData.cs +++ b/Assets/Scripts/ServerShared/ZoneData.cs @@ -299,17 +299,22 @@ public static class Provinces } // 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. A boss zone that is not on - // exitPath, or whose successor is not one of its crossing links, keeps all of its links: it is never left with none. - public static (int Boss, int Far)[] ExtraExits(int[][] adjacency, IReadOnlyCollection bossZones, int exit, int[] exitPath) => - CrossingLinks(adjacency, bossZones, exit).GroupBy(link => link.Boss) - .SelectMany(links => - { - var at = Array.IndexOf(exitPath, links.Key); - var kept = at >= 0 && at + 1 < exitPath.Length ? exitPath[at + 1] : -1; - return links.Any(link => link.Far == kept) ? links.Where(link => link.Far != kept) : Enumerable.Empty<(int Boss, int Far)>(); - }) - .ToArray(); + // 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 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. diff --git a/tests/Aetheria.Shared.Tests/OreGeographyTests.cs b/tests/Aetheria.Shared.Tests/OreGeographyTests.cs index 5e950d34d..bc497e88e 100644 --- a/tests/Aetheria.Shared.Tests/OreGeographyTests.cs +++ b/tests/Aetheria.Shared.Tests/OreGeographyTests.cs @@ -543,10 +543,9 @@ public sealed class OreGeographyTests : IDisposable 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})); + // 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(() => Provinces.ExtraExits(diamond, new[] {2}, 6, broken)).Message); } [Fact] From 2822a583ea6cf60480159f576b8009c9d9d153ff Mon Sep 17 00:00:00 2001 From: soul Date: Tue, 27 Oct 2026 20:24:02 +0100 Subject: [PATCH 7/7] Pin CrossingLinks far-zone ordering and a boss zone at the start of the exit path in hand-built graphs (cut one-exit, finding pure-helper-survivors) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/OreGeographyTests.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/OreGeographyTests.cs b/tests/Aetheria.Shared.Tests/OreGeographyTests.cs index bc497e88e..20c1e9043 100644 --- a/tests/Aetheria.Shared.Tests/OreGeographyTests.cs +++ b/tests/Aetheria.Shared.Tests/OreGeographyTests.cs @@ -543,6 +543,17 @@ public sealed class OreGeographyTests : IDisposable 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(() => Provinces.ExtraExits(diamond, new[] {2}, 6, broken)).Message);