2026-07-17 08:19:21 +01:00
|
|
|
using Aetheria.State.Documents;
|
|
|
|
|
using GameCult.Aetheria.State.Verse;
|
|
|
|
|
|
|
|
|
|
public sealed record AetheriaDaemonMaterializedTutorialZone(
|
|
|
|
|
AetheriaDaemonTutorialZoneTopology Topology,
|
|
|
|
|
AetheriaDaemonGeneratedZonePlan CelestialPlan,
|
|
|
|
|
string NearestFactionKey,
|
|
|
|
|
int FactionPresence,
|
|
|
|
|
AetheriaOrbitSnapshot[] Orbits,
|
|
|
|
|
AetheriaEntitySnapshot[] Entities,
|
2026-07-17 09:23:29 +01:00
|
|
|
AetheriaAgentTaskState[] AgentTasks,
|
2026-07-17 08:19:21 +01:00
|
|
|
int PlayerEntityIndex);
|
|
|
|
|
|
|
|
|
|
public sealed record AetheriaDaemonMaterializedTutorialWorld(
|
|
|
|
|
AetheriaDaemonTutorialTopology Topology,
|
|
|
|
|
IReadOnlyDictionary<int, AetheriaDaemonMaterializedTutorialZone> Zones,
|
|
|
|
|
int PlayerZoneIndex,
|
|
|
|
|
int PlayerEntityIndex);
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Daemon-owned port of the fossil ZoneGenerator population pass. Celestial
|
|
|
|
|
/// generation hands its exact remaining random state to this organ, so adding
|
|
|
|
|
/// presentation work cannot perturb station, turret, or enemy placement.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public static class AetheriaDaemonTutorialWorldMaterializer
|
|
|
|
|
{
|
|
|
|
|
public static AetheriaDaemonMaterializedTutorialWorld Materialize(
|
|
|
|
|
AetheriaDaemonTutorialWorldPlan world,
|
|
|
|
|
IReadOnlyList<AetheriaDaemonTutorialFactionInput> factions,
|
2026-07-17 10:03:10 +01:00
|
|
|
AetheriaRuntimeCatalogSnapshot catalog,
|
|
|
|
|
bool isPrelude = true,
|
|
|
|
|
string identityPrefix = "tutorial")
|
2026-07-17 08:19:21 +01:00
|
|
|
{
|
|
|
|
|
if (world == null) throw new ArgumentNullException(nameof(world));
|
|
|
|
|
if (catalog == null) throw new ArgumentNullException(nameof(catalog));
|
|
|
|
|
var homes = world.Topology.HomeZoneByFactionKey;
|
2026-07-17 10:03:10 +01:00
|
|
|
factions = homes.Keys
|
|
|
|
|
.Select(key => factions.First(faction =>
|
|
|
|
|
string.Equals(faction.CorporationKey, key, StringComparison.Ordinal)))
|
|
|
|
|
.ToArray();
|
2026-07-17 08:19:21 +01:00
|
|
|
var adjacency = world.Topology.Zones.ToDictionary(
|
|
|
|
|
zone => zone.ZoneIndex,
|
|
|
|
|
zone => zone.AdjacentZoneIndices,
|
|
|
|
|
EqualityComparer<int>.Default);
|
|
|
|
|
var distances = factions.ToDictionary(
|
|
|
|
|
faction => faction.CorporationKey,
|
|
|
|
|
faction => DistancesFrom(homes[faction.CorporationKey], adjacency),
|
|
|
|
|
StringComparer.Ordinal);
|
|
|
|
|
var zones = new Dictionary<int, AetheriaDaemonMaterializedTutorialZone>();
|
|
|
|
|
var playerEntityIndex = -1;
|
|
|
|
|
|
|
|
|
|
foreach (var topology in world.Topology.Zones.OrderBy(zone => zone.ZoneIndex))
|
|
|
|
|
{
|
|
|
|
|
var celestial = world.Zones[topology.ZoneIndex];
|
|
|
|
|
var random = new CultMath.Random(celestial.PostBodyRandomState);
|
2026-07-17 08:57:40 +01:00
|
|
|
var spawnRandom = new CultMath.Random(celestial.PostBodyRandomState ^ 0x51A7_10C5u);
|
2026-07-17 08:19:21 +01:00
|
|
|
var nearest = factions
|
|
|
|
|
.OrderBy(faction => distances[faction.CorporationKey][topology.ZoneIndex])
|
|
|
|
|
.First();
|
|
|
|
|
var homeDistance = distances[nearest.CorporationKey][topology.ZoneIndex];
|
2026-07-17 10:03:10 +01:00
|
|
|
var influence = isPrelude
|
|
|
|
|
? Math.Max(0, (nearest.InfluenceDistance + 1) / 2)
|
|
|
|
|
: Math.Max(0, nearest.InfluenceDistance);
|
|
|
|
|
var factionPresence = influence - homeDistance + 1;
|
2026-07-17 08:19:21 +01:00
|
|
|
var stationCount = (int)(random.NextFloat() * (factionPresence + 1));
|
|
|
|
|
var orbits = celestial.Orbits.ToList();
|
|
|
|
|
var entities = new List<AetheriaEntitySnapshot>();
|
2026-07-17 09:23:29 +01:00
|
|
|
var agentTasks = new List<AetheriaAgentTaskState>();
|
2026-07-17 08:19:21 +01:00
|
|
|
var generators = new Dictionary<string, AetheriaDaemonLoadoutGenerator>(StringComparer.Ordinal);
|
|
|
|
|
|
|
|
|
|
AetheriaDaemonLoadoutGenerator Generator(string factionKey)
|
|
|
|
|
{
|
|
|
|
|
if (!generators.TryGetValue(factionKey, out var generator))
|
|
|
|
|
{
|
|
|
|
|
generator = new AetheriaDaemonLoadoutGenerator(
|
|
|
|
|
catalog,
|
|
|
|
|
(uint)random.NextInt(1, int.MaxValue),
|
|
|
|
|
topology.ZoneIndex,
|
|
|
|
|
homes,
|
2026-07-17 08:48:56 +01:00
|
|
|
adjacency,
|
2026-07-17 10:03:10 +01:00
|
|
|
isPrelude: isPrelude);
|
2026-07-17 08:19:21 +01:00
|
|
|
generators.Add(factionKey, generator);
|
|
|
|
|
}
|
|
|
|
|
return generator;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var potential = celestial.Orbits
|
|
|
|
|
.Where(orbit => !string.IsNullOrWhiteSpace(orbit.ParentOrbitKey))
|
|
|
|
|
.Where(orbit => !celestial.Orbits.Any(sibling =>
|
|
|
|
|
!ReferenceEquals(sibling, orbit) &&
|
|
|
|
|
string.Equals(sibling.ParentOrbitKey, orbit.ParentOrbitKey, StringComparison.Ordinal) &&
|
|
|
|
|
Math.Abs(sibling.Distance - orbit.Distance) < 0.1))
|
|
|
|
|
.OrderBy(orbit => orbit.Distance)
|
|
|
|
|
.ToArray();
|
|
|
|
|
var selected = potential.Skip(potential.Length / 2).Take(Math.Max(0, stationCount)).ToArray();
|
|
|
|
|
for (var stationIndex = 0; stationIndex < selected.Length; stationIndex++)
|
|
|
|
|
{
|
|
|
|
|
var baseOrbit = selected[stationIndex];
|
|
|
|
|
var stationOrbit = new AetheriaOrbitSnapshot
|
|
|
|
|
{
|
2026-07-17 10:03:10 +01:00
|
|
|
OrbitKey = $"{identityPrefix}.zone.{topology.ZoneIndex}.station.{stationIndex}.orbit",
|
2026-07-17 08:19:21 +01:00
|
|
|
ParentOrbitKey = baseOrbit.ParentOrbitKey,
|
|
|
|
|
Distance = baseOrbit.Distance,
|
|
|
|
|
Phase = baseOrbit.Phase + MathF.PI / 3 * Sign(random.NextFloat() - 0.5f),
|
2026-07-17 09:09:00 +01:00
|
|
|
Period = baseOrbit.Distance,
|
2026-07-17 08:19:21 +01:00
|
|
|
FixedPosition = new AetheriaVector2()
|
|
|
|
|
};
|
|
|
|
|
orbits.Add(stationOrbit);
|
|
|
|
|
var security = (int)((1f - MathF.Pow(random.NextFloat(), factionPresence / 2f)) * 3f);
|
|
|
|
|
entities.Add(AetheriaDaemonGeneratedEntityFactory.Create(
|
|
|
|
|
$"{topology.Name} Station {stationIndex + 1}",
|
|
|
|
|
"station",
|
|
|
|
|
nearest.CorporationKey,
|
|
|
|
|
Generator(nearest.CorporationKey).Build("station", nearest.CorporationKey),
|
|
|
|
|
orbitKey: stationOrbit.OrbitKey,
|
|
|
|
|
securityLevel: security,
|
|
|
|
|
securityRadius: celestial.Radius));
|
|
|
|
|
|
|
|
|
|
for (var turretIndex = 0; turretIndex < 2; turretIndex++)
|
|
|
|
|
{
|
|
|
|
|
var distanceMultiplier = turretIndex / 2;
|
|
|
|
|
if (turretIndex % 2 == 0) distanceMultiplier = -distanceMultiplier;
|
|
|
|
|
var turretOrbit = new AetheriaOrbitSnapshot
|
|
|
|
|
{
|
2026-07-17 10:03:10 +01:00
|
|
|
OrbitKey = $"{identityPrefix}.zone.{topology.ZoneIndex}.station.{stationIndex}.turret.{turretIndex}.orbit",
|
2026-07-17 08:19:21 +01:00
|
|
|
ParentOrbitKey = stationOrbit.ParentOrbitKey,
|
|
|
|
|
Distance = stationOrbit.Distance,
|
|
|
|
|
Phase = stationOrbit.Phase + 20f * distanceMultiplier / stationOrbit.Distance,
|
2026-07-17 09:09:00 +01:00
|
|
|
Period = stationOrbit.Distance,
|
2026-07-17 08:19:21 +01:00
|
|
|
FixedPosition = new AetheriaVector2()
|
|
|
|
|
};
|
|
|
|
|
orbits.Add(turretOrbit);
|
|
|
|
|
entities.Add(AetheriaDaemonGeneratedEntityFactory.Create(
|
|
|
|
|
$"{topology.Name} Turret {stationIndex + 1}-{turretIndex + 1}",
|
|
|
|
|
"turret",
|
|
|
|
|
nearest.CorporationKey,
|
|
|
|
|
Generator(nearest.CorporationKey).Build("turret", nearest.CorporationKey),
|
|
|
|
|
orbitKey: turretOrbit.OrbitKey,
|
|
|
|
|
securityLevel: security,
|
|
|
|
|
securityRadius: celestial.Radius));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var enemyCount = (int)(random.NextFloat() * factionPresence * 2) + stationCount;
|
|
|
|
|
for (var enemyIndex = 0; enemyIndex < Math.Max(0, enemyCount); enemyIndex++)
|
|
|
|
|
{
|
2026-07-17 08:57:40 +01:00
|
|
|
var halfRadius = celestial.Radius * 0.5;
|
2026-07-17 08:19:21 +01:00
|
|
|
var enemy = AetheriaDaemonGeneratedEntityFactory.Create(
|
|
|
|
|
$"{nearest.ShortName} Ship {enemyIndex + 1}",
|
|
|
|
|
"ship",
|
|
|
|
|
nearest.CorporationKey,
|
2026-07-17 08:57:40 +01:00
|
|
|
Generator(nearest.CorporationKey).Build("ship", nearest.CorporationKey),
|
|
|
|
|
spawnRandom.NextFloat((float)-halfRadius, (float)halfRadius),
|
|
|
|
|
spawnRandom.NextFloat((float)-halfRadius, (float)halfRadius));
|
2026-07-17 09:23:29 +01:00
|
|
|
var entityIndex = entities.Count;
|
2026-07-17 10:03:10 +01:00
|
|
|
var taskId = $"{identityPrefix}.zone.{topology.ZoneIndex}.entity.{entityIndex}.patrol";
|
2026-07-17 09:23:29 +01:00
|
|
|
enemy.AgentTaskCapabilities =
|
|
|
|
|
[
|
|
|
|
|
AetheriaRuntimeAgentTaskTypes.Attack,
|
|
|
|
|
AetheriaRuntimeAgentTaskTypes.Defend,
|
|
|
|
|
AetheriaRuntimeAgentTaskTypes.Explore,
|
|
|
|
|
AetheriaRuntimeAgentTaskTypes.Patrol
|
|
|
|
|
];
|
|
|
|
|
enemy.AssignedAgentTaskId = taskId;
|
|
|
|
|
agentTasks.Add(new AetheriaAgentTaskState
|
|
|
|
|
{
|
|
|
|
|
TaskId = taskId,
|
|
|
|
|
CorporationKey = nearest.CorporationKey,
|
|
|
|
|
TaskType = AetheriaRuntimeAgentTaskTypes.Patrol,
|
|
|
|
|
Priority = 0,
|
|
|
|
|
ZoneIndex = topology.ZoneIndex,
|
|
|
|
|
Status = AetheriaRuntimeAgentTaskStatuses.Assigned,
|
|
|
|
|
AssignedEntityIndex = entityIndex,
|
|
|
|
|
CompletionRadius = 10,
|
|
|
|
|
TargetOrbitKeys = orbits
|
|
|
|
|
.OrderBy(_ => spawnRandom.NextFloat())
|
|
|
|
|
.Take(4)
|
|
|
|
|
.Select(orbit => orbit.OrbitKey)
|
|
|
|
|
.ToArray()
|
|
|
|
|
});
|
2026-07-17 08:19:21 +01:00
|
|
|
entities.Add(enemy);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var localPlayerIndex = -1;
|
|
|
|
|
if (topology.ZoneIndex == world.Topology.EntranceZoneIndex)
|
|
|
|
|
{
|
|
|
|
|
var protagonist = factions[0];
|
|
|
|
|
var starter = new AetheriaDaemonLoadoutGenerator(
|
|
|
|
|
catalog,
|
|
|
|
|
world.Topology.GenerationSeed ^ 0x50A7_EA11u,
|
|
|
|
|
topology.ZoneIndex,
|
|
|
|
|
homes,
|
2026-07-17 08:48:56 +01:00
|
|
|
adjacency,
|
2026-07-17 10:03:10 +01:00
|
|
|
isPrelude: isPrelude).Build("ship", protagonist.CorporationKey);
|
2026-07-17 08:19:21 +01:00
|
|
|
localPlayerIndex = entities.Count;
|
|
|
|
|
entities.Add(AetheriaDaemonGeneratedEntityFactory.Create(
|
|
|
|
|
"Pilot",
|
|
|
|
|
"ship",
|
|
|
|
|
protagonist.CorporationKey,
|
|
|
|
|
starter));
|
|
|
|
|
playerEntityIndex = localPlayerIndex;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 08:57:40 +01:00
|
|
|
ApplyOrbitalPositions(orbits, entities);
|
|
|
|
|
|
2026-07-17 08:19:21 +01:00
|
|
|
zones.Add(topology.ZoneIndex, new AetheriaDaemonMaterializedTutorialZone(
|
|
|
|
|
topology,
|
|
|
|
|
celestial,
|
|
|
|
|
nearest.CorporationKey,
|
|
|
|
|
factionPresence,
|
|
|
|
|
orbits.ToArray(),
|
|
|
|
|
entities.ToArray(),
|
2026-07-17 09:23:29 +01:00
|
|
|
agentTasks.ToArray(),
|
2026-07-17 08:19:21 +01:00
|
|
|
localPlayerIndex));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (playerEntityIndex < 0)
|
|
|
|
|
throw new InvalidOperationException("Tutorial materialization produced no player entry ship.");
|
|
|
|
|
return new AetheriaDaemonMaterializedTutorialWorld(
|
|
|
|
|
world.Topology,
|
|
|
|
|
zones,
|
|
|
|
|
world.Topology.EntranceZoneIndex,
|
|
|
|
|
playerEntityIndex);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 08:57:40 +01:00
|
|
|
private static void ApplyOrbitalPositions(
|
|
|
|
|
IReadOnlyList<AetheriaOrbitSnapshot> orbits,
|
|
|
|
|
IReadOnlyList<AetheriaEntitySnapshot> entities)
|
|
|
|
|
{
|
|
|
|
|
var runtimeZone = new AetheriaRuntimeZoneSnapshotCommit
|
|
|
|
|
{
|
|
|
|
|
Orbits = orbits.Select(orbit => new AetheriaRuntimeOrbitSnapshotCommit
|
|
|
|
|
{
|
|
|
|
|
OrbitKey = orbit.OrbitKey,
|
|
|
|
|
ParentOrbitKey = orbit.ParentOrbitKey,
|
|
|
|
|
Distance = orbit.Distance,
|
|
|
|
|
Phase = orbit.Phase,
|
2026-07-17 09:09:00 +01:00
|
|
|
Period = orbit.Period,
|
2026-07-17 08:57:40 +01:00
|
|
|
FixedPositionX = orbit.FixedPosition?.X ?? 0,
|
|
|
|
|
FixedPositionY = orbit.FixedPosition?.Y ?? 0
|
|
|
|
|
}).ToArray()
|
|
|
|
|
};
|
|
|
|
|
var positions = AetheriaRuntimeOrbitQueries.BuildPositions(runtimeZone);
|
|
|
|
|
foreach (var entity in entities.Where(entity => !string.IsNullOrWhiteSpace(entity.OrbitKey)))
|
|
|
|
|
{
|
|
|
|
|
if (!positions.TryGetValue(entity.OrbitKey, out var position))
|
|
|
|
|
throw new InvalidDataException(
|
|
|
|
|
$"Generated orbital entity '{entity.Name}' references missing orbit '{entity.OrbitKey}'.");
|
|
|
|
|
entity.Position.X = position.x;
|
|
|
|
|
entity.Position.Z = position.z;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 08:19:21 +01:00
|
|
|
private static IReadOnlyDictionary<int, int> DistancesFrom(
|
|
|
|
|
int start,
|
|
|
|
|
IReadOnlyDictionary<int, IReadOnlyList<int>> adjacency)
|
|
|
|
|
{
|
|
|
|
|
var result = adjacency.Keys.ToDictionary(index => index, _ => int.MaxValue);
|
|
|
|
|
var queue = new Queue<int>();
|
|
|
|
|
result[start] = 0;
|
|
|
|
|
queue.Enqueue(start);
|
|
|
|
|
while (queue.Count > 0)
|
|
|
|
|
{
|
|
|
|
|
var current = queue.Dequeue();
|
|
|
|
|
foreach (var neighbor in adjacency[current])
|
|
|
|
|
{
|
|
|
|
|
if (result[neighbor] <= result[current] + 1) continue;
|
|
|
|
|
result[neighbor] = result[current] + 1;
|
|
|
|
|
queue.Enqueue(neighbor);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static int Sign(float value) => value < 0 ? -1 : value > 0 ? 1 : 0;
|
|
|
|
|
}
|