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:
commit
8fa1fc21b2
3 changed files with 297 additions and 11 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue