Aetheria/Assets/Scripts/ServerShared/Galaxy.cs
soul 84acc9e563 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>
2026-10-27 17:28:09 +01:00

669 lines
30 KiB
C#

using GameCult.Caching;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using DataStructures.ViliWonka.Heap;
using Ink.Runtime;
using JM.LinqFaster;
using UniRx;
using CultMath;
using static CultMath.math;
using Random = CultMath.Random;
public class Galaxy
{
public Dictionary<Faction, GalaxyZone> HomeZones = new Dictionary<Faction, GalaxyZone>();
public Dictionary<Faction, GalaxyZone> BossZones = new Dictionary<Faction, GalaxyZone>();
public HashSet<GalaxyZone> DiscoveredZones = new HashSet<GalaxyZone>();
public SectorBackgroundSettings Background { get; }
public Faction[] Factions { get; }
public GalaxyZone[] Zones { get; }
public GalaxyZone Entrance { get; }
public GalaxyZone Exit { get; private set; }
public Dictionary<Faction, FactionRelationship> FactionRelationships { get; } = new Dictionary<Faction, FactionRelationship>();
private Action<string> Log { get; }
public bool IsPrelude { get; }
// The run's seed, minted once by GalaxyStage.SeedFrom and carried by the save (SavedGame.Seed). It seeds
// generation, the run's item draws (RunStart) and the zone's drop dice (Zone.DropSeed).
public uint Seed { get; }
// The run's resource provinces, ordered by seed zone index; GalaxyZone.Province is each zone's. Built once, by the
// generation constructors or the save constructor (BuildProvinces), and never by PlaceGate, faction placement or naming.
public GalaxyProvince[] Provinces { get; private set; }
private GalaxyZone[] _exitPath;
private readonly CultCache _cache;
public GalaxyZone[] ExitPath
{
get
{
if(Entrance!=null && Exit != null)
return _exitPath ??= FindPath(Entrance, Exit);
return null;
}
}
public Galaxy(CultCache cultCache, SavedGame savedGame, Action<string> log)
{
IsPrelude = savedGame.IsTutorial;
_cache = cultCache;
Log = log;
Background = savedGame.Background;
Seed = savedGame.Seed;
Factions = savedGame.Factions.Select(faction => cultCache.Get(faction)).ToArray();
for (var i = 0; i < Factions.Length; i++)
{
FactionRelationships[Factions[i]] = savedGame.Relationships[i];
}
var savedZones = savedGame.Zones.Select(zone => cultCache.Get(zone)).ToArray();
Zones = savedZones.Select(zone =>
{
return new GalaxyZone
{
Name = zone.Name,
Position = zone.Position,
PackedContents = zone.Contents
};
}).ToArray();
foreach (var i in savedGame.DiscoveredZones) DiscoveredZones.Add(Zones[i]);
for (var i = 0; i < Zones.Length; i++)
{
Zones[i].AdjacentZones = savedZones[i].AdjacentZones.Select(azi => Zones[azi]).ToList();
Zones[i].Factions = savedZones[i].Factions.Select(mi => Factions[mi]).ToArray();
Zones[i].Owner = savedZones[i].Owner < 0 ? null : Factions[savedZones[i].Owner];
}
HomeZones = savedGame.HomeZones.ToDictionary(
x => Factions[x.Key],
x => Zones[x.Value]);
BossZones = savedGame.BossZones.ToDictionary(
x => Factions[x.Key],
x => Zones[x.Value]);
Entrance = Zones[savedGame.Entrance];
if(savedGame.Exit != -1)
Exit = Zones[savedGame.Exit];
// A save from before ore geography has no provinces: it gets them by the rule generation uses.
BuildProvinces(global::Provinces.DefaultZonesPerProvince, savedGame.Provinces == null ? null : savedZones.Select(zone => zone.Province).ToArray(), savedGame.Provinces);
CalculateDistanceMatrix();
}
public Galaxy(
SectorGenerationSettings settings,
SectorBackgroundSettings background,
CultCache cache,
Action<string> log,
Action<string> progressCallback,
uint seed)
{
_cache = cache;
IsPrelude = false;
Background = background;
Log = log;
var factions = cache.GetAll<Faction>();
Seed = seed != 0 ? seed : throw new ArgumentOutOfRangeException(nameof(seed), "a generated galaxy takes its run's seed from GalaxyStage, never zero");
var random = new Random(Seed);
Factions = factions.OrderBy(x => random.NextFloat()).Take(settings.MegaCount).ToArray();
foreach (var f in Factions) FactionRelationships[f] = FactionRelationship.Neutral;
Zones = GenerateZones(settings.ZoneCount, ref random, progressCallback);
GenerateLinks(settings.LinkDensity, progressCallback);
CalculateDistanceMatrix(progressCallback);
// Exit is the most isolated zone (highest total distance to all other zones)
Exit = Zones.MaxBy(z => z.Isolation);
// Entrance is the zone furthest from the exit
Entrance = Zones.MaxBy(z => Exit.Distance[z]);
DiscoveredZones.Add(Entrance);
foreach(var z in Entrance.AdjacentZones) DiscoveredZones.Add(z);
PlaceFactionsMain(settings.BossCount, progressCallback);
BuildProvinces(settings.ProvinceZones);
CalculateFactionInfluence(progressCallback);
NamePlaces(ref random, progressCallback);
progressCallback?.Invoke("Done!");
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, 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);
var provinces = new List<GalaxyProvince>();
foreach (var region in Enumerable.Range(0, Zones.Length).GroupBy(zone => regionOf[zone]).OrderBy(group => group.Key))
{
var seeds = region.Select(zone => seedOf[zone]).Distinct().OrderBy(seed => seed).ToArray();
var hand = global::Provinces.Deal(region.Key, seeds.Length, Seed, _cache);
for (var place = 0; place < seeds.Length; place++)
{
var type = types != null && types.TryGetValue(seeds[place], out var saved) && _cache.Get(saved) != null ? saved : hand[place];
var province = new GalaxyProvince {Seed = Zones[seeds[place]], Type = type, Zones = region.Where(zone => seedOf[zone] == seeds[place]).Select(zone => Zones[zone]).ToArray()};
foreach (var zone in province.Zones) zone.Province = province;
provinces.Add(province);
}
}
Provinces = provinces.OrderBy(province => index[province.Seed]).ToArray();
}
public Faction ResolveFaction(string name)
{
return _cache.GetAll<Faction>().FirstOrDefault(f => f.Name.StartsWith(name, StringComparison.InvariantCultureIgnoreCase));
}
// 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");
// 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.
public void PlaceGate(Faction antagonist)
{
BossZones[antagonist] = HomeZones[antagonist];
Exit = HomeZones[antagonist];
}
// How many jumps a faction's influence reaches in this galaxy: its authored distance, halved (rounding up) in a
// prelude. Derived, never written back to the catalog's Faction, so one galaxy's generation cannot change the next.
public int InfluenceOf(Faction faction) =>
IsPrelude ? (faction.InfluenceDistance + 1) / 2 : faction.InfluenceDistance;
public Galaxy(
TutorialGenerationSettings settings,
SectorBackgroundSettings background,
CultCache cache,
PlayerSettings playerSettings,
DirectoryInfo narrativeDirectory,
Action<string> log,
Action<string> progressCallback,
uint seed)
{
_cache = cache;
IsPrelude = true;
Background = background;
Log = log;
Seed = seed != 0 ? seed : throw new ArgumentOutOfRangeException(nameof(seed), "a generated galaxy takes its run's seed from GalaxyStage, never zero");
var random = new Random(Seed);
var factions = new List<Faction>();
var protagonistFaction = ResolveCast(nameof(settings.ProtagonistFaction), settings.ProtagonistFaction);
factions.Add(protagonistFaction);
var antagonistFaction = ResolveCast(nameof(settings.AntagonistFaction), settings.AntagonistFaction);
factions.Add(antagonistFaction);
var bufferFaction = ResolveCast(nameof(settings.BufferFaction), settings.BufferFaction);
factions.Add(bufferFaction);
// A cast without a quest faction places no quest zone.
var questFaction = string.IsNullOrEmpty(settings.QuestFaction)
? null
: ResolveCast(nameof(settings.QuestFaction), settings.QuestFaction);
if (questFaction != null) factions.Add(questFaction);
var neutralFactions = settings.NeutralFactions
.Select(name => ResolveCast(nameof(settings.NeutralFactions), name))
.ToArray();
factions.AddRange(neutralFactions);
Factions = factions.ToArray();
foreach (var faction in Factions) FactionRelationships[faction] = FactionRelationship.Neutral;
Zones = GenerateZones(settings.ZoneCount, ref random, progressCallback);
GenerateLinks(settings.LinkDensity, progressCallback);
CalculateDistanceMatrix(progressCallback);
HomeZones[protagonistFaction] = Zones
.MaxBy(z => ConnectedRegion(z, InfluenceOf(protagonistFaction)).Count);
HomeZones[antagonistFaction] = Zones
.MaxBy(z => ConnectedRegion(z, InfluenceOf(antagonistFaction)).Count * z.Distance[HomeZones[protagonistFaction]]);
HomeZones[protagonistFaction] = Zones
.MaxBy(z => ConnectedRegion(z, InfluenceOf(protagonistFaction)).Count * sqrt(z.Distance[HomeZones[antagonistFaction]]));
// var antagonistRegion = ConnectedRegion(HomeZones[antagonistFaction], antagonistFaction.InfluenceDistance);
// var protagonistRegion = ConnectedRegion(HomeZones[protagonistFaction], protagonistFaction.InfluenceDistance);
// Place the buffer faction in a zone where it has equal distance to the pro/antagonist HQs and where it can control the most territory
var bufferDistance = Zones.Min(z => abs(z.Distance[HomeZones[antagonistFaction]] - z.Distance[HomeZones[protagonistFaction]]));
var potentialBufferZones = Zones
.Where(z => abs(z.Distance[HomeZones[antagonistFaction]] - z.Distance[HomeZones[protagonistFaction]]) == bufferDistance);
HomeZones[bufferFaction] = potentialBufferZones.MaxBy(z => ConnectedRegion(z, InfluenceOf(bufferFaction)).Count);
// Place neutral headquarters away from existing factions while also maximizing territory
foreach (var faction in neutralFactions)
{
HomeZones[faction] = Zones.MaxBy(z =>
ConnectedRegion(z, InfluenceOf(faction)).Count *
HomeZones.Values.Aggregate(1f, (i, os) => i * sqrt(os.Distance[z])));
}
CalculateFactionInfluence(progressCallback);
if (questFaction != null)
{
var potentialQuestZones = Zones
.Where(z => z.Factions.Contains(antagonistFaction) && z.Factions.Contains(bufferFaction));
if (potentialQuestZones.Any())
HomeZones[questFaction] = potentialQuestZones
.MaxBy(z => z.Distance[HomeZones[antagonistFaction]] * ConnectedRegion(z, InfluenceOf(questFaction)).Count);
else
HomeZones[questFaction] = Zones
.Where(z => z.Factions.Contains(antagonistFaction))
.MinBy(z=>z.Distance[HomeZones[bufferFaction]]);
CalculateFactionInfluence(progressCallback);
}
Entrance = Zones.Where(z => z.Owner == null).MinBy(z => z.Distance[HomeZones[protagonistFaction]]);
DiscoveredZones.Add(Entrance);
foreach(var z in Entrance.AdjacentZones) DiscoveredZones.Add(z);
CalculateFactionInfluence(progressCallback);
// No boss zone and no Exit yet (PlaceGate comes after): the prelude is one region.
BuildProvinces(settings.ProvinceZones);
NamePlaces(ref random, progressCallback);
// progressCallback?.Invoke("Weaving Narrative");
// var processor = new StoryProcessor(playerSettings, narrativeDirectory, this, ref random, Log);
// processor.ProcessStories();
progressCallback?.Invoke("Done!");
if(progressCallback!=null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao
}
private GalaxyZone[] GenerateZones(int zoneCount, ref Random random, Action<string> progressCallback = null)
{
var outputSamples = WeightedSampleElimination.GeneratePoints(zoneCount,
ref random,
Background.CloudDensity,
v => (.2f - lengthsq(v - float2(.5f))) * 4,
progressCallback);
return outputSamples.Select(v => new GalaxyZone {Position = v}).ToArray();
}
private void PlaceFactionsMain(int bossCount, Action<string> progressCallback = null)
{
progressCallback?.Invoke("Finding Chokepoints");
if(progressCallback!=null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao
// Find all zones on the exit path where removing that zone would disconnect the entrance from the exit
// Disregard "corridor" zones with only two adjacent zones
var chokePoints = ExitPath
.Where(z => z.AdjacentZones.Count > 2 && !ConnectedRegion(Entrance, z).Contains(Exit));
progressCallback?.Invoke("Placing Factions");
if (progressCallback != null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao
// Choose some megas to have bosses placed: the factions whose boss Loadout resolves
var bossMegas = Factions
.Where(m => Loadouts.BossOf(_cache, m, out _) != null)
.Take(bossCount)
.ToArray();
// Place boss zones along the critical path as far apart from each other as possible
foreach (var mega in bossMegas)
{
BossZones[mega] = chokePoints.MaxBy(z =>
Exit.Distance[z] * Entrance.Distance[z] *
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)
{
HomeZones[mega] = ConnectedRegion(BossZones[mega], InfluenceOf(mega))
.MaxBy(z =>
ConnectedRegion(z, InfluenceOf(mega)).Count *
HomeZones.Values.Aggregate(1f, (i, os) => i * sqrt(os.Distance[z])));
}
// Place remaining headquarters away from existing megas while also maximizing territory
foreach (var mega in Factions.Where(m => !bossMegas.Contains(m)))
{
HomeZones[mega] = Zones.MaxBy(z =>
pow(ConnectedRegion(z, InfluenceOf(mega)).Count, HomeZones.Count) *
Exit.Distance[z] * Entrance.Distance[z] *
HomeZones.Values.Aggregate(1f, (i, os) => i * sqrt(os.Distance[z])) *
BossZones.Values.Aggregate(1f, (i, os) => i * sqrt(os.Distance[z])));
}
}
// 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");
if (progressCallback != null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao
// Assign faction presence
foreach (var zone in Zones)
{
// Factions are present in all zones within their sphere of influence
zone.Factions = Factions
.Where(f => HomeZones.ContainsKey(f))
.Where(f => zone.Distance[HomeZones[f]] <= InfluenceOf(f))
.ToArray();
// Owner of a zone is the faction with the nearest headquarters
var nearestFaction = Factions
.Where(f => HomeZones.ContainsKey(f))
.MinBy(f => (float)zone.Distance[HomeZones[f]]);
if (zone.Distance[HomeZones[nearestFaction]] <= InfluenceOf(nearestFaction))
zone.Owner = nearestFaction;
}
}
// Each faction names the zones it owns from its own curated list (ruling faction-curated-place-names): nearest its
// home first, so the home zone takes the capital, which is Places[0]. A zone left over when a list is spent, and every
// zone nobody owns, takes a catalogue designation. A name is never used twice, ignoring case.
internal void NamePlaces(ref Random random, Action<string> progressCallback = null)
{
progressCallback?.Invoke("Naming places");
var used = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var zone in Zones) zone.Name = null;
foreach (var faction in Factions)
{
if (!HomeZones.TryGetValue(faction, out var home)) continue;
var places = (faction.Places ?? Array.Empty<FactionPlace>()).Select(p => p?.Name).Where(FactionPlace.IsValidName).ToArray();
var next = 0;
var owned = Zones.Select((zone, index) => (zone, index)).Where(z => z.zone.Owner == faction)
.OrderBy(z => z.zone.Distance[home]).ThenBy(z => z.index);
foreach (var (zone, _) in owned)
{
while (next < places.Length && used.Contains(places[next])) next++;
if (next == places.Length) break;
zone.Name = places[next];
used.Add(places[next++]);
}
}
foreach (var zone in Zones.Where(z => z.Name == null))
{
string designation;
do designation = $"EAC-{random.NextInt(9999)}";
while (!used.Add(designation));
zone.Name = designation;
}
}
private void GenerateLinks(float linkDensity, Action<string> progressCallback = null)
{
progressCallback?.Invoke("Triangulating Zone Positions");
if (progressCallback != null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao
// The Delaunay triangulation connects adjacent sectors, every zone among them
var links = Delaunay.Edges(Zones.Select(z => z.Position).ToArray())
.Select(edge => (Zones[edge.a], Zones[edge.b]))
.ToList();
progressCallback?.Invoke("Eliminating Zone Links");
if (progressCallback != null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao
// foreach (var link in links.ToArray())
// {
// foreach (var zone in Zones)
// {
// if(zone != link.Item1 && zone != link.Item2)
// if (distance_to_segment(zone.Position, link.Item1.Position, link.Item2.Position, out _) < minLineSeparation)
// links.Remove(link);
// }
// }
foreach (var link in links)
{
link.Item1.AdjacentZones.Add(link.Item2);
link.Item2.AdjacentZones.Add(link.Item1);
}
float LinkWeight((GalaxyZone, GalaxyZone) link)
{
return 1 / saturate(Background.CloudDensity((link.Item1.Position + link.Item2.Position) / 2)) *
lengthsq(link.Item1.Position - link.Item2.Position) *
(link.Item1.AdjacentZones.Count - 1) * (link.Item2.AdjacentZones.Count - 1);
}
var heap = new MaxHeap<(GalaxyZone, GalaxyZone)>(links.Count);
foreach (var link in links) heap.PushObj(link, LinkWeight(link));
while (heap.Count > linkDensity * links.Count)
{
var link = heap.PopObj();
if (ConnectedRegion(link.Item1, link.Item1, link.Item2).Contains(link.Item2))
{
link.Item1.AdjacentZones.Remove(link.Item2);
link.Item2.AdjacentZones.Remove(link.Item1);
foreach (var secondary in link.Item1.AdjacentZones)
{
heap.SetValue((link.Item1, secondary), LinkWeight((link.Item1, secondary)));
heap.SetValue((secondary, link.Item1), LinkWeight((secondary, link.Item1)));
}
foreach (var secondary in link.Item2.AdjacentZones)
{
heap.SetValue((link.Item2, secondary), LinkWeight((link.Item2, secondary)));
heap.SetValue((secondary, link.Item2), LinkWeight((secondary, link.Item2)));
}
}
}
}
// Cache distance matrix and calculate isolation for every zone (used extensively for placing stuff)
private void CalculateDistanceMatrix(Action<string> progressCallback = null)
{
progressCallback?.Invoke("Calculating Distance Matrix");
if(progressCallback!=null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao
foreach (var zone in Zones)
{
zone.Distance = ConnectedRegionDistance(zone);
zone.Isolation = zone.Distance.Sum(x => x.Value);
}
}
class DijkstraVertex
{
public float Cost;
public DijkstraVertex Parent;
public GalaxyZone Zone;
}
public Dictionary<GalaxyZone, int> ConnectedRegionDistance(GalaxyZone v)
{
var members = new Dictionary<GalaxyZone, int> {{v, 0}};
int cost = 1;
while (true)
{
var lastCount = members.Count;
// For each member, add all vertices that are connected to it but are not already a member
// Also, if there is a zone being ignored, do not traverse across it
foreach (var member in members.Keys.ToArray())
foreach (var adjacentZone in member.AdjacentZones)
{
if(!members.ContainsKey(adjacentZone))
members.Add(adjacentZone, cost);
}
// If we have stopped finding neighbors, stop traversing
if (members.Count == lastCount)
return members;
cost++;
}
}
public HashSet<GalaxyZone> ConnectedRegion(
GalaxyZone v,
GalaxyZone ignoreLinkSource,
GalaxyZone ignoreLinkTarget,
int maxDistance = int.MaxValue)
{
return ConnectedRegion(v, x => x.Item1 != ignoreLinkSource || x.Item2 != ignoreLinkTarget, maxDistance);
}
public HashSet<GalaxyZone> ConnectedRegion(
GalaxyZone v,
GalaxyZone ignoreZone,
int maxDistance = int.MaxValue)
{
return ConnectedRegion(v, x => x.Item2 != ignoreZone, maxDistance);
}
public HashSet<GalaxyZone> ConnectedRegion(
GalaxyZone v,
int maxDistance = int.MaxValue)
{
return ConnectedRegion(v, x => true, maxDistance);
}
public HashSet<GalaxyZone> ConnectedRegion(
GalaxyZone v,
Predicate<(GalaxyZone source, GalaxyZone target)> linkFilter,
int maxDistance = int.MaxValue)
{
var visited = new HashSet<GalaxyZone> {v};
int cost = 1;
while (true)
{
var lastCount = visited.Count;
// For each member, add all nodes that are connected to it but have not been visited
// Also, if there is a zone being ignored, do not traverse across it
foreach (var zone in visited.ToArray())
foreach (var adjacentZone in zone.AdjacentZones)
{
if(!visited.Contains(adjacentZone) && linkFilter((zone, adjacentZone)))
visited.Add(adjacentZone);
}
// If we have stopped finding neighbors, stop traversing
if (visited.Count == lastCount || cost == maxDistance)
return visited;
cost++;
}
}
public GalaxyZone[] FindPath(GalaxyZone source, GalaxyZone target, bool bestFirst = false)
{
MinHeap<DijkstraVertex> unsearchedNodes = new MinHeap<DijkstraVertex>();
unsearchedNodes.PushObj(new DijkstraVertex{Zone = source}, 0);
var searched = new HashSet<GalaxyZone>();
while (true)
{
if(unsearchedNodes.Count == 0) return null; // No nodes left unsearched
var s = unsearchedNodes.PopObj(); // Lowest cost unsearched node
if (s.Zone == target) // We found the path
{
Stack<DijkstraVertex> path = new Stack<DijkstraVertex>(); // Since we start at the end, use a LIFO collection
path.Push(s);
while(path.Peek().Parent!=null) // Keep pushing until we reach the start, which has no parent
path.Push(path.Peek().Parent);
return path.Select(dv => dv.Zone).ToArray();
}
// For each adjacent star (filter already visited stars unless heuristic is in use)
IEnumerable<GalaxyZone> zonesToSearch = s.Zone.AdjacentZones;
if (!bestFirst) zonesToSearch = zonesToSearch.Where(z => !searched.Contains(z));
foreach (var dijkstraStar in zonesToSearch
// Cost is parent cost plus distance squared
.Select(zone => new DijkstraVertex {Parent = s, Zone = zone, Cost = s.Cost + lengthsq(s.Zone.Position - zone.Position)}))
// Add new member to list, sorted by cost plus optional heuristic
unsearchedNodes.PushObj(dijkstraStar, bestFirst ? dijkstraStar.Cost + lengthsq(dijkstraStar.Zone.Position - target.Position) : dijkstraStar.Cost);
searched.Add(s.Zone);
}
}
}
public class GalaxyZone
{
public string Name;
public float2 Position;
public List<GalaxyZone> AdjacentZones = new List<GalaxyZone>();
public int Isolation;
public Dictionary<GalaxyZone, int> Distance;
public Faction[] Factions;
public Faction Owner;
public Zone Contents;
public ZonePack PackedContents;
public GalaxyProvince Province;
public bool NamedZone;
public List<LocationStory> Locations = new List<LocationStory>();
}
// A resource province (ruling provinces-within-regions): a contiguous group of zones inside one region, grown from Seed,
// whose Type (ProvinceData, unset when the catalog holds none) weights the kinds and ores of its belts.
public class GalaxyProvince
{
public GalaxyZone Seed;
public CultRecordRef<ProvinceData> Type;
public GalaxyZone[] Zones;
}
public class GalaxyQuest
{
public Story Story;
public Dictionary<string, LocationStory> KnotLocations = new Dictionary<string, LocationStory>();
}
public class LocationStory
{
public GalaxyZone Zone;
public string FileName;
public string Name;
public Story Story;
public Faction Faction;
public SecurityLevel Security;
public LocationType Type;
public int Turrets;
public Dictionary<string, List<GalaxyQuest>> KnotQuests = new Dictionary<string, List<GalaxyQuest>>();
}