using GameCult.Aetheria.State.Verse; public sealed record AetheriaDaemonTutorialFactionInput( string CorporationKey, string Name, string ShortName, int InfluenceDistance) { public IReadOnlyList TrainingNames { get; init; } = Array.Empty(); public string BossHullItemKey { get; init; } = ""; } public sealed record AetheriaDaemonTutorialTopologySettings( int ZoneCount, float LinkDensity, string ProtagonistFaction, string AntagonistFaction, string BufferFaction, IReadOnlyList NeutralFactions, string QuestFaction) { public static AetheriaDaemonTutorialTopologySettings Fossil { get; } = new( 64, 0.5f, "Miss", "Zhe", "Luc", new[] { "Aero", "Finch" }, "Adras"); } public sealed record AetheriaDaemonTutorialZoneTopology( int ZoneIndex, string Name, float X, float Y, IReadOnlyList AdjacentZoneIndices, IReadOnlyList FactionKeys, string OwnerFactionKey); public sealed record AetheriaDaemonTutorialTopology( uint GenerationSeed, float NoisePosition, int EntranceZoneIndex, IReadOnlyList DiscoveredZoneIndices, IReadOnlyDictionary HomeZoneByFactionKey, IReadOnlyList Zones) { public int ExitZoneIndex { get; init; } = -1; public IReadOnlyDictionary BossZoneByFactionKey { get; init; } = new Dictionary(StringComparer.Ordinal); } /// /// Daemon-owned migration of the fossil tutorial galaxy topology. This organ /// deliberately stops before zone contents: materialization consumes this /// result, but cannot change its graph, faction placement, or discovery truth. /// public static class AetheriaDaemonTutorialTopologyGenerator { internal const int CandidateMultiplier = 8; public static AetheriaDaemonTutorialTopology GenerateFossil( IReadOnlyList factions, uint seed, AetheriaDaemonTutorialTopologySettings? settings = null) { var noiseRandom = new CultMath.Random(seed ^ 0x74A7_91D3u); float noisePosition; var attempts = 0; do { if (++attempts > 1_000_000) throw new InvalidOperationException("Tutorial cloud field did not produce an admissible entrance region."); noisePosition = noiseRandom.NextFloat(0, 1000); } while (TutorialCloudDensity(0.5f, 0.5f, noisePosition) < 0.5f); return Generate( factions, seed, (x, y) => TutorialCloudDensity(x, y, noisePosition), settings) with { NoisePosition = noisePosition }; } public static float TutorialCloudDensity(float x, float y, float noisePosition) { var point = new CultMath.float2(x + noisePosition, y + noisePosition); var frequency = 0.1f; var amplitude = 0.5f; var sum = 0f; for (var octave = 0; octave < 10; octave++) { var noise = MathF.Abs(CultMath.math.simplex_noise(point * frequency)); sum += (octave < 4 ? 1f - noise : noise) * amplitude; frequency *= 2f; amplitude *= 0.7f; } return MathF.Pow(sum + 0.3f, 10f) * 0.01f; } public static IReadOnlyList ResolveFossilFactions( AetheriaRuntimeCatalogSnapshot catalog, AetheriaDaemonTutorialTopologySettings? settings = null) { settings ??= AetheriaDaemonTutorialTopologySettings.Fossil; var requested = new[] { settings.ProtagonistFaction, settings.AntagonistFaction, settings.BufferFaction } .Concat(settings.NeutralFactions) .Append(settings.QuestFaction) .ToArray(); return requested.Select(name => { var matches = (catalog.Corporations ?? Array.Empty()) .Where(value => MatchesAuthoredSelector(value, name)) .ToArray(); if (matches.Length == 0) throw new InvalidDataException($"Tutorial generation requires the authored faction '{name}'."); if (matches.Length > 1) throw new InvalidDataException( $"Tutorial faction selector '{name}' is ambiguous: {string.Join(", ", matches.Select(value => value.Name))}."); var match = matches[0]; if (string.IsNullOrWhiteSpace(match.CorporationKey)) throw new InvalidDataException($"Tutorial faction '{name}' has no stable corporation key."); var nameFile = catalog.FindNameFile(match.GeonameFileKey); if (nameFile == null || nameFile.Names.Count == 0) throw new InvalidDataException($"Tutorial faction '{name}' has no typed geoname corpus."); return new AetheriaDaemonTutorialFactionInput( match.CorporationKey, match.Name ?? "", match.ShortName ?? "", match.InfluenceDistance) { TrainingNames = nameFile.Names }; }).ToArray(); } private static bool MatchesAuthoredSelector(AetheriaRuntimeCorporation corporation, string selector) { return string.Equals(corporation.CorporationKey, selector, StringComparison.OrdinalIgnoreCase) || StartsWithSelector(corporation.ShortName, selector) || StartsWithSelector(corporation.Name, selector); } private static bool StartsWithSelector(string? value, string selector) => !string.IsNullOrWhiteSpace(value) && value.StartsWith(selector, StringComparison.OrdinalIgnoreCase); public static AetheriaDaemonTutorialTopology Generate( IReadOnlyList factions, uint seed, Func cloudDensity, AetheriaDaemonTutorialTopologySettings? settings = null) { settings ??= AetheriaDaemonTutorialTopologySettings.Fossil; if (settings.ZoneCount < 3) throw new ArgumentOutOfRangeException(nameof(settings), "Tutorial topology requires at least three zones."); if (cloudDensity == null) throw new ArgumentNullException(nameof(cloudDensity)); var roles = ResolveRoles(factions, settings); var random = new CultMath.Random(seed); var points = GeneratePoints(settings.ZoneCount, ref random, cloudDensity); var links = DelaunayLinks(points); PruneLinks(points, links, settings.LinkDensity, cloudDensity); var distances = BuildDistances(points.Length, links); var home = PlaceFactions(points.Length, links, distances, roles); var influence = roles.All.ToDictionary( faction => faction.CorporationKey, faction => Math.Max(0, (faction.InfluenceDistance + 1) / 2), StringComparer.Ordinal); var zoneFactions = new string[points.Length][]; var owners = new string[points.Length]; for (var zoneIndex = 0; zoneIndex < points.Length; zoneIndex++) { zoneFactions[zoneIndex] = roles.All .Where(faction => distances[zoneIndex, home[faction.CorporationKey]] <= influence[faction.CorporationKey]) .Select(faction => faction.CorporationKey) .ToArray(); owners[zoneIndex] = roles.All .Where(faction => distances[zoneIndex, home[faction.CorporationKey]] <= influence[faction.CorporationKey]) .OrderBy(faction => distances[zoneIndex, home[faction.CorporationKey]]) .ThenBy(faction => Array.IndexOf(roles.All, faction)) .Select(faction => faction.CorporationKey) .FirstOrDefault() ?? ""; } var entranceCandidates = Enumerable.Range(0, points.Length) .Where(index => owners[index].Length == 0) .ToArray(); if (entranceCandidates.Length == 0) throw new InvalidOperationException("Tutorial topology produced no unowned entrance zone."); var entrance = entranceCandidates .OrderBy(index => distances[index, home[roles.Protagonist.CorporationKey]]) .ThenBy(index => index) .First(); var discovered = new[] { entrance } .Concat(Neighbors(entrance, links)) .Distinct() .OrderBy(index => index) .ToArray(); var nameSeed = (uint)random.NextInt(1, int.MaxValue); var zoneNames = GenerateNames(roles.All, owners, nameSeed); var zones = Enumerable.Range(0, points.Length) .Select(index => new AetheriaDaemonTutorialZoneTopology( index, zoneNames[index], points[index].X, points[index].Y, Neighbors(index, links).OrderBy(value => value).ToArray(), zoneFactions[index], owners[index])) .ToArray(); return new AetheriaDaemonTutorialTopology(seed, float.NaN, entrance, discovered, home, zones); } private static TutorialRoles ResolveRoles( IReadOnlyList factions, AetheriaDaemonTutorialTopologySettings settings) { AetheriaDaemonTutorialFactionInput Resolve(string name) { var matches = factions.Where(value => string.Equals(value.CorporationKey, name, StringComparison.OrdinalIgnoreCase) || StartsWithSelector(value.ShortName, name) || StartsWithSelector(value.Name, name)) .ToArray(); if (matches.Length == 0) throw new InvalidDataException($"Tutorial topology is missing faction '{name}'."); if (matches.Length > 1) throw new InvalidDataException( $"Tutorial topology faction selector '{name}' is ambiguous: {string.Join(", ", matches.Select(value => value.Name))}."); return matches[0]; } var protagonist = Resolve(settings.ProtagonistFaction); var antagonist = Resolve(settings.AntagonistFaction); var buffer = Resolve(settings.BufferFaction); var neutrals = settings.NeutralFactions.Select(Resolve).ToArray(); var quest = Resolve(settings.QuestFaction); var all = new[] { protagonist, antagonist, buffer } .Concat(neutrals) .Append(quest) .DistinctBy(value => value.CorporationKey, StringComparer.Ordinal) .ToArray(); if (all.Length != 4 + neutrals.Length) throw new InvalidDataException("Tutorial faction roles must resolve to distinct corporations."); return new TutorialRoles(protagonist, antagonist, buffer, neutrals, quest, all); } internal static string[] GenerateNames( IReadOnlyList factions, IReadOnlyList ownerKeys, uint seed, int order = 3) { var generators = new Dictionary(StringComparer.Ordinal); foreach (var faction in factions) { if (faction.TrainingNames.Count == 0) throw new InvalidDataException($"Tutorial faction '{faction.ShortName}' has no geoname training rows."); generators[faction.CorporationKey] = new TutorialMarkovNameGenerator( seed, faction.TrainingNames, order: order, minimumLength: 5, maximumLength: 10); } var catalogRandom = new CultMath.Random(seed); var used = new HashSet(StringComparer.Ordinal); var output = new string[ownerKeys.Count]; for (var zoneIndex = 0; zoneIndex < ownerKeys.Count; zoneIndex++) { var owner = ownerKeys[zoneIndex]; if (owner.Length == 0) { string candidate; do candidate = $"EAC-{catalogRandom.NextInt(9999)}"; while (!used.Add(candidate)); output[zoneIndex] = candidate; continue; } output[zoneIndex] = generators[owner].Next(used); } return output; } internal static Point[] GeneratePoints( int count, ref CultMath.Random random, Func density) { var candidates = new Point[count * CandidateMultiplier]; var accepted = 0; var accumulator = 0f; var attempts = 0; while (accepted < candidates.Length) { if (++attempts > candidates.Length * 1_000_000) throw new InvalidOperationException("Tutorial density did not admit enough topology samples."); var point = new Point(random.NextFloat(), random.NextFloat()); var dx = point.X - 0.5f; var dy = point.Y - 0.5f; var envelope = Saturate((0.2f - (dx * dx + dy * dy)) * 4f); var sampleDensity = Saturate(density(point.X, point.Y)); accumulator += sampleDensity * sampleDensity * envelope; if (accumulator <= 0.5f) continue; accumulator = 0; candidates[accepted++] = point; } var dMax = 2f * MathF.Sqrt((1f / count) / (2f * MathF.Sqrt(3f))); var dMaxSquared = dMax * dMax; var weights = new float[candidates.Length]; var densities = candidates.Select(point => density(point.X, point.Y)).ToArray(); var active = Enumerable.Repeat(true, candidates.Length).ToArray(); for (var i = 0; i < candidates.Length; i++) for (var j = 0; j < candidates.Length; j++) { var distanceSquared = DistanceSquared(candidates[i], candidates[j]); if (distanceSquared > dMaxSquared) continue; weights[i] += Weight(distanceSquared, dMax, densities[i]); } for (var remaining = candidates.Length; remaining > count; remaining--) { var eliminated = Enumerable.Range(0, candidates.Length) .Where(index => active[index]) .OrderByDescending(index => weights[index]) .ThenBy(index => index) .First(); active[eliminated] = false; for (var neighbor = 0; neighbor < candidates.Length; neighbor++) { if (!active[neighbor]) continue; var distanceSquared = DistanceSquared(candidates[neighbor], candidates[eliminated]); if (distanceSquared <= dMaxSquared) weights[neighbor] -= Weight(distanceSquared, dMax, densities[neighbor]); } } return Enumerable.Range(0, candidates.Length) .Where(index => active[index]) .Select(index => candidates[index]) .ToArray(); } private static float Weight(float distanceSquared, float dMax, float density) { var distance = MathF.Sqrt(distanceSquared); return MathF.Pow(1f - distance / dMax, 5f + 6f * density); } internal static HashSet DelaunayLinks(IReadOnlyList points) { var vertices = points.Concat(new[] { new Point(-16, -8), new Point(16, -8), new Point(0, 16) }).ToArray(); var superA = points.Count; var superB = points.Count + 1; var superC = points.Count + 2; var triangles = new List { Orient(vertices, new Triangle(superA, superB, superC)) }; for (var pointIndex = 0; pointIndex < points.Count; pointIndex++) { var bad = triangles.Where(triangle => ContainsInCircumcircle(vertices, triangle, vertices[pointIndex])).ToArray(); var boundary = bad .SelectMany(TriangleEdges) .GroupBy(edge => edge) .Where(group => group.Count() == 1) .Select(group => group.Key) .ToArray(); foreach (var triangle in bad) triangles.Remove(triangle); triangles.AddRange(boundary.Select(edge => Orient(vertices, new Triangle(edge.A, edge.B, pointIndex)))); } return triangles .Where(triangle => triangle.A < points.Count && triangle.B < points.Count && triangle.C < points.Count) .SelectMany(TriangleEdges) .ToHashSet(); } private static Triangle Orient(IReadOnlyList points, Triangle triangle) { return Cross(points[triangle.A], points[triangle.B], points[triangle.C]) >= 0 ? triangle : new Triangle(triangle.B, triangle.A, triangle.C); } private static bool ContainsInCircumcircle(IReadOnlyList points, Triangle triangle, Point point) { var a = points[triangle.A]; var b = points[triangle.B]; var c = points[triangle.C]; var ax = a.X - point.X; var ay = a.Y - point.Y; var bx = b.X - point.X; var by = b.Y - point.Y; var cx = c.X - point.X; var cy = c.Y - point.Y; var determinant = (ax * ax + ay * ay) * (bx * cy - cx * by) - (bx * bx + by * by) * (ax * cy - cx * ay) + (cx * cx + cy * cy) * (ax * by - bx * ay); return determinant > 1e-7f; } private static IEnumerable TriangleEdges(Triangle triangle) { yield return new Edge(triangle.A, triangle.B); yield return new Edge(triangle.B, triangle.C); yield return new Edge(triangle.C, triangle.A); } internal static void PruneLinks( IReadOnlyList points, HashSet links, float linkDensity, Func density) { var target = (int)MathF.Ceiling(Saturate(linkDensity) * links.Count); while (links.Count > target) { var degrees = Enumerable.Range(0, points.Count) .Select(index => links.Count(edge => edge.Contains(index))) .ToArray(); var removable = links .Where(edge => IsConnectedWithout(points.Count, links, edge.A, edge.B, edge)) .Select(edge => new { Edge = edge, Weight = LinkWeight(points, edge, degrees, density) }) .OrderByDescending(value => value.Weight) .ThenBy(value => value.Edge.A) .ThenBy(value => value.Edge.B) .FirstOrDefault(); if (removable == null) break; links.Remove(removable.Edge); } } private static float LinkWeight( IReadOnlyList points, Edge edge, IReadOnlyList degrees, Func density) { var a = points[edge.A]; var b = points[edge.B]; var midpointDensity = Saturate(density((a.X + b.X) * 0.5f, (a.Y + b.Y) * 0.5f)); var densityFactor = midpointDensity <= 0 ? float.PositiveInfinity : 1f / midpointDensity; return densityFactor * DistanceSquared(a, b) * (degrees[edge.A] - 1) * (degrees[edge.B] - 1); } private static Dictionary PlaceFactions( int zoneCount, HashSet links, int[,] distances, TutorialRoles roles) { var home = new Dictionary(StringComparer.Ordinal); var influence = roles.All.ToDictionary( faction => faction.CorporationKey, faction => Math.Max(0, (faction.InfluenceDistance + 1) / 2), StringComparer.Ordinal); home[roles.Protagonist.CorporationKey] = MaxZone(zoneCount, zone => RegionSize(zone, influence[roles.Protagonist.CorporationKey], distances)); home[roles.Antagonist.CorporationKey] = MaxZone(zoneCount, zone => RegionSize(zone, influence[roles.Antagonist.CorporationKey], distances) * distances[zone, home[roles.Protagonist.CorporationKey]]); home[roles.Protagonist.CorporationKey] = MaxZone(zoneCount, zone => RegionSize(zone, influence[roles.Protagonist.CorporationKey], distances) * Math.Sqrt(distances[zone, home[roles.Antagonist.CorporationKey]])); var minimumDifference = Enumerable.Range(0, zoneCount) .Min(zone => Math.Abs( distances[zone, home[roles.Antagonist.CorporationKey]] - distances[zone, home[roles.Protagonist.CorporationKey]])); home[roles.Buffer.CorporationKey] = MaxZone( Enumerable.Range(0, zoneCount).Where(zone => Math.Abs( distances[zone, home[roles.Antagonist.CorporationKey]] - distances[zone, home[roles.Protagonist.CorporationKey]]) == minimumDifference), zone => RegionSize(zone, influence[roles.Buffer.CorporationKey], distances)); foreach (var neutral in roles.Neutrals) { home[neutral.CorporationKey] = MaxZone(zoneCount, zone => { var score = (double)RegionSize(zone, influence[neutral.CorporationKey], distances); foreach (var occupied in home.Values) score *= Math.Sqrt(distances[zone, occupied]); return score; }); } var present = BuildPresence(zoneCount, roles.All.Where(value => value != roles.Quest), home, influence, distances); var preferredQuestZones = Enumerable.Range(0, zoneCount) .Where(zone => present[zone].Contains(roles.Antagonist.CorporationKey) && present[zone].Contains(roles.Buffer.CorporationKey)) .ToArray(); if (preferredQuestZones.Length > 0) { home[roles.Quest.CorporationKey] = MaxZone(preferredQuestZones, zone => distances[zone, home[roles.Antagonist.CorporationKey]] * RegionSize(zone, influence[roles.Quest.CorporationKey], distances)); } else { var antagonistZones = Enumerable.Range(0, zoneCount) .Where(zone => present[zone].Contains(roles.Antagonist.CorporationKey)); home[roles.Quest.CorporationKey] = antagonistZones .OrderBy(zone => distances[zone, home[roles.Buffer.CorporationKey]]) .ThenBy(zone => zone) .First(); } return home; } private static HashSet[] BuildPresence( int zoneCount, IEnumerable factions, IReadOnlyDictionary home, IReadOnlyDictionary influence, int[,] distances) { return Enumerable.Range(0, zoneCount) .Select(zone => factions .Where(faction => distances[zone, home[faction.CorporationKey]] <= influence[faction.CorporationKey]) .Select(faction => faction.CorporationKey) .ToHashSet(StringComparer.Ordinal)) .ToArray(); } internal static int[,] BuildDistances(int zoneCount, IReadOnlySet links) { var output = new int[zoneCount, zoneCount]; for (var source = 0; source < zoneCount; source++) { var queue = new Queue(); var visited = new bool[zoneCount]; queue.Enqueue(source); visited[source] = true; while (queue.Count > 0) { var current = queue.Dequeue(); foreach (var neighbor in Neighbors(current, links)) { if (visited[neighbor]) continue; visited[neighbor] = true; output[source, neighbor] = output[source, current] + 1; queue.Enqueue(neighbor); } } if (visited.Any(value => !value)) throw new InvalidOperationException("Tutorial topology graph is disconnected."); } return output; } internal static bool IsConnectedWithout( int zoneCount, IReadOnlySet links, int source, int target, Edge ignored) { var queue = new Queue(); var visited = new bool[zoneCount]; queue.Enqueue(source); visited[source] = true; while (queue.Count > 0) { var current = queue.Dequeue(); foreach (var edge in links) { if (edge == ignored || !edge.Contains(current)) continue; var neighbor = edge.Other(current); if (neighbor == target) return true; if (visited[neighbor]) continue; visited[neighbor] = true; queue.Enqueue(neighbor); } } return false; } internal static IEnumerable Neighbors(int zone, IEnumerable links) { return links.Where(edge => edge.Contains(zone)).Select(edge => edge.Other(zone)); } internal static int RegionSize(int zone, int maxDistance, int[,] distances) { return Enumerable.Range(0, distances.GetLength(0)).Count(other => distances[zone, other] <= maxDistance); } internal static int MaxZone(int zoneCount, Func score) => MaxZone(Enumerable.Range(0, zoneCount), score); internal static int MaxZone(IEnumerable zones, Func score) => zones.Select(zone => new { Zone = zone, Score = score(zone) }) .OrderByDescending(value => value.Score) .ThenBy(value => value.Zone) .First().Zone; private static float DistanceSquared(Point a, Point b) { var x = a.X - b.X; var y = a.Y - b.Y; return x * x + y * y; } private static float Cross(Point a, Point b, Point c) => (b.X - a.X) * (c.Y - a.Y) - (b.Y - a.Y) * (c.X - a.X); private static float Saturate(float value) => value < 0 ? 0 : value > 1 ? 1 : value; internal readonly record struct Point(float X, float Y); private readonly record struct Triangle(int A, int B, int C); internal readonly record struct Edge { public Edge(int a, int b) { A = Math.Min(a, b); B = Math.Max(a, b); } public int A { get; } public int B { get; } public bool Contains(int value) => A == value || B == value; public int Other(int value) => A == value ? B : B == value ? A : throw new ArgumentOutOfRangeException(nameof(value)); } private sealed record TutorialRoles( AetheriaDaemonTutorialFactionInput Protagonist, AetheriaDaemonTutorialFactionInput Antagonist, AetheriaDaemonTutorialFactionInput Buffer, IReadOnlyList Neutrals, AetheriaDaemonTutorialFactionInput Quest, AetheriaDaemonTutorialFactionInput[] All); private sealed class TutorialMarkovNameGenerator { private readonly Dictionary> _chains = new(StringComparer.Ordinal); private readonly List _samples = []; private readonly int _order; private readonly int _minimumLength; private readonly int _maximumLength; private CultMath.Random _random; public TutorialMarkovNameGenerator( uint seed, IEnumerable sampleNames, int order, int minimumLength, int maximumLength) { _random = new CultMath.Random(seed); _order = Math.Max(1, order); _minimumLength = Math.Max(1, minimumLength); _maximumLength = maximumLength; var names = new HashSet(StringComparer.Ordinal); foreach (var line in sampleNames) foreach (var word in (line ?? "").ToUpperInvariant().Split(' ', ',', '.', '"')) if (word.Length >= _minimumLength) names.Add(word); foreach (var line in names) foreach (var word in line.Split(' ', '\'', '(', ')')) { var lower = word.Trim().ToLowerInvariant(); if (lower.Length >= _order + 1) _samples.Add(lower + "|"); } foreach (var word in _samples) for (var letter = 0; letter < word.Length - _order; letter++) { var token = word.Substring(letter, _order); if (!_chains.TryGetValue(token, out var row)) _chains[token] = row = []; row.Add(word[letter + _order]); } if (_samples.Count == 0) throw new InvalidDataException("Tutorial geoname corpus contains no usable Markov samples."); } public string Next(ISet used) { for (var attempt = 0; attempt < 1_000_000; attempt++) { var sample = _samples[_random.NextInt(0, _samples.Count)]; var value = sample.Substring(0, _order); while (_chains.TryGetValue(value.Substring(value.Length - _order, _order), out var row)) { var next = row[_random.NextInt(0, row.Count)]; if (next == '|') break; value += next; } value = char.ToUpperInvariant(value[0]) + value.Substring(1); if (value.Length >= _minimumLength && value.Length <= _maximumLength && used.Add(value)) return value; } throw new InvalidOperationException("Tutorial Markov corpus could not produce another unique zone name."); } } }