Move the ServerShared data model onto CultLib's CultCache
Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and
GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also
checks the Caching project exists.
Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject]
and integer [Key]s on the Q7 concrete types (the eight ItemData leaves,
Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the
four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout).
SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName].
ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose
[MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout
moves into ServerShared so AetheriaStores can route it.
References: Guid and DatabaseLink<T> members become CultRecordRef<T>;
Guid-keyed maps become ref-keyed with [CultReference(..., many: true)];
ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits,
bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey.
Identity is the record key: ID comparisons become reference or key
comparisons. StatModifierData.RequireBehavior is a simple type name.
AetheriaStores.Open composes the catalog (read-only unless writable), run
and player stores, one home per type. CultRecordRefs carries IsSet, a
null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert.
ItemManager.GetData is the one item resolution path. MathResolver is
declared by [assembly: CultCacheFormatterResolver].
Inspector attributes: text, range and asset path map onto CultLib's
CultInspector attributes (asset path only on string members); every
replaced Aetheria attribute keeps [Inspectable] for the runtime panel.
Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project,
which follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
|
|
|
using GameCult.Caching;
|
2021-03-12 05:12:01 +00:00
|
|
|
using System;
|
2021-03-10 09:18:07 +00:00
|
|
|
using System.Collections.Generic;
|
2021-04-30 14:24:01 +01:00
|
|
|
using System.IO;
|
2021-03-11 01:36:08 +00:00
|
|
|
using System.Linq;
|
2021-03-15 11:40:46 +00:00
|
|
|
using System.Threading;
|
|
|
|
|
using System.Threading.Tasks;
|
2021-03-12 05:12:01 +00:00
|
|
|
using DataStructures.ViliWonka.Heap;
|
2021-05-14 03:03:28 +01:00
|
|
|
using Ink.Runtime;
|
2021-03-11 01:36:08 +00:00
|
|
|
using JM.LinqFaster;
|
2021-04-09 14:01:34 +01:00
|
|
|
using UniRx;
|
2026-09-14 17:39:12 +02:00
|
|
|
using CultMath;
|
|
|
|
|
using static CultMath.math;
|
|
|
|
|
using Random = CultMath.Random;
|
2021-03-10 09:18:07 +00:00
|
|
|
|
2021-05-14 03:03:28 +01:00
|
|
|
public class Galaxy
|
2021-03-10 09:18:07 +00:00
|
|
|
{
|
2021-05-14 03:03:28 +01:00
|
|
|
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>();
|
2021-03-14 03:56:49 +00:00
|
|
|
|
2021-04-22 02:53:06 +01:00
|
|
|
public SectorBackgroundSettings Background { get; }
|
2021-03-20 04:20:17 +00:00
|
|
|
public Faction[] Factions { get; }
|
2021-05-14 03:03:28 +01:00
|
|
|
public GalaxyZone[] Zones { get; }
|
|
|
|
|
public GalaxyZone Entrance { get; }
|
2026-10-06 19:32:02 +02:00
|
|
|
public GalaxyZone Exit { get; private set; }
|
2021-05-02 20:59:45 +01:00
|
|
|
public Dictionary<Faction, FactionRelationship> FactionRelationships { get; } = new Dictionary<Faction, FactionRelationship>();
|
2021-05-16 05:24:51 +01:00
|
|
|
private Action<string> Log { get; }
|
2022-08-08 22:43:55 +01:00
|
|
|
public bool IsPrelude { get; }
|
A fixed seed fixes every fit: RunStart seeds the run's item draws from the galaxy's seed
ScenarioStage.Generated picked from ItemManager.Random, which is clock-seeded per process and advances in play, so a
relaunch of Djinni Shakedown or Long Haul in one session gave the player a different fit; every branded lot's
workmanship and role fills came from the same clock stream, so arena gear and explicit fits varied in quality too.
Galaxy now keeps the seed it was generated at, and RunStart.GenerateArena and Stage each reseed the item manager's
random from it, one stream per step, so what runs between them cannot shift either.
SeedRules relaunches every fixed-seed scenario around a clock-free Main Galaxy launch and a scrambled item random,
and compares the galaxy (links, factions, homes, exit too), the arena down to each item's maker and workmanship, and
the staged fits.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:01:17 +02:00
|
|
|
|
2026-10-18 17:29:17 +02:00
|
|
|
// 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).
|
A fixed seed fixes every fit: RunStart seeds the run's item draws from the galaxy's seed
ScenarioStage.Generated picked from ItemManager.Random, which is clock-seeded per process and advances in play, so a
relaunch of Djinni Shakedown or Long Haul in one session gave the player a different fit; every branded lot's
workmanship and role fills came from the same clock stream, so arena gear and explicit fits varied in quality too.
Galaxy now keeps the seed it was generated at, and RunStart.GenerateArena and Stage each reseed the item manager's
random from it, one stream per step, so what runs between them cannot shift either.
SeedRules relaunches every fixed-seed scenario around a clock-free Main Galaxy launch and a scrambled item random,
and compares the galaxy (links, factions, homes, exit too), the arena down to each item's maker and workmanship, and
the staged fits.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:01:17 +02:00
|
|
|
public uint Seed { get; }
|
2026-10-27 06:42:58 +01:00
|
|
|
|
|
|
|
|
// 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; }
|
2022-08-08 22:43:55 +01:00
|
|
|
|
2021-05-14 03:03:28 +01:00
|
|
|
private GalaxyZone[] _exitPath;
|
2026-08-10 14:57:50 +01:00
|
|
|
private readonly CultCache _cache;
|
2021-03-12 05:12:01 +00:00
|
|
|
|
2021-05-14 03:03:28 +01:00
|
|
|
public GalaxyZone[] ExitPath
|
2021-03-12 05:12:01 +00:00
|
|
|
{
|
|
|
|
|
get
|
|
|
|
|
{
|
2021-04-22 02:53:06 +01:00
|
|
|
if(Entrance!=null && Exit != null)
|
|
|
|
|
return _exitPath ??= FindPath(Entrance, Exit);
|
|
|
|
|
return null;
|
2021-03-12 05:12:01 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 14:57:50 +01:00
|
|
|
public Galaxy(CultCache cultCache, SavedGame savedGame, Action<string> log)
|
|
|
|
|
{
|
|
|
|
|
IsPrelude = savedGame.IsTutorial;
|
|
|
|
|
_cache = cultCache;
|
|
|
|
|
Log = log;
|
|
|
|
|
Background = savedGame.Background;
|
2026-10-18 17:29:17 +02:00
|
|
|
Seed = savedGame.Seed;
|
2026-08-10 14:57:50 +01:00
|
|
|
|
Move the ServerShared data model onto CultLib's CultCache
Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and
GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also
checks the Caching project exists.
Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject]
and integer [Key]s on the Q7 concrete types (the eight ItemData leaves,
Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the
four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout).
SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName].
ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose
[MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout
moves into ServerShared so AetheriaStores can route it.
References: Guid and DatabaseLink<T> members become CultRecordRef<T>;
Guid-keyed maps become ref-keyed with [CultReference(..., many: true)];
ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits,
bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey.
Identity is the record key: ID comparisons become reference or key
comparisons. StatModifierData.RequireBehavior is a simple type name.
AetheriaStores.Open composes the catalog (read-only unless writable), run
and player stores, one home per type. CultRecordRefs carries IsSet, a
null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert.
ItemManager.GetData is the one item resolution path. MathResolver is
declared by [assembly: CultCacheFormatterResolver].
Inspector attributes: text, range and asset path map onto CultLib's
CultInspector attributes (asset path only on string members); every
replaced Aetheria attribute keeps [Inspectable] for the runtime panel.
Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project,
which follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
|
|
|
Factions = savedGame.Factions.Select(faction => cultCache.Get(faction)).ToArray();
|
2026-08-10 14:57:50 +01:00
|
|
|
for (var i = 0; i < Factions.Length; i++)
|
|
|
|
|
{
|
|
|
|
|
FactionRelationships[Factions[i]] = savedGame.Relationships[i];
|
|
|
|
|
}
|
|
|
|
|
|
Move the ServerShared data model onto CultLib's CultCache
Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and
GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also
checks the Caching project exists.
Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject]
and integer [Key]s on the Q7 concrete types (the eight ItemData leaves,
Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the
four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout).
SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName].
ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose
[MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout
moves into ServerShared so AetheriaStores can route it.
References: Guid and DatabaseLink<T> members become CultRecordRef<T>;
Guid-keyed maps become ref-keyed with [CultReference(..., many: true)];
ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits,
bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey.
Identity is the record key: ID comparisons become reference or key
comparisons. StatModifierData.RequireBehavior is a simple type name.
AetheriaStores.Open composes the catalog (read-only unless writable), run
and player stores, one home per type. CultRecordRefs carries IsSet, a
null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert.
ItemManager.GetData is the one item resolution path. MathResolver is
declared by [assembly: CultCacheFormatterResolver].
Inspector attributes: text, range and asset path map onto CultLib's
CultInspector attributes (asset path only on string members); every
replaced Aetheria attribute keeps [Inspectable] for the runtime panel.
Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project,
which follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
|
|
|
var savedZones = savedGame.Zones.Select(zone => cultCache.Get(zone)).ToArray();
|
|
|
|
|
Zones = savedZones.Select(zone =>
|
2026-08-10 14:57:50 +01:00
|
|
|
{
|
|
|
|
|
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++)
|
|
|
|
|
{
|
Move the ServerShared data model onto CultLib's CultCache
Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and
GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also
checks the Caching project exists.
Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject]
and integer [Key]s on the Q7 concrete types (the eight ItemData leaves,
Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the
four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout).
SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName].
ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose
[MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout
moves into ServerShared so AetheriaStores can route it.
References: Guid and DatabaseLink<T> members become CultRecordRef<T>;
Guid-keyed maps become ref-keyed with [CultReference(..., many: true)];
ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits,
bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey.
Identity is the record key: ID comparisons become reference or key
comparisons. StatModifierData.RequireBehavior is a simple type name.
AetheriaStores.Open composes the catalog (read-only unless writable), run
and player stores, one home per type. CultRecordRefs carries IsSet, a
null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert.
ItemManager.GetData is the one item resolution path. MathResolver is
declared by [assembly: CultCacheFormatterResolver].
Inspector attributes: text, range and asset path map onto CultLib's
CultInspector attributes (asset path only on string members); every
replaced Aetheria attribute keeps [Inspectable] for the runtime panel.
Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project,
which follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
|
|
|
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];
|
2026-08-10 14:57:50 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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];
|
|
|
|
|
|
2026-10-27 06:42:58 +01:00
|
|
|
// 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);
|
|
|
|
|
|
2026-08-10 14:57:50 +01:00
|
|
|
CalculateDistanceMatrix();
|
|
|
|
|
}
|
|
|
|
|
|
2021-05-14 03:03:28 +01:00
|
|
|
public Galaxy(
|
2021-04-22 02:53:06 +01:00
|
|
|
SectorGenerationSettings settings,
|
|
|
|
|
SectorBackgroundSettings background,
|
2026-08-10 14:57:50 +01:00
|
|
|
CultCache cache,
|
2021-05-16 05:24:51 +01:00
|
|
|
Action<string> log,
|
2026-10-18 17:28:36 +02:00
|
|
|
Action<string> progressCallback,
|
|
|
|
|
uint seed)
|
2021-03-14 03:56:49 +00:00
|
|
|
{
|
2026-08-10 14:57:50 +01:00
|
|
|
_cache = cache;
|
2022-08-08 22:43:55 +01:00
|
|
|
IsPrelude = false;
|
2021-04-22 02:53:06 +01:00
|
|
|
Background = background;
|
2021-05-16 05:24:51 +01:00
|
|
|
Log = log;
|
2026-08-10 14:57:50 +01:00
|
|
|
var factions = cache.GetAll<Faction>();
|
2026-10-18 17:28:36 +02:00
|
|
|
Seed = seed != 0 ? seed : throw new ArgumentOutOfRangeException(nameof(seed), "a generated galaxy takes its run's seed from GalaxyStage, never zero");
|
A fixed seed fixes every fit: RunStart seeds the run's item draws from the galaxy's seed
ScenarioStage.Generated picked from ItemManager.Random, which is clock-seeded per process and advances in play, so a
relaunch of Djinni Shakedown or Long Haul in one session gave the player a different fit; every branded lot's
workmanship and role fills came from the same clock stream, so arena gear and explicit fits varied in quality too.
Galaxy now keeps the seed it was generated at, and RunStart.GenerateArena and Stage each reseed the item manager's
random from it, one stream per step, so what runs between them cannot shift either.
SeedRules relaunches every fixed-seed scenario around a clock-free Main Galaxy launch and a scrambled item random,
and compares the galaxy (links, factions, homes, exit too), the arena down to each item's maker and workmanship, and
the staged fits.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:01:17 +02:00
|
|
|
var random = new Random(Seed);
|
2026-08-10 14:57:50 +01:00
|
|
|
Factions = factions.OrderBy(x => random.NextFloat()).Take(settings.MegaCount).ToArray();
|
2021-05-02 20:59:45 +01:00
|
|
|
foreach (var f in Factions) FactionRelationships[f] = FactionRelationship.Neutral;
|
2021-03-15 11:40:46 +00:00
|
|
|
|
2021-04-22 02:53:06 +01:00
|
|
|
Zones = GenerateZones(settings.ZoneCount, ref random, progressCallback);
|
2021-03-14 03:56:49 +00:00
|
|
|
|
2021-04-22 02:53:06 +01:00
|
|
|
GenerateLinks(settings.LinkDensity, progressCallback);
|
2021-03-14 03:56:49 +00:00
|
|
|
|
2021-04-22 02:53:06 +01:00
|
|
|
CalculateDistanceMatrix(progressCallback);
|
|
|
|
|
|
|
|
|
|
// Exit is the most isolated zone (highest total distance to all other zones)
|
|
|
|
|
Exit = Zones.MaxBy(z => z.Isolation);
|
2021-03-15 11:40:46 +00:00
|
|
|
|
2021-04-22 02:53:06 +01:00
|
|
|
// 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);
|
2021-03-14 03:56:49 +00:00
|
|
|
|
2026-10-27 06:42:58 +01:00
|
|
|
BuildProvinces(settings.ProvinceZones);
|
|
|
|
|
|
2021-04-22 02:53:06 +01:00
|
|
|
CalculateFactionInfluence(progressCallback);
|
2021-03-14 03:56:49 +00:00
|
|
|
|
2026-10-17 19:10:17 +02:00
|
|
|
NamePlaces(ref random, progressCallback);
|
2021-03-18 22:48:33 +00:00
|
|
|
|
2021-04-22 02:53:06 +01:00
|
|
|
progressCallback?.Invoke("Done!");
|
|
|
|
|
if(progressCallback!=null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao
|
|
|
|
|
}
|
2022-08-08 22:43:55 +01:00
|
|
|
|
2026-10-27 17:28:09 +01:00
|
|
|
// 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());
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-27 06:42:58 +01:00
|
|
|
// 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)
|
|
|
|
|
{
|
2026-10-27 17:28:09 +01:00
|
|
|
var (index, adjacency) = ZoneGraph();
|
2026-10-27 06:42:58 +01:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2022-08-08 22:43:55 +01:00
|
|
|
public Faction ResolveFaction(string name)
|
|
|
|
|
{
|
2026-08-10 14:57:50 +01:00
|
|
|
return _cache.GetAll<Faction>().FirstOrDefault(f => f.Name.StartsWith(name, StringComparison.InvariantCultureIgnoreCase));
|
2022-08-08 22:43:55 +01:00
|
|
|
}
|
2021-04-22 02:53:06 +01:00
|
|
|
|
2026-10-06 19:32:02 +02:00
|
|
|
// 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];
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 20:25:26 +02:00
|
|
|
// 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;
|
|
|
|
|
|
2021-05-14 03:03:28 +01:00
|
|
|
public Galaxy(
|
2021-04-22 02:53:06 +01:00
|
|
|
TutorialGenerationSettings settings,
|
|
|
|
|
SectorBackgroundSettings background,
|
2026-08-10 14:57:50 +01:00
|
|
|
CultCache cache,
|
|
|
|
|
PlayerSettings playerSettings,
|
2021-04-30 14:24:01 +01:00
|
|
|
DirectoryInfo narrativeDirectory,
|
2021-05-16 05:24:51 +01:00
|
|
|
Action<string> log,
|
2026-10-18 17:28:36 +02:00
|
|
|
Action<string> progressCallback,
|
|
|
|
|
uint seed)
|
2021-04-22 02:53:06 +01:00
|
|
|
{
|
2026-08-10 14:57:50 +01:00
|
|
|
_cache = cache;
|
2022-08-08 22:43:55 +01:00
|
|
|
IsPrelude = true;
|
2021-04-27 16:44:44 +01:00
|
|
|
|
2021-04-22 02:53:06 +01:00
|
|
|
Background = background;
|
2021-05-16 05:24:51 +01:00
|
|
|
Log = log;
|
2026-10-18 17:28:36 +02:00
|
|
|
Seed = seed != 0 ? seed : throw new ArgumentOutOfRangeException(nameof(seed), "a generated galaxy takes its run's seed from GalaxyStage, never zero");
|
A fixed seed fixes every fit: RunStart seeds the run's item draws from the galaxy's seed
ScenarioStage.Generated picked from ItemManager.Random, which is clock-seeded per process and advances in play, so a
relaunch of Djinni Shakedown or Long Haul in one session gave the player a different fit; every branded lot's
workmanship and role fills came from the same clock stream, so arena gear and explicit fits varied in quality too.
Galaxy now keeps the seed it was generated at, and RunStart.GenerateArena and Stage each reseed the item manager's
random from it, one stream per step, so what runs between them cannot shift either.
SeedRules relaunches every fixed-seed scenario around a clock-free Main Galaxy launch and a scrambled item random,
and compares the galaxy (links, factions, homes, exit too), the arena down to each item's maker and workmanship, and
the staged fits.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:01:17 +02:00
|
|
|
var random = new Random(Seed);
|
2021-03-14 03:56:49 +00:00
|
|
|
|
2021-04-22 02:53:06 +01:00
|
|
|
var factions = new List<Faction>();
|
2021-04-27 16:44:44 +01:00
|
|
|
|
2026-10-06 19:32:02 +02:00
|
|
|
var protagonistFaction = ResolveCast(nameof(settings.ProtagonistFaction), settings.ProtagonistFaction);
|
2021-04-22 02:53:06 +01:00
|
|
|
factions.Add(protagonistFaction);
|
|
|
|
|
|
2026-10-06 19:32:02 +02:00
|
|
|
var antagonistFaction = ResolveCast(nameof(settings.AntagonistFaction), settings.AntagonistFaction);
|
2021-04-22 02:53:06 +01:00
|
|
|
factions.Add(antagonistFaction);
|
|
|
|
|
|
2026-10-06 19:32:02 +02:00
|
|
|
var bufferFaction = ResolveCast(nameof(settings.BufferFaction), settings.BufferFaction);
|
2021-04-22 02:53:06 +01:00
|
|
|
factions.Add(bufferFaction);
|
|
|
|
|
|
2026-10-06 19:32:02 +02:00
|
|
|
// 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);
|
2021-04-22 02:53:06 +01:00
|
|
|
|
|
|
|
|
var neutralFactions = settings.NeutralFactions
|
2026-10-06 19:32:02 +02:00
|
|
|
.Select(name => ResolveCast(nameof(settings.NeutralFactions), name))
|
2021-04-22 02:53:06 +01:00
|
|
|
.ToArray();
|
|
|
|
|
factions.AddRange(neutralFactions);
|
|
|
|
|
|
|
|
|
|
Factions = factions.ToArray();
|
2026-10-03 20:25:26 +02:00
|
|
|
foreach (var faction in Factions) FactionRelationships[faction] = FactionRelationship.Neutral;
|
2021-03-14 03:56:49 +00:00
|
|
|
|
2021-04-22 02:53:06 +01:00
|
|
|
Zones = GenerateZones(settings.ZoneCount, ref random, progressCallback);
|
2021-03-14 03:56:49 +00:00
|
|
|
|
2021-04-22 02:53:06 +01:00
|
|
|
GenerateLinks(settings.LinkDensity, progressCallback);
|
2021-03-14 03:56:49 +00:00
|
|
|
|
2021-04-22 02:53:06 +01:00
|
|
|
CalculateDistanceMatrix(progressCallback);
|
|
|
|
|
|
|
|
|
|
HomeZones[protagonistFaction] = Zones
|
2026-10-03 20:25:26 +02:00
|
|
|
.MaxBy(z => ConnectedRegion(z, InfluenceOf(protagonistFaction)).Count);
|
2021-04-22 02:53:06 +01:00
|
|
|
|
|
|
|
|
HomeZones[antagonistFaction] = Zones
|
2026-10-03 20:25:26 +02:00
|
|
|
.MaxBy(z => ConnectedRegion(z, InfluenceOf(antagonistFaction)).Count * z.Distance[HomeZones[protagonistFaction]]);
|
2021-04-22 02:53:06 +01:00
|
|
|
|
|
|
|
|
HomeZones[protagonistFaction] = Zones
|
2026-10-03 20:25:26 +02:00
|
|
|
.MaxBy(z => ConnectedRegion(z, InfluenceOf(protagonistFaction)).Count * sqrt(z.Distance[HomeZones[antagonistFaction]]));
|
2021-03-14 03:56:49 +00:00
|
|
|
|
2021-04-22 02:53:06 +01:00
|
|
|
// var antagonistRegion = ConnectedRegion(HomeZones[antagonistFaction], antagonistFaction.InfluenceDistance);
|
|
|
|
|
// var protagonistRegion = ConnectedRegion(HomeZones[protagonistFaction], protagonistFaction.InfluenceDistance);
|
2021-03-14 03:56:49 +00:00
|
|
|
|
2021-04-22 02:53:06 +01:00
|
|
|
// 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
|
2026-08-10 14:57:50 +01:00
|
|
|
var bufferDistance = Zones.Min(z => abs(z.Distance[HomeZones[antagonistFaction]] - z.Distance[HomeZones[protagonistFaction]]));
|
2021-04-22 02:53:06 +01:00
|
|
|
var potentialBufferZones = Zones
|
2026-08-10 14:57:50 +01:00
|
|
|
.Where(z => abs(z.Distance[HomeZones[antagonistFaction]] - z.Distance[HomeZones[protagonistFaction]]) == bufferDistance);
|
2026-10-03 20:25:26 +02:00
|
|
|
HomeZones[bufferFaction] = potentialBufferZones.MaxBy(z => ConnectedRegion(z, InfluenceOf(bufferFaction)).Count);
|
2021-03-18 22:48:33 +00:00
|
|
|
|
2021-04-22 02:53:06 +01:00
|
|
|
// Place neutral headquarters away from existing factions while also maximizing territory
|
|
|
|
|
foreach (var faction in neutralFactions)
|
|
|
|
|
{
|
|
|
|
|
HomeZones[faction] = Zones.MaxBy(z =>
|
2026-10-03 20:25:26 +02:00
|
|
|
ConnectedRegion(z, InfluenceOf(faction)).Count *
|
2026-08-10 14:57:50 +01:00
|
|
|
HomeZones.Values.Aggregate(1f, (i, os) => i * sqrt(os.Distance[z])));
|
2021-04-22 02:53:06 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
CalculateFactionInfluence(progressCallback);
|
|
|
|
|
|
2026-10-06 19:32:02 +02:00
|
|
|
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);
|
|
|
|
|
}
|
2021-04-22 02:53:06 +01:00
|
|
|
|
2021-04-30 11:50:47 +01:00
|
|
|
Entrance = Zones.Where(z => z.Owner == null).MinBy(z => z.Distance[HomeZones[protagonistFaction]]);
|
2021-03-14 03:56:49 +00:00
|
|
|
|
2021-04-09 14:01:34 +01:00
|
|
|
DiscoveredZones.Add(Entrance);
|
|
|
|
|
foreach(var z in Entrance.AdjacentZones) DiscoveredZones.Add(z);
|
2021-04-22 02:53:06 +01:00
|
|
|
|
|
|
|
|
CalculateFactionInfluence(progressCallback);
|
|
|
|
|
|
2026-10-27 06:42:58 +01:00
|
|
|
// No boss zone and no Exit yet (PlaceGate comes after): the prelude is one region.
|
|
|
|
|
BuildProvinces(settings.ProvinceZones);
|
|
|
|
|
|
2026-10-17 19:10:17 +02:00
|
|
|
NamePlaces(ref random, progressCallback);
|
2021-04-30 14:24:01 +01:00
|
|
|
|
2021-10-27 02:58:22 +01:00
|
|
|
// progressCallback?.Invoke("Weaving Narrative");
|
|
|
|
|
// var processor = new StoryProcessor(playerSettings, narrativeDirectory, this, ref random, Log);
|
|
|
|
|
// processor.ProcessStories();
|
2021-04-22 02:53:06 +01:00
|
|
|
|
|
|
|
|
progressCallback?.Invoke("Done!");
|
|
|
|
|
if(progressCallback!=null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao
|
|
|
|
|
}
|
|
|
|
|
|
2021-05-14 03:03:28 +01:00
|
|
|
private GalaxyZone[] GenerateZones(int zoneCount, ref Random random, Action<string> progressCallback = null)
|
2021-04-22 02:53:06 +01:00
|
|
|
{
|
|
|
|
|
var outputSamples = WeightedSampleElimination.GeneratePoints(zoneCount,
|
|
|
|
|
ref random,
|
|
|
|
|
Background.CloudDensity,
|
2026-08-10 14:57:50 +01:00
|
|
|
v => (.2f - lengthsq(v - float2(.5f))) * 4,
|
2021-04-22 02:53:06 +01:00
|
|
|
progressCallback);
|
2026-08-10 14:57:50 +01:00
|
|
|
return outputSamples.Select(v => new GalaxyZone {Position = v}).ToArray();
|
2026-06-13 13:00:15 +01:00
|
|
|
}
|
|
|
|
|
|
2021-04-22 02:53:06 +01:00
|
|
|
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
|
2021-04-09 14:01:34 +01:00
|
|
|
|
2021-03-14 03:56:49 +00:00
|
|
|
// 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));
|
2021-04-22 02:53:06 +01:00
|
|
|
|
2021-03-15 11:40:46 +00:00
|
|
|
progressCallback?.Invoke("Placing Factions");
|
2021-04-22 02:53:06 +01:00
|
|
|
if (progressCallback != null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao
|
|
|
|
|
|
2026-10-18 21:14:09 +02:00
|
|
|
// Choose some megas to have bosses placed: the factions whose boss Loadout resolves
|
2021-03-15 11:40:46 +00:00
|
|
|
var bossMegas = Factions
|
2026-10-18 21:14:09 +02:00
|
|
|
.Where(m => Loadouts.BossOf(_cache, m, out _) != null)
|
2021-04-22 02:53:06 +01:00
|
|
|
.Take(bossCount)
|
2021-03-14 03:56:49 +00:00
|
|
|
.ToArray();
|
2021-04-22 02:53:06 +01:00
|
|
|
|
2021-03-14 03:56:49 +00:00
|
|
|
// 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 =>
|
2021-04-22 02:53:06 +01:00
|
|
|
Exit.Distance[z] * Entrance.Distance[z] *
|
2021-03-14 03:56:49 +00:00
|
|
|
BossZones.Values.Aggregate(1, (i, os) => i * os.Distance[z]));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-27 17:28:09 +01:00
|
|
|
// 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);
|
|
|
|
|
|
2021-03-14 03:56:49 +00:00
|
|
|
// 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)
|
|
|
|
|
{
|
2026-10-03 20:25:26 +02:00
|
|
|
HomeZones[mega] = ConnectedRegion(BossZones[mega], InfluenceOf(mega))
|
2021-03-14 03:56:49 +00:00
|
|
|
.MaxBy(z =>
|
2026-10-03 20:25:26 +02:00
|
|
|
ConnectedRegion(z, InfluenceOf(mega)).Count *
|
2026-08-10 14:57:50 +01:00
|
|
|
HomeZones.Values.Aggregate(1f, (i, os) => i * sqrt(os.Distance[z])));
|
2021-03-14 03:56:49 +00:00
|
|
|
}
|
2021-04-22 02:53:06 +01:00
|
|
|
|
2021-03-14 03:56:49 +00:00
|
|
|
// Place remaining headquarters away from existing megas while also maximizing territory
|
2021-04-22 02:53:06 +01:00
|
|
|
foreach (var mega in Factions.Where(m => !bossMegas.Contains(m)))
|
2021-03-14 03:56:49 +00:00
|
|
|
{
|
|
|
|
|
HomeZones[mega] = Zones.MaxBy(z =>
|
2026-10-03 20:25:26 +02:00
|
|
|
pow(ConnectedRegion(z, InfluenceOf(mega)).Count, HomeZones.Count) *
|
2021-04-22 02:53:06 +01:00
|
|
|
Exit.Distance[z] * Entrance.Distance[z] *
|
2026-08-10 14:57:50 +01:00
|
|
|
HomeZones.Values.Aggregate(1f, (i, os) => i * sqrt(os.Distance[z])) *
|
|
|
|
|
BossZones.Values.Aggregate(1f, (i, os) => i * sqrt(os.Distance[z])));
|
2021-03-14 03:56:49 +00:00
|
|
|
}
|
2021-04-22 02:53:06 +01:00
|
|
|
}
|
|
|
|
|
|
2026-10-27 17:28:09 +01:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2021-04-22 02:53:06 +01:00
|
|
|
private void CalculateFactionInfluence(Action<string> progressCallback = null)
|
|
|
|
|
{
|
2021-03-15 11:40:46 +00:00
|
|
|
progressCallback?.Invoke("Calculating Faction Influence");
|
2021-04-22 02:53:06 +01:00
|
|
|
if (progressCallback != null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao
|
2021-03-14 03:56:49 +00:00
|
|
|
|
|
|
|
|
// Assign faction presence
|
|
|
|
|
foreach (var zone in Zones)
|
|
|
|
|
{
|
|
|
|
|
// Factions are present in all zones within their sphere of influence
|
2021-03-15 11:40:46 +00:00
|
|
|
zone.Factions = Factions
|
2021-04-22 02:53:06 +01:00
|
|
|
.Where(f => HomeZones.ContainsKey(f))
|
2026-10-03 20:25:26 +02:00
|
|
|
.Where(f => zone.Distance[HomeZones[f]] <= InfluenceOf(f))
|
2021-03-14 03:56:49 +00:00
|
|
|
.ToArray();
|
2021-04-22 02:53:06 +01:00
|
|
|
|
2021-03-14 03:56:49 +00:00
|
|
|
// Owner of a zone is the faction with the nearest headquarters
|
2021-04-22 02:53:06 +01:00
|
|
|
var nearestFaction = Factions
|
|
|
|
|
.Where(f => HomeZones.ContainsKey(f))
|
|
|
|
|
.MinBy(f => (float)zone.Distance[HomeZones[f]]);
|
2026-10-03 20:25:26 +02:00
|
|
|
if (zone.Distance[HomeZones[nearestFaction]] <= InfluenceOf(nearestFaction))
|
2021-03-14 03:56:49 +00:00
|
|
|
zone.Owner = nearestFaction;
|
|
|
|
|
}
|
2021-04-22 02:53:06 +01:00
|
|
|
}
|
2021-03-14 03:56:49 +00:00
|
|
|
|
2026-10-17 19:10:17 +02:00
|
|
|
// 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)
|
2021-04-22 02:53:06 +01:00
|
|
|
{
|
2026-10-17 19:10:17 +02:00
|
|
|
progressCallback?.Invoke("Naming places");
|
|
|
|
|
var used = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
|
foreach (var zone in Zones) zone.Name = null;
|
2021-03-15 11:40:46 +00:00
|
|
|
|
2026-10-17 19:10:17 +02:00
|
|
|
foreach (var faction in Factions)
|
2021-03-15 11:40:46 +00:00
|
|
|
{
|
2026-10-17 19:10:17 +02:00
|
|
|
if (!HomeZones.TryGetValue(faction, out var home)) continue;
|
2026-10-17 22:14:22 +02:00
|
|
|
var places = (faction.Places ?? Array.Empty<FactionPlace>()).Select(p => p?.Name).Where(FactionPlace.IsValidName).ToArray();
|
2026-10-17 19:10:17 +02:00
|
|
|
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)
|
2021-03-15 11:40:46 +00:00
|
|
|
{
|
2026-10-17 19:10:17 +02:00
|
|
|
while (next < places.Length && used.Contains(places[next])) next++;
|
|
|
|
|
if (next == places.Length) break;
|
|
|
|
|
zone.Name = places[next];
|
|
|
|
|
used.Add(places[next++]);
|
2021-03-15 11:40:46 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-10-17 19:10:17 +02:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
2021-03-15 11:40:46 +00:00
|
|
|
}
|
|
|
|
|
|
2021-04-22 02:53:06 +01:00
|
|
|
private void GenerateLinks(float linkDensity, Action<string> progressCallback = null)
|
2021-03-15 11:40:46 +00:00
|
|
|
{
|
2021-04-22 02:53:06 +01:00
|
|
|
progressCallback?.Invoke("Triangulating Zone Positions");
|
|
|
|
|
if (progressCallback != null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao
|
|
|
|
|
|
2026-10-03 21:59:09 +02:00
|
|
|
// 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();
|
2021-04-22 02:53:06 +01:00
|
|
|
|
|
|
|
|
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)
|
Delete AetheriaMath in favour of CultMath
AetheriaMath predates the CultMath swap and re-implemented helpers CultMath
now owns. Callers use CultMath directly: decay (Ship, AetherDrive, Entity),
damp (VelocityConversion, FieldTester, FieldDriver), first_order_intercept
(TurretController, Combat, GuidedProjectile), quadratic_bezier (FieldDriver)
and smootherstep (Ship). Parameter order matches in every case, and those
formulas have the same float evaluation order, so behaviour is unchanged.
Galaxy's commented-out segment check names distance_to_segment.
The other overloads had no callers and are dropped without a CultMath
counterpart being needed: the double Damp overloads, FindDistanceToSegment,
GetCatmullRomPosition, GetCubicSplinePosition, Smoothstep(x), and the
Deg2Rad/Rad2Deg constants.
AetherDb census, factions and hardpoint-fit are byte-identical to the
pre-cut captures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 18:02:41 +02:00
|
|
|
// if (distance_to_segment(zone.Position, link.Item1.Position, link.Item2.Position, out _) < minLineSeparation)
|
2021-04-22 02:53:06 +01:00
|
|
|
// links.Remove(link);
|
|
|
|
|
// }
|
|
|
|
|
// }
|
|
|
|
|
|
|
|
|
|
foreach (var link in links)
|
|
|
|
|
{
|
|
|
|
|
link.Item1.AdjacentZones.Add(link.Item2);
|
|
|
|
|
link.Item2.AdjacentZones.Add(link.Item1);
|
|
|
|
|
}
|
|
|
|
|
|
2021-05-14 03:03:28 +01:00
|
|
|
float LinkWeight((GalaxyZone, GalaxyZone) link)
|
2021-04-22 02:53:06 +01:00
|
|
|
{
|
2026-08-10 14:57:50 +01:00
|
|
|
return 1 / saturate(Background.CloudDensity((link.Item1.Position + link.Item2.Position) / 2)) *
|
|
|
|
|
lengthsq(link.Item1.Position - link.Item2.Position) *
|
2021-04-22 02:53:06 +01:00
|
|
|
(link.Item1.AdjacentZones.Count - 1) * (link.Item2.AdjacentZones.Count - 1);
|
|
|
|
|
}
|
|
|
|
|
|
2021-05-14 03:03:28 +01:00
|
|
|
var heap = new MaxHeap<(GalaxyZone, GalaxyZone)>(links.Count);
|
2021-04-22 02:53:06 +01:00
|
|
|
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)));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2021-03-14 03:56:49 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Cache distance matrix and calculate isolation for every zone (used extensively for placing stuff)
|
2021-04-22 02:53:06 +01:00
|
|
|
private void CalculateDistanceMatrix(Action<string> progressCallback = null)
|
2021-03-14 03:56:49 +00:00
|
|
|
{
|
2021-04-22 02:53:06 +01:00
|
|
|
progressCallback?.Invoke("Calculating Distance Matrix");
|
|
|
|
|
if(progressCallback!=null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao
|
2021-03-14 03:56:49 +00:00
|
|
|
foreach (var zone in Zones)
|
|
|
|
|
{
|
|
|
|
|
zone.Distance = ConnectedRegionDistance(zone);
|
|
|
|
|
zone.Isolation = zone.Distance.Sum(x => x.Value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2021-03-11 01:36:08 +00:00
|
|
|
class DijkstraVertex
|
|
|
|
|
{
|
|
|
|
|
public float Cost;
|
|
|
|
|
public DijkstraVertex Parent;
|
2021-05-14 03:03:28 +01:00
|
|
|
public GalaxyZone Zone;
|
2021-03-11 01:36:08 +00:00
|
|
|
}
|
|
|
|
|
|
2021-05-14 03:03:28 +01:00
|
|
|
public Dictionary<GalaxyZone, int> ConnectedRegionDistance(GalaxyZone v)
|
2021-03-11 01:36:08 +00:00
|
|
|
{
|
2021-05-14 03:03:28 +01:00
|
|
|
var members = new Dictionary<GalaxyZone, int> {{v, 0}};
|
2021-03-12 05:12:01 +00:00
|
|
|
int cost = 1;
|
2021-03-11 01:36:08 +00:00
|
|
|
while (true)
|
|
|
|
|
{
|
2021-03-11 11:18:21 +00:00
|
|
|
var lastCount = members.Count;
|
2021-03-11 01:36:08 +00:00
|
|
|
// 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
|
2021-03-12 05:12:01 +00:00
|
|
|
foreach (var member in members.Keys.ToArray())
|
|
|
|
|
foreach (var adjacentZone in member.AdjacentZones)
|
|
|
|
|
{
|
|
|
|
|
if(!members.ContainsKey(adjacentZone))
|
|
|
|
|
members.Add(adjacentZone, cost);
|
|
|
|
|
}
|
2021-03-11 01:36:08 +00:00
|
|
|
// If we have stopped finding neighbors, stop traversing
|
|
|
|
|
if (members.Count == lastCount)
|
|
|
|
|
return members;
|
2021-03-12 05:12:01 +00:00
|
|
|
cost++;
|
2021-03-11 01:36:08 +00:00
|
|
|
}
|
|
|
|
|
}
|
2021-03-11 11:18:21 +00:00
|
|
|
|
2021-05-14 03:03:28 +01:00
|
|
|
public HashSet<GalaxyZone> ConnectedRegion(
|
|
|
|
|
GalaxyZone v,
|
|
|
|
|
GalaxyZone ignoreLinkSource,
|
|
|
|
|
GalaxyZone ignoreLinkTarget,
|
2021-03-12 05:12:01 +00:00
|
|
|
int maxDistance = int.MaxValue)
|
|
|
|
|
{
|
2021-03-14 03:56:49 +00:00
|
|
|
return ConnectedRegion(v, x => x.Item1 != ignoreLinkSource || x.Item2 != ignoreLinkTarget, maxDistance);
|
2021-03-12 05:12:01 +00:00
|
|
|
}
|
|
|
|
|
|
2021-05-14 03:03:28 +01:00
|
|
|
public HashSet<GalaxyZone> ConnectedRegion(
|
|
|
|
|
GalaxyZone v,
|
|
|
|
|
GalaxyZone ignoreZone,
|
2021-03-12 05:12:01 +00:00
|
|
|
int maxDistance = int.MaxValue)
|
|
|
|
|
{
|
|
|
|
|
return ConnectedRegion(v, x => x.Item2 != ignoreZone, maxDistance);
|
|
|
|
|
}
|
|
|
|
|
|
2021-05-14 03:03:28 +01:00
|
|
|
public HashSet<GalaxyZone> ConnectedRegion(
|
|
|
|
|
GalaxyZone v,
|
2021-03-12 05:12:01 +00:00
|
|
|
int maxDistance = int.MaxValue)
|
|
|
|
|
{
|
|
|
|
|
return ConnectedRegion(v, x => true, maxDistance);
|
|
|
|
|
}
|
2021-03-11 11:18:21 +00:00
|
|
|
|
2021-05-14 03:03:28 +01:00
|
|
|
public HashSet<GalaxyZone> ConnectedRegion(
|
|
|
|
|
GalaxyZone v,
|
|
|
|
|
Predicate<(GalaxyZone source, GalaxyZone target)> linkFilter,
|
2021-03-12 05:12:01 +00:00
|
|
|
int maxDistance = int.MaxValue)
|
2021-03-11 11:18:21 +00:00
|
|
|
{
|
2021-05-14 03:03:28 +01:00
|
|
|
var visited = new HashSet<GalaxyZone> {v};
|
2021-03-11 11:18:21 +00:00
|
|
|
int cost = 1;
|
|
|
|
|
while (true)
|
|
|
|
|
{
|
2021-03-12 05:12:01 +00:00
|
|
|
var lastCount = visited.Count;
|
|
|
|
|
// For each member, add all nodes that are connected to it but have not been visited
|
2021-03-11 11:18:21 +00:00
|
|
|
// Also, if there is a zone being ignored, do not traverse across it
|
2021-03-12 05:12:01 +00:00
|
|
|
foreach (var zone in visited.ToArray())
|
|
|
|
|
foreach (var adjacentZone in zone.AdjacentZones)
|
2021-03-11 11:18:21 +00:00
|
|
|
{
|
2021-03-12 05:12:01 +00:00
|
|
|
if(!visited.Contains(adjacentZone) && linkFilter((zone, adjacentZone)))
|
|
|
|
|
visited.Add(adjacentZone);
|
2021-03-11 11:18:21 +00:00
|
|
|
}
|
|
|
|
|
// If we have stopped finding neighbors, stop traversing
|
2021-03-12 05:12:01 +00:00
|
|
|
if (visited.Count == lastCount || cost == maxDistance)
|
|
|
|
|
return visited;
|
2021-03-11 11:18:21 +00:00
|
|
|
cost++;
|
|
|
|
|
}
|
|
|
|
|
}
|
2021-03-11 01:36:08 +00:00
|
|
|
|
2021-05-14 03:03:28 +01:00
|
|
|
public GalaxyZone[] FindPath(GalaxyZone source, GalaxyZone target, bool bestFirst = false)
|
2021-03-11 01:36:08 +00:00
|
|
|
{
|
2021-03-12 05:12:01 +00:00
|
|
|
MinHeap<DijkstraVertex> unsearchedNodes = new MinHeap<DijkstraVertex>();
|
|
|
|
|
unsearchedNodes.PushObj(new DijkstraVertex{Zone = source}, 0);
|
2021-05-14 03:03:28 +01:00
|
|
|
var searched = new HashSet<GalaxyZone>();
|
2021-03-11 01:36:08 +00:00
|
|
|
while (true)
|
|
|
|
|
{
|
2021-03-12 05:12:01 +00:00
|
|
|
if(unsearchedNodes.Count == 0) return null; // No nodes left unsearched
|
|
|
|
|
var s = unsearchedNodes.PopObj(); // Lowest cost unsearched node
|
2021-03-11 01:36:08 +00:00
|
|
|
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);
|
2021-03-12 05:12:01 +00:00
|
|
|
return path.Select(dv => dv.Zone).ToArray();
|
2021-03-11 01:36:08 +00:00
|
|
|
}
|
|
|
|
|
// For each adjacent star (filter already visited stars unless heuristic is in use)
|
2021-05-14 03:03:28 +01:00
|
|
|
IEnumerable<GalaxyZone> zonesToSearch = s.Zone.AdjacentZones;
|
2021-03-12 05:12:01 +00:00
|
|
|
if (!bestFirst) zonesToSearch = zonesToSearch.Where(z => !searched.Contains(z));
|
|
|
|
|
foreach (var dijkstraStar in zonesToSearch
|
2021-03-11 01:36:08 +00:00
|
|
|
// Cost is parent cost plus distance squared
|
2026-08-10 14:57:50 +01:00
|
|
|
.Select(zone => new DijkstraVertex {Parent = s, Zone = zone, Cost = s.Cost + lengthsq(s.Zone.Position - zone.Position)}))
|
2025-01-30 01:58:05 +00:00
|
|
|
// Add new member to list, sorted by cost plus optional heuristic
|
2026-08-10 14:57:50 +01:00
|
|
|
unsearchedNodes.PushObj(dijkstraStar, bestFirst ? dijkstraStar.Cost + lengthsq(dijkstraStar.Zone.Position - target.Position) : dijkstraStar.Cost);
|
2021-03-12 05:12:01 +00:00
|
|
|
searched.Add(s.Zone);
|
2021-03-11 01:36:08 +00:00
|
|
|
}
|
|
|
|
|
}
|
2021-03-10 09:18:07 +00:00
|
|
|
}
|
|
|
|
|
|
2021-05-14 03:03:28 +01:00
|
|
|
public class GalaxyZone
|
2021-03-10 09:18:07 +00:00
|
|
|
{
|
|
|
|
|
public string Name;
|
|
|
|
|
public float2 Position;
|
2021-05-14 03:03:28 +01:00
|
|
|
public List<GalaxyZone> AdjacentZones = new List<GalaxyZone>();
|
2021-03-11 11:18:21 +00:00
|
|
|
public int Isolation;
|
2021-05-14 03:03:28 +01:00
|
|
|
public Dictionary<GalaxyZone, int> Distance;
|
2021-03-20 04:20:17 +00:00
|
|
|
public Faction[] Factions;
|
|
|
|
|
public Faction Owner;
|
2026-08-10 14:57:50 +01:00
|
|
|
public Zone Contents;
|
|
|
|
|
public ZonePack PackedContents;
|
2026-10-27 06:42:58 +01:00
|
|
|
public GalaxyProvince Province;
|
2021-05-14 03:03:28 +01:00
|
|
|
public bool NamedZone;
|
|
|
|
|
public List<LocationStory> Locations = new List<LocationStory>();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-27 06:42:58 +01:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2021-05-14 03:03:28 +01:00
|
|
|
public class GalaxyQuest
|
|
|
|
|
{
|
|
|
|
|
public Story Story;
|
2021-05-16 05:24:51 +01:00
|
|
|
public Dictionary<string, LocationStory> KnotLocations = new Dictionary<string, LocationStory>();
|
2021-05-14 03:03:28 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public class LocationStory
|
|
|
|
|
{
|
2021-05-16 05:24:51 +01:00
|
|
|
public GalaxyZone Zone;
|
2021-05-14 03:03:28 +01:00
|
|
|
public string FileName;
|
|
|
|
|
public string Name;
|
|
|
|
|
public Story Story;
|
|
|
|
|
public Faction Faction;
|
|
|
|
|
public SecurityLevel Security;
|
|
|
|
|
public LocationType Type;
|
2021-05-16 05:24:51 +01:00
|
|
|
public int Turrets;
|
|
|
|
|
public Dictionary<string, List<GalaxyQuest>> KnotQuests = new Dictionary<string, List<GalaxyQuest>>();
|
2025-01-30 01:58:05 +00:00
|
|
|
}
|