Aetheria/Assets/Scripts/ServerShared/Galaxy.cs
Meta Vens e56af28892 Galaxy links zones along a double-precision Delaunay triangulation: the vendored float MIConvexHull dropped zones, so ~2% of galaxies threw
The vendored MIConvexHull (NIH, ported from double to float) returned triangulations that left whole sets of
interior zones with no cell at all (prelude seed 103: 22 of 64 zones; main seed 4247651650: 182 of 196). Those
zones had no link, Distance lacked them, and the constructor threw KeyNotFoundException placing the antagonist
home (prelude) or the entrance (main). Galaxy was its only consumer; Delaunay.Edges (Bowyer-Watson, double) replaces
it, and the 3,896 vendored lines go.

The three RestoredHullsTests loops that skipped seeds whose galaxy threw now require every seed to build, and the
stale comment that Galaxy mutates Faction.InfluenceDistance goes with them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:59:09 +02:00

575 lines
24 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 NameGeneratorSettings NameGeneratorSettings { get; }
public Faction[] Factions { get; }
public GalaxyZone[] Zones { get; }
public GalaxyZone Entrance { get; }
public GalaxyZone Exit { get; }
public Dictionary<Faction, FactionRelationship> FactionRelationships { get; } = new Dictionary<Faction, FactionRelationship>();
private Action<string> Log { get; }
public bool IsPrelude { get; }
private HashSet<CultRecordKey> _containedFactions;
private GalaxyZone[] _exitPath;
private Dictionary<Faction, MarkovNameGenerator> _nameGenerators = new Dictionary<Faction, MarkovNameGenerator>();
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;
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];
CalculateDistanceMatrix();
}
public Galaxy(
SectorGenerationSettings settings,
SectorBackgroundSettings background,
NameGeneratorSettings nameGeneratorSettings,
CultCache cache,
Action<string> log,
Action<string> progressCallback = null,
uint seed = 0)
{
_cache = cache;
IsPrelude = false;
Background = background;
Log = log;
var factions = cache.GetAll<Faction>();
var random = new Random(seed == 0 ? (uint) (DateTime.Now.Ticks % uint.MaxValue) : 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);
CalculateFactionInfluence(progressCallback);
GenerateNames(cache, nameGeneratorSettings, 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
}
public Faction ResolveFaction(string name)
{
return _cache.GetAll<Faction>().FirstOrDefault(f => f.Name.StartsWith(name, StringComparison.InvariantCultureIgnoreCase));
}
// 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,
NameGeneratorSettings nameGeneratorSettings,
CultCache cache,
PlayerSettings playerSettings,
DirectoryInfo narrativeDirectory,
Action<string> log,
Action<string> progressCallback = null,
uint seed = 0)
{
_cache = cache;
IsPrelude = true;
Background = background;
Log = log;
var random = new Random(seed == 0 ? (uint) (DateTime.Now.Ticks % uint.MaxValue) : seed);
var factions = new List<Faction>();
var protagonistFaction = ResolveFaction(settings.ProtagonistFaction);
factions.Add(protagonistFaction);
var antagonistFaction = ResolveFaction(settings.AntagonistFaction);
factions.Add(antagonistFaction);
var bufferFaction = ResolveFaction(settings.BufferFaction);
factions.Add(bufferFaction);
var questFaction = ResolveFaction(settings.QuestFaction);
factions.Add(questFaction);
var neutralFactions = settings.NeutralFactions
.Select(ResolveFaction)
.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);
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);
GenerateNames(cache, nameGeneratorSettings, 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 based on whether a boss hull is assigned
var bossMegas = Factions
.Where(m => m.BossHull.IsSet())
.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]));
}
// 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])));
}
}
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;
}
}
private void GenerateNames(CultCache cache,
NameGeneratorSettings nameGeneratorSettings,
ref Random random,
Action<string> progressCallback = null)
{
var used = new HashSet<string>();
for (var i = 0; i < Factions.Length; i++)
{
progressCallback?.Invoke($"Feeding Markov Chains: {i + 1} / {Factions.Length}");
//if(progressCallback!=null) Thread.Sleep(250); // Inserting Delay to make it seem like it's doing more work lmao
var faction = Factions[i];
_nameGenerators[faction] = new MarkovNameGenerator(ref random, cache.Get(faction.GeonameFile).Names, nameGeneratorSettings, used);
}
// Generate zone name using the owner's name generator, otherwise assign catalogue ID
foreach (var zone in Zones)
{
if (zone.Owner != null)
{
zone.Name = _nameGenerators[zone.Owner].NextName.Trim();
}
else
{
zone.Name = $"EAC-{random.NextInt(9999).ToString()}";
}
}
}
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);
}
}
public bool ContainsFaction(CultRecordRef<Faction> faction)
{
_containedFactions ??= new HashSet<CultRecordKey>(Factions.Select(f => _cache.RefOf(f).Key));
return _containedFactions.Contains(faction.Key);
}
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 bool NamedZone;
public List<LocationStory> Locations = new List<LocationStory>();
}
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>>();
}