Aetheria/Assets/Scripts/ServerShared/Galaxy.cs
Meta Vens 740e1ee781 Place names: Faction.Places, Galaxy.NamePlaces and the place-names command
Each faction names the zones it owns from its own curated list (ruling
faction-curated-place-names): the home zone takes Places[0], the rest follow
in distance order, a spent or absent list falls back to unique catalogue
designations. Galaxy.NamePlaces replaces the Markov GenerateNames body. The
place-names command authors the 15-row table and, until commit 3, purges the
NameFile records and GeonameFile links while their type still exists.

The shipped catalog carries no Places yet (commit 2), so the tests that read
shipped lists fail at this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:10:17 +02:00

597 lines
25 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; private set; }
public Dictionary<Faction, FactionRelationship> FactionRelationships { get; } = new Dictionary<Faction, FactionRelationship>();
private Action<string> Log { get; }
public bool IsPrelude { get; }
// The seed this galaxy was generated at, which also seeds its run's item draws (RunStart); zero for a galaxy
// restored from a save.
public uint Seed { get; }
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;
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>();
Seed = seed == 0 ? (uint) (DateTime.Now.Ticks % uint.MaxValue) : seed;
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);
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
}
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,
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;
Seed = seed == 0 ? (uint) (DateTime.Now.Ticks % uint.MaxValue) : seed;
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);
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 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;
}
}
// 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(n => !string.IsNullOrEmpty(n)).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 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>>();
}