Aetheria/Assets/Scripts/ServerShared/ZoneGenerator.cs
soul 4848c5332f Ore geography: yields, provinces cut inside boss-zone regions, types dealt per region
FieldYield and FieldKindData.Yields (key 3), ProvinceData, FieldKinds.Pick/Assign/Ensure/Yield
with a province type, Provinces.Regions/Partition/Deal, Galaxy.Provinces built once by the
generation and save constructors, SavedGame key 15 and SavedZone key 6, ProvinceZones setting.
Not yet built or run; tests follow in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 06:42:58 +01:00

921 lines
44 KiB
C#

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using MessagePack;
using UniRx;
using CultMath;
using static CultMath.math;
using float2 = CultMath.float2;
using Random = CultMath.Random;
public static class ZoneGenerator
{
private class Circle
{
public float2 Center;
public float Radius;
public float Area => PI * Radius * Radius;
public Circle(float2 center, float radius)
{
Center = center;
Radius = radius;
}
public float DistanceTo(float2 point) => length(point - Center) - Radius;
public float DistanceTo(Circle other) => length(other.Center - Center) - Radius - other.Radius;
}
private const int MaximumPlacementSamples = 32;
// ambient: false keeps the planets, orbits and stations but generates no ship and no turret, so nothing wanders
// into a scenario's authored conditions (docs/scenarios-cut.md, 2.1). The rule is applied here, not by pruning.
public static ZonePack GenerateZone(
ItemManager itemManager,
ZoneGenerationSettings zoneSettings,
PlanetSettings planetSettings,
Galaxy galaxy,
GalaxyZone galaxyZone,
bool isTutorial = false,
bool ambient = true)
{
var pack = new ZonePack();
var cache = itemManager.ItemData;
var seed = galaxyZone.Name.StableHash() ^ (uint) pcg3d(galaxyZone.Position).x;
var random = new Random(seed);
var density = saturate(galaxy.Background.CloudDensity(galaxyZone.Position)/2);
pack.Radius = zoneSettings.ZoneRadius.Evaluate(density);
pack.Mass = zoneSettings.ZoneMass.Evaluate(density);
var targetSubzoneCount = zoneSettings.SubZoneCount.Evaluate(density);
//Debug.Log($"Generating zone at position {zone.Position} with radius {zoneRadius} and mass {zoneMass}");
var planets = new List<GeneratorPlanet>();
if (targetSubzoneCount > 1)
{
var zoneBoundary = new Circle(float2.zero, pack.Radius * zoneSettings.ZoneBoundaryRadius);
float boundaryTangentRadius(float2 point) => -zoneBoundary.DistanceTo(point);
var occupiedAreas = new List<Circle>();
float tangentRadius(float2 point) => min(boundaryTangentRadius(point), occupiedAreas.Min(circle => circle.DistanceTo(point)));
var startPosition = random.NextFloat(pack.Radius * .25f, pack.Radius * .5f) * random.NextFloat2Direction();
occupiedAreas.Add(new Circle(startPosition, boundaryTangentRadius(startPosition)));
int samples = 0;
while (occupiedAreas.Count < targetSubzoneCount && samples < MaximumPlacementSamples)
{
samples = 0;
for (int i = 0; i < MaximumPlacementSamples; i++)
{
var samplePos = random.NextFloat2(-pack.Radius, pack.Radius);
var rad = tangentRadius(samplePos);
if (rad > 0)
{
occupiedAreas.Add(new Circle(samplePos, rad));
break;
}
samples++;
}
}
var totalArea = occupiedAreas.Sum(c => c.Area);
// Every area root is massive, so a root's reach is Reach x its nearest area's centre (WellTree.SubzoneReach)
var centres = occupiedAreas.Select(c => c.Center).ToList();
for (var k = 0; k < occupiedAreas.Count; k++)
{
var c = occupiedAreas[k];
planets.AddRange(GenerateEntities(zoneSettings, planetSettings, ref random, c.Area / totalArea * pack.Mass, c.Radius, c.Center,
WellTree.SubzoneReach(centres, k)));
}
}
else
planets.AddRange(GenerateEntities(zoneSettings, planetSettings, ref random, pack.Mass, pack.Radius, float2.zero, float.PositiveInfinity));
// Every orbit is a record; parents link to records, so each orbit holds its key before any child names it
var orbitMap = new Dictionary<GeneratorPlanet, OrbitData>();
foreach (var planet in planets)
cache.Upsert(orbitMap[planet] = new OrbitData
{
FixedPosition = planet.FixedPosition,
Distance = planet.Distance,
Phase = planet.Phase
});
foreach (var planet in planets.Where(p => p.Parent != null))
{
orbitMap[planet].Parent = cache.RefOf(orbitMap[planet.Parent]);
cache.Upsert(orbitMap[planet]);
}
pack.Orbits = planets.Select(planet => cache.RefOf(orbitMap[planet])).ToList();
var beltOf = new Dictionary<GeneratorPlanet, AsteroidBeltData>();
pack.Planets = planets.Where(p=>!p.Empty).Select((planet, index) =>
{
BodyType bodyType = planet.Belt ? BodyType.Asteroid :
planet.Mass > zoneSettings.SunMass ? BodyType.Sun :
planet.Mass > zoneSettings.GasGiantMass ? BodyType.GasGiant :
planet.Mass > zoneSettings.PlanetMass ? BodyType.Planet : BodyType.Planetoid;
BodyData planetData;
switch (bodyType)
{
case BodyType.Asteroid:
planetData = new AsteroidBeltData();
break;
case BodyType.Planetoid:
case BodyType.Planet:
planetData = new PlanetData();
break;
case BodyType.GasGiant:
planetData = new GasGiantData();
break;
case BodyType.Sun:
planetData = new SunData();
break;
default:
throw new ArgumentOutOfRangeException();
}
planetData.Mass = planet.Mass;
planetData.Orbit = cache.RefOf(orbitMap[planet]);
if (planetData is AsteroidBeltData beltData)
{
beltOf[planet] = beltData;
beltData.Asteroids =
Enumerable.Range(0, (int) zoneSettings.AsteroidCount.Evaluate(beltData.Mass * orbitMap[planet].Distance))
.Select(_ => new Asteroid
{
Distance = orbitMap[planet].Distance + random.NextFloat()*(random.NextFloat()-.5f)*2*planet.BeltHalfWidth,
Phase = random.NextFloat(),
Size = random.NextFloat(),
RotationSpeed = zoneSettings.AsteroidRotationSpeed.Evaluate(random.NextFloat())
})
//.OrderByDescending(a=>a.Size)
.ToArray();
}
else if (planetData is GasGiantData gas)
{
if (gas is SunData sun)
{
var hueDraw = random.NextFloat();
float primary = ColorMath.HueOutsideBand(hueDraw, zoneSettings.SunHueExcludedStart, zoneSettings.SunHueExcludedEnd);
float secondary = ColorMath.SecondaryHueOutsideBand(hueDraw, random.NextFloat() > .5, zoneSettings.SunSecondaryColorDistance,
zoneSettings.SunHueExcludedStart, zoneSettings.SunHueExcludedEnd);
gas.Colors = new []
{
float4(ColorMath.HsvToRgb(float3(primary, zoneSettings.SunColorSaturation, .5f)), 0),
float4(ColorMath.HsvToRgb(float3(secondary, zoneSettings.SunColorSaturation, 1)), 1)
};
sun.FogTintColor = ColorMath.HsvToRgb(float3(primary, zoneSettings.SunFogTintSaturation, 1));
sun.LightColor = ColorMath.HsvToRgb(float3(primary, zoneSettings.SunLightSaturation, 1));
gas.FirstOffsetDomainRotationSpeed = 5;
}
else
{
// Define primary color and two adjacent colors
float primary = random.NextFloat();
float right = frac(primary + zoneSettings.GasGiantBandColorSeparation);
float left = frac(primary + 1 - zoneSettings.GasGiantBandColorSeparation);
// Create n time keys from 0 to 1
var bandCount = (int) (zoneSettings.GasGiantBandCount.Evaluate(random.NextFloat()) + .5f);
var times = Enumerable.Range(0, bandCount)
.Select(i => (float) i / (bandCount-1));
// Each band has a chance of being either the primary or one of the adjacent hues
// Saturation and Value are random with curves applied
gas.Colors = times
.Select(time => float4(ColorMath.HsvToRgb(float3(
random.NextFloat() > zoneSettings.GasGiantBandAltColorChance ? primary : (random.NextFloat() > .5f ? right : left),
zoneSettings.GasGiantBandSaturation.Evaluate(random.NextFloat()),
zoneSettings.GasGiantBandSaturation.Evaluate(random.NextFloat()))),time))
.ToArray();
gas.FirstOffsetDomainRotationSpeed = 0;
}
gas.AlbedoRotationSpeed = -3;
gas.FirstOffsetRotationSpeed = 5;
gas.SecondOffsetRotationSpeed = 10;
gas.SecondOffsetDomainRotationSpeed = -25;
}
// A body is named for the first eight characters of the key the cache mints for it
var bodyKey = cache.Upsert(planetData).Key;
planetData.Name = bodyKey.Value.Substring(0, 8);
// Mining Cut 3 (Q16 B): a belt's field kind is chosen here, at generation, and saved with it.
if (planetData is AsteroidBeltData generatedBelt) FieldKinds.Ensure($"{seed}:belt:{index}", generatedBelt, cache, galaxyZone.Province?.Type ?? default);
return cache.Upsert(planetData);
}).ToList();
// Trojan swarms (rulings trojans-first, trojans-ride-host-period-with-stations): a belt on a copy of its host's rail, its
// rocks round both L4 and L5. Drawn from a stream of its own, so no other draw in the zone moves; not a GeneratorPlanet,
// so it takes no slot and no station candidacy sees it. A standing binary leaves a bodyless node (Empty) that keeps its
// pair's mass and a parent, so the Empty guard is what keeps a swarm off a rail no body is on.
var swarmRandom = new Random(seed ^ TrojanStream);
var swarmHosts = new HashSet<GeneratorPlanet>();
foreach (var host in planets.Where(p => p.Parent != null && !p.Parent.Empty && !p.Empty && !p.Belt && p.Distance > 0
&& p.Mass > zoneSettings.PlanetMass && p.Mass <= zoneSettings.SunMass))
{
if (swarmRandom.NextFloat() >= zoneSettings.TrojanProbability) continue;
swarmHosts.Add(host);
var hostOrbit = orbitMap[host];
var rail = new OrbitData { Parent = hostOrbit.Parent, Distance = hostOrbit.Distance, Phase = hostOrbit.Phase };
pack.Orbits.Add(cache.Upsert(rail));
var swarm = new AsteroidBeltData
{
Orbit = cache.RefOf(rail), RidesRail = true,
Asteroids = TrojanRocks(zoneSettings, ref swarmRandom, rail.Distance, rail.Phase)
};
BodyData swarmBody = swarm;
var swarmKey = cache.Upsert(swarmBody).Key;
swarm.Name = swarmKey.Value.Substring(0, 8);
FieldKinds.Ensure($"{seed}:swarm:{swarmHosts.Count}", swarm, cache, galaxyZone.Province?.Type ?? default);
pack.Planets.Add(cache.Upsert(swarmBody));
}
// Laned belts (rulings belt-shapes-direction, lanes-two-sided-visual-margin): a belt candidate that kept its body carries
// an annulus on a copy of its rail that fills its slot less a lane round the body, rocks on both sides. The lane is sized
// against the body the player sees (LaneBodyRadii body radii a side), and only a body whose slot holds a band at least
// that wide on both sides gets one. One belt shape per parent, and none on a swarm host. Drawn from a stream of its own,
// so no other draw in the zone moves; its rocks sit inside the host's slot, below the next sibling's spacing, so they
// raise no L_F and no support: the lane moves no well, period or height.
var laneRandom = new Random(seed ^ LaneStream);
var shaped = new HashSet<GeneratorPlanet>(planets.Where(p => p.Belt).Select(p => p.Parent));
foreach (var host in planets.Where(p => p.BeltCandidate && !p.Belt))
{
if (laneRandom.NextFloat() >= zoneSettings.LaneProbability) continue;
var lane = zoneSettings.LaneBodyRadii * planetSettings.BodyRadius.Evaluate(host.Mass);
if (swarmHosts.Contains(host) || shaped.Contains(host.Parent) || !LaneHost(host, lane)) continue;
shaped.Add(host.Parent);
var hostOrbit = orbitMap[host];
var rail = new OrbitData { Parent = hostOrbit.Parent, Distance = hostOrbit.Distance, Phase = hostOrbit.Phase };
pack.Orbits.Add(cache.Upsert(rail));
var laned = new AsteroidBeltData
{
Orbit = cache.RefOf(rail),
Asteroids = LaneRocks(zoneSettings, ref laneRandom, host.Mass, rail.Distance, lane, host.SlotInner, host.SlotOuter)
};
BodyData lanedBody = laned;
var lanedKey = cache.Upsert(lanedBody).Key;
laned.Name = lanedKey.Value.Substring(0, 8);
FieldKinds.Ensure($"{seed}:lane:{shaped.Count}", laned, cache, galaxyZone.Province?.Type ?? default);
pack.Planets.Add(cache.Upsert(lanedBody));
}
// The generator's own well tree, for clearance only (the Zone builds its own): what a rail must stay out of is the body's
// support, or its Roche margin if more. A belt's extent is the rule Zone reads (AsteroidBelt.Extent). Swarm and lane rails
// are left out: a swarm's extent is its own rail, a copy of its host's, and a lane's outer rock sits inside its host's slot,
// below the next sibling's spacing, so neither raises an L_F or a support.
var wellNodes = planets.Select(p => new WellNode(cache.RefOf(orbitMap[p]).Key, p.Parent == null ? default : cache.RefOf(orbitMap[p.Parent]).Key,
p.Distance, p.Phase, p.Empty || p.Belt ? 0f : p.Mass,
p.Belt ? AsteroidBelt.Extent(beltOf[p], p.Distance) : p.Distance, p.FixedPosition)).ToList();
var wells = WellTree.Build(wellNodes, planetSettings, pack.Radius);
float Extent(GeneratorPlanet p)
{
var key = cache.RefOf(orbitMap[p]).Key;
var roche = p.Empty || p.Belt ? 0f : WellTree.Curves(planetSettings, p.Mass).Softening * RocheMargin;
return Math.Max(wells.Support(key), roche);
}
bool Massive(GeneratorPlanet p) => wells.Reach(cache.RefOf(orbitMap[p]).Key) > 0f;
var nearestFaction = galaxy.Factions.MinBy(f => galaxy.HomeZones[f].Distance[galaxyZone]);
var nearestFactionHomeZone = galaxy.HomeZones[nearestFaction];
var factionPresence = galaxy.InfluenceOf(nearestFaction) - nearestFactionHomeZone.Distance[galaxyZone] + 1;
var storyStations = galaxyZone.Locations.Where(story => story.Type == LocationType.Station).ToArray();
var stationCount = (int)(random.NextFloat() * (factionPresence + 1)) + storyStations.Length;
// Enemy count below is based on this pre-override value, so the tutorial entrance doesn't gain an enemy
// just because its station got forced.
var baseStationCount = stationCount;
// Operator ruling, 2026-09-25 (docs/locomotion-cut.md, "Tutorial station"): the entrance zone always
// gets a generated faction station with a docking bay, deterministically. Galaxy.cs halves influence and
// picks the lowest-influence unowned zone for Entrance, which otherwise rolls stationCount 0 on every
// seed. galaxy.Entrance is reference-identity, not a name comparison.
var isTutorialEntrance = isTutorial && galaxyZone == galaxy.Entrance;
if (isTutorialEntrance && stationCount < 1) stationCount = 1;
// A station's source is a well-bearing body alone on its ring whose rail clears every massive sibling's extent (the
// sibling's support, or its Roche margin if more) and the largest reach of any station or turret hull: whatever hull
// is built there, its Lagrange orbit then sits clear of any other body.
var cellSize = itemManager.GameplaySettings.SchematicCellSize;
var largestReach = cache.GetAll<HullData>().Where(h => h.HullType == HullType.Station || h.HullType == HullType.Turret)
.Select(h => h.Reach(cellSize)).DefaultIfEmpty(0f).Max();
var candidates = planets
.Where(p => p.Parent != null && !p.Empty && !p.Belt
&& p.Parent.Children.TrueForAll(c => c == p || (abs(c.Distance - p.Distance) >= WellTree.RingWindow
&& (!Massive(c) || abs(c.Distance - p.Distance) > Extent(c) + largestReach))))
.OrderBy(p => p.Distance)
.ToArray();
// (the rail, how far a body on it keeps others out, whether it is a station's own orbit rather than a base's)
var sources = candidates.Skip(candidates.Length / 2).Take(stationCount)
.Select(p => (orbit: orbitMap[p], extent: Extent(p), direct: false)).ToArray();
if (isTutorialEntrance && candidates.Length == 0)
{
// The entrance gets a station whatever its bodies: it orbits a root node beyond its ring group (or, for a body, its
// whole family), never at a ring member's distance, and sits there, not at a Lagrange point.
var roots = planets.Where(p => p.Parent == null).ToList();
var rootNode = roots.FirstOrDefault(p => p.Children.Count > 0) ?? (roots.Count > 1 ? roots.OrderByDescending(p => p.Mass).First() : null);
if (rootNode == null)
throw new InvalidOperationException(
"The tutorial entrance zone must always get a station, but it has no candidate orbit at all -- every planet in the zone is a parentless root.");
var distance = rootNode.Empty
? (1 + GravityField.RingReach) * GravityField.Beta * rootNode.Children.Where(Massive).Min(c => c.Distance)
: Extent(rootNode);
var direct = new OrbitData { Parent = cache.RefOf(orbitMap[rootNode]), Distance = distance, Phase = random.NextFloat() };
pack.Orbits.Add(cache.Upsert(direct));
sources = new[] { (orbit: direct, extent: float.NegativeInfinity, direct: true) };
}
var selectedStationOrbits = sources.Select(x => x.orbit).ToArray();
var extentOf = sources.ToDictionary(x => x.orbit, x => x.extent);
var loadoutGenerators = new Dictionary<Faction, LoadoutGenerator>();
LoadoutGenerator GetLoadoutGenerator(Faction faction)
{
if (!loadoutGenerators.ContainsKey(faction))
loadoutGenerators[faction] = new LoadoutGenerator(ref random, itemManager, galaxy, galaxyZone, faction, .5f);
return loadoutGenerators[faction];
}
// L4 or L5: 60 degrees (one sixth of a turn) ahead of or behind the planet on its orbit. Phase is in turns.
OrbitData CreateLagrangeOrbit(OrbitData baseOrbit)
{
if (sources.First(x => x.orbit == baseOrbit).direct) return baseOrbit;
var lagrangeOrbit = new OrbitData
{
Parent = baseOrbit.Parent,
Distance = baseOrbit.Distance,
Phase = baseOrbit.Phase + sign(random.NextFloat() - .5f) / 6f,
};
pack.Orbits.Add(cache.Upsert(lagrangeOrbit));
return lagrangeOrbit;
}
// A turret co-orbits with its station, on one side of it (side is -1 behind, +1 ahead), clear of the body inside
// it on that side by one of the turret's own radii: its centre sits innerReach + 2 turretReach beyond that
// body's. Every body's reach counts, so turrets of differing reach clear each other. Phase is in turns, so the
// arc length divides by the orbit's circumference. Returns where the turret sits (signed arc from the station)
// and its reach, or null when there is no turret hull to generate, before any orbit is created.
(float arc, float reach, bool placed)? PlaceTurret(OrbitData orbit, LoadoutGenerator loadoutGenerator, float side, float innerArc, float innerReach,
OrbitData baseOrbit)
{
var turret = loadoutGenerator.GenerateTurretLoadout();
if (turret == null) return null;
var turretReach = (itemManager.GetData(turret.Hull) as HullData).Reach(itemManager.GameplaySettings.SchematicCellSize);
var arc = innerArc + side * (innerReach + 2 * turretReach);
// A turret whose centre is within the base body's extent plus its own reach of the base is not placed: its side stops.
var phase = orbit.Phase + arc / (2 * PI * orbit.Distance);
if (2 * orbit.Distance * abs(sin(PI * (phase - baseOrbit.Phase))) < extentOf[baseOrbit] + turretReach)
{
itemManager.Log("A turret was not placed: its station's base body is within its reach (story-turret-tail-clearance).");
return (arc, turretReach, false);
}
var turretOrbit = new OrbitData
{
Parent = orbit.Parent,
Distance = orbit.Distance,
Phase = phase
};
pack.Orbits.Add(cache.Upsert(turretOrbit));
turret.Orbit = cache.RefOf(turretOrbit);
pack.Entities.Add(turret);
return (arc, turretReach, true);
}
// Turrets flank the station alternately, behind then ahead, each side growing outward from the station.
void PlaceTurrets(OrbitData orbit, LoadoutGenerator loadoutGenerator, int count, float stationReach, OrbitData baseOrbit)
{
var arcs = new float[2];
var stopped = new bool[2];
var reaches = new[] { stationReach, stationReach };
for(int t=0; t<count; t++)
{
var side = t % 2;
if (stopped[side]) continue;
var placed = PlaceTurret(orbit, loadoutGenerator, side == 0 ? -1f : 1f, arcs[side], reaches[side], baseOrbit);
if (placed == null) return;
if (!placed.Value.placed) { stopped[side] = true; continue; }
(arcs[side], reaches[side]) = (placed.Value.arc, placed.Value.reach);
}
}
float StationReach(OrbitalEntityPack station) =>
(itemManager.GetData(station.Hull) as HullData).Reach(itemManager.GameplaySettings.SchematicCellSize);
for (var i = 0; i < storyStations.Length; i++)
{
var story = storyStations[i];
if (i >= selectedStationOrbits.Length) { itemManager.Log($"Story station for {story.Faction?.Name} has no rail clear of the bodies; its story has nowhere to live!"); continue; }
var orbit = selectedStationOrbits[i];
var lagrangeOrbit = CreateLagrangeOrbit(orbit);
var station = GetLoadoutGenerator(story.Faction).GenerateStationLoadout(
StationKinds.Choose(cache, story.Faction, $"{seed}:station:{i}", itemManager.Log));
if (station == null)
{
itemManager.Log($"Story station for {story.Faction?.Name} could not be generated; its story has nowhere to live!");
continue;
}
station.Orbit = cache.RefOf(lagrangeOrbit);
station.SecurityLevel = story.Security;
station.SecurityRadius = pack.Radius;
station.Story = i;
pack.Entities.Add(station);
if (ambient) PlaceTurrets(lagrangeOrbit, GetLoadoutGenerator(story.Faction), story.Turrets, StationReach(station), orbit);
}
for (var i = storyStations.Length; i < selectedStationOrbits.Length; i++)
{
var orbit = selectedStationOrbits[i];
var security = (SecurityLevel) ((int) ((1f - pow(random.NextFloat(), factionPresence / 2f)) * 3f));
var lagrangeOrbit = CreateLagrangeOrbit(orbit);
var station = GetLoadoutGenerator(nearestFaction).GenerateStationLoadout(
StationKinds.Choose(cache, nearestFaction, $"{seed}:station:{i}", itemManager.Log));
if (station == null)
{
if (isTutorialEntrance)
throw new InvalidOperationException(
$"The tutorial entrance zone must always get a station, but {nearestFaction?.Name} has no station loadout to generate.");
continue;
}
station.Orbit = cache.RefOf(lagrangeOrbit);
station.SecurityLevel = security;
station.SecurityRadius = pack.Radius;
pack.Entities.Add(station);
if (ambient) PlaceTurrets(lagrangeOrbit, GetLoadoutGenerator(nearestFaction), 2, StationReach(station), orbit);
}
// A boss zone gets its faction's boss whether or not the zone is ambient: the boss is the run's goal, not
// wandering traffic. The boss is its faction's boss Loadout, built whole by Loadouts.Materialize and never drawn
// (ruling boss-gear-is-a-loadout); ChooseProduct only brands a design-only slot. RunGoal reads the mark; nothing
// else decides whether a boss lives.
foreach (var bossFaction in galaxy.BossZones.Where(entry => entry.Value == galaxyZone).Select(entry => entry.Key))
{
var loadout = Loadouts.BossOf(itemManager.ItemData, bossFaction, out var refusal);
if (loadout == null)
{
itemManager.Log($"{bossFaction.Name} has no boss: it {Loadouts.Describe(refusal)}. Its boss zone has no boss to kill.");
continue;
}
var failures = new List<string>();
var entity = Loadouts.Materialize(itemManager, null, loadout, design => GetLoadoutGenerator(bossFaction).ChooseProduct(design), failures); // the generator exists only once the Loadout is known to fit
if (entity == null)
{
itemManager.Log($"{bossFaction.Name}'s boss Loadout {loadout.Name} cannot be built: {string.Join("; ", failures)}");
continue;
}
entity.Faction = bossFaction;
var boss = EntitySerializer.Pack(entity);
boss.Boss = true;
pack.Entities.Add(boss);
pack.BossSpawned = true;
}
if (galaxyZone == galaxy.Exit && !pack.BossSpawned)
itemManager.Log("Galaxy.Exit has no boss: Galaxy.BossZones does not map a boss faction to the exit zone, or its boss Loadout is unset, missing or cannot be built (ZoneGenerator exit-without-boss). The exit gate stays sealed.");
if (!ambient) return pack;
var enemyCount = (int)(random.NextFloat() * factionPresence * 2) + baseStationCount;
for (int i = 0; i < enemyCount; i++)
{
var ship = GetLoadoutGenerator(nearestFaction).GenerateShipLoadout();
if (ship == null) continue;
pack.Entities.Add(ship);
}
// Neutral wanderers: ships belonging to a faction that neither owns the zone nor is its
// nearest faction, so existing hostility rules leave them non-hostile to the player.
// Testing affordance for hand-testing combat/targeting; none spawn if no faction qualifies.
var eligibleWandererFactions = EligibleWandererFactions(galaxy.Factions, galaxyZone.Owner, nearestFaction);
if (zoneSettings.NeutralWandererCount > 0 && eligibleWandererFactions.Length > 0)
{
var wandererFaction = eligibleWandererFactions[random.NextInt(eligibleWandererFactions.Length)];
var wandererLoadoutGenerator = GetLoadoutGenerator(wandererFaction);
for (int i = 0; i < zoneSettings.NeutralWandererCount; i++)
{
var ship = wandererLoadoutGenerator.GenerateShipLoadout();
if (ship == null) continue;
pack.Entities.Add(ship);
}
}
return pack;
}
// The neutral-wanderer faction pool: every galaxy faction except the zone owner and the nearest
// faction (whose ships are already the zone's hostile-when-trespassing/enemy spawns). Extracted so
// the selection rule is testable without running the full zone-generation pipeline.
public static Faction[] EligibleWandererFactions(Faction[] factions, Faction owner, Faction nearest) =>
factions.Where(f => f != owner && f != nearest).ToArray();
public static GeneratorPlanet[] GenerateEntities(ZoneGenerationSettings settings, PlanetSettings planetSettings, ref Random random, float mass, float radius, float2 fixedPosition,
float rootReach)
{
var root = new GeneratorPlanet
{
FixedPosition = fixedPosition,
Settings = settings,
Mass = mass
};
// There is some chance of generating a rosette or binary system
// Probabilities which are fixed for the entire galaxy are in GlobalData, contained in the GameContext
var rosette = random.NextFloat() < settings.RosetteProbability;
if (rosette)
{
// Create a rosette with a number of vertices between 2 and 9 inclusive
root.ExpandRosette(ref random, (int)(random.NextFloat(1, 5) + random.NextFloat(1, 5)));
// Create a small number of less massive "captured" planets orbiting past the rosette
root.ExpandSolar(
ref random,
count: (int)(random.NextFloat(1, 3) * random.NextFloat(1, 2)),
massMulMin: .6f,
massMulMax: .8f,
jupiterJump: 1,
massFraction: .1f);
var averageChildMass = root.Children.Sum(p => p.Mass) / root.Children.Count;
foreach(var p in root.Children.Where(c=>c.Mass>settings.GasGiantMass))
{
var m = p.Mass / averageChildMass;
// Give each child in the rosette its own mini solar system
p.ExpandSolar(
ref random,
count: (int) (random.NextFloat(1, 3 * m) + random.NextFloat(1, 3 * m)),
massMulMin: 0.75f,
massMulMax: 2.5f,
jupiterJump: random.NextFloat() * random.NextFloat() * 10 + 1,
massFraction: .5f
);
}
}
else
{
// Create a regular old boring solar system
root.ExpandSolar(
ref random,
count: random.NextInt(5, 15),
massMulMin: 0.75f,
massMulMax: 2.5f,
jupiterJump: random.NextFloat() * random.NextFloat() * 10 + 1,
massFraction: .25f
);
}
var alreadyExpanded = new List<GeneratorPlanet>();
var binaries = new List<GeneratorPlanet>();
for(int i=0; i<settings.SatellitePasses; i++)
{
// Get all children that are above the satellite creation mass floor and not rosette members
var satelliteCandidates = rosette
? root.AllPlanets().Where(p =>
p != root &&
p.Parent != root &&
p.Mass > settings.SatelliteCreationMassFloor &&
!alreadyExpanded.Contains(p))
: root.AllPlanets().Where(p =>
p != root &&
p.Mass > settings.SatelliteCreationMassFloor &&
!alreadyExpanded.Contains(p));
foreach (var planet in satelliteCandidates)
{
// There's a chance of generating satellites for each qualified planet
if (random.NextFloat() < settings.SatelliteCreationProbability)
{
// Sometimes the satellite is so massive that it forms a binary system (like Earth!)
if (random.NextFloat() < settings.BinaryCreationProbability)
{
planet.ExpandRosette(ref random, 2);
binaries.AddRange(planet.Children);
}
// Otherwise, terrestrial planets get a couple satellites while gas giants get many
else
{
planet.ExpandSolar(
ref random,
count: planet.Mass < settings.GasGiantMass ? random.NextInt(1, 3) : random.NextInt(2, 6),
massMulMin: .75f,
massMulMax: 1.5f,
jupiterJump: 1,
massFraction: .15f); // Planetary satellites are not nearly as massive as planets themselves
}
alreadyExpanded.Add(planet);
}
}
}
// Get all children that are below the belt creation mass floor and not rosette members, also exclude binaries
var beltCandidates = rosette
? root.AllPlanets().Where(p => p != root && p.Parent != root && p.Mass < settings.BeltMassCeiling && !binaries.Contains(p) && p.Children.Count == 0)
: root.AllPlanets().Where(p => p != root && p.Mass < settings.BeltMassCeiling && !binaries.Contains(p) && p.Children.Count == 0);
foreach(var planet in beltCandidates.Reverse())
{
planet.BeltCandidate = true;
if (random.NextFloat() < settings.BeltProbability && !planet.Parent.Children.Any(p=>p.Belt))
planet.Belt = true;
}
var totalMass = root.AllPlanets().Sum(p => p.Mass);
var rootMass = root.Mass;
foreach (var planet in root.AllPlanets())
planet.Mass = (planet.Mass / totalMass) * rootMass;
Place(root, planetSettings, radius, rootReach);
return root.AllPlanets().ToArray();
}
// Placement (tail-taming-law-r3-band-loose-cusp, c4-proceed-and-fly): a child sits at least RocheMargin of its parent's Roche
// limits out; a massive child of a body also far enough that its own pull peak beats CuspMargin times its parent's pull there;
// siblings sit SiblingSpacing of a reach apart. Every other number is a GravityField constant.
public const float RocheMargin = 1.5f;
private const uint TrojanStream = 0x9E3779B9u;
// A swarm's rocks: alternately round L4 and L5 of the rail at `distance` and `phase`, each within half an arc of its point
// along the orbit and a half-width of the rail across it, thickest at the point.
public static Asteroid[] TrojanRocks(ZoneGenerationSettings settings, ref Random random, float distance, float phase)
{
var rocks = new Asteroid[2 * (int) settings.TrojanCount.Evaluate(distance)];
for (var i = 0; i < rocks.Length; i++)
rocks[i] = new Asteroid
{
Distance = distance * (1 + random.NextFloat() * (random.NextFloat() - .5f) * 2 * settings.TrojanHalfWidth),
Phase = frac(phase + (i % 2 == 0 ? 1f : -1f) / 6f + random.NextFloat() * (random.NextFloat() - .5f) * settings.TrojanArc),
Size = random.NextFloat(),
RotationSpeed = settings.AsteroidRotationSpeed.Evaluate(random.NextFloat())
};
return rocks;
}
private const uint LaneStream = 0x85EBCA6Bu;
// Whether a placed body can carry a lane of half-width `lane`: its slot holds a band at least that wide on both sides
// (ruling lanes-two-sided-visual-margin), and it is not its parent's outermost child (ruling lane-not-outermost). Place
// stretches the chain to the window, so an outermost child's outer side is the window, not a sibling's spacing; a lane's outer
// band there would reach past every sibling's extent and raise its parent's support, and lanes move nothing else in the zone.
// An only child is its parent's outermost.
public static bool LaneHost(GeneratorPlanet host, float lane) =>
host.Parent.Children.Any(c => c.Distance > host.Distance)
&& host.SlotInner <= host.Distance - 2 * lane && host.SlotOuter >= host.Distance + 2 * lane;
// A laned belt's rocks: the count an annulus of the host's mass at `distance` would draw, over the bands [inner, distance - lane]
// and [distance + lane, outer], each rock in a band by its area and drawn across it as the annulus draws across its width.
public static Asteroid[] LaneRocks(ZoneGenerationSettings settings, ref Random random, float mass, float distance, float lane, float inner, float outer)
{
float Sq(float x) => x * x;
var innerArea = Sq(distance - lane) - Sq(inner);
var share = innerArea / (innerArea + Sq(outer) - Sq(distance + lane));
var rocks = new Asteroid[(int) settings.AsteroidCount.Evaluate(mass * distance)];
for (var i = 0; i < rocks.Length; i++)
{
var innerBand = random.NextFloat() < share;
var a = innerBand ? inner : distance + lane;
var b = innerBand ? distance - lane : outer;
rocks[i] = new Asteroid
{
Distance = (a + b) / 2 + random.NextFloat() * (random.NextFloat() - .5f) * (b - a),
Phase = random.NextFloat(),
Size = random.NextFloat(),
RotationSpeed = settings.AsteroidRotationSpeed.Evaluate(random.NextFloat())
};
}
return rocks;
}
public const float CuspMargin = 1.5f;
public const float SiblingSpacing = .5f;
/// <summary>
/// The one placement pass: gives every generated node its distance and every belt its half width, top-down. A body's children
/// are laid inside-out at the nearest the rules allow, the lightest family is folded into the parent (its mass kept) while the
/// outermost overflows the window, and the rest are stretched geometrically so the outermost sits at the window's edge. A
/// non-root's window ends before its own fade (Gamma x Reach x its distance); a root's, in a zone of several, ends under
/// rootReach / Beta so its support never meets another root's (root-window-floor), and is 0.75 x the area radius at most.
/// A rosette's ring is one orbit, and a member may itself be bodyless, which counts as one body of its family's mass; once its members have settled (their own children folded in), it folds into its node,
/// which becomes a body, if it cannot keep its heaviest member outside its Roche margin. Ported from docs/research/gravity-well-law/replace.py.
/// </summary>
public static void Place(GeneratorPlanet root, PlanetSettings planetSettings, float radius, float rootReach)
{
var q = 1 / (1 - SiblingSpacing * GravityField.Reach);
float Roche(float mass) => WellTree.Curves(planetSettings, mass).Softening;
float Strength(float mass) => WellTree.Curves(planetSettings, mass).Strength;
float WellMass(GeneratorPlanet p) => p.Empty || p.Belt ? 0f : p.Mass;
float Family(GeneratorPlanet p) => (p.Empty ? 0f : p.Mass) + p.Children.Sum(c => Family(c));
// A bodyless node pulls from afar as one body of its family's mass, which it becomes if its ring cannot stand (binaries-orbited-from-afar)
float OneBody(GeneratorPlanet p) => p.Empty ? Family(p) : WellMass(p);
// A folded family joins its target: a body takes its mass, a bodyless node (whose own Mass is no mass) takes it as a child,
// which its own Settle then places.
void Fold(GeneratorPlanet node, GeneratorPlanet into)
{
node.Parent.Children.Remove(node);
if (into.Empty) { node.Parent = into; into.Children.Add(node); }
else into.Mass += Family(node);
}
void PlaceChildren(List<GeneratorPlanet> children, Func<float> lower, float upper, GeneratorPlanet into)
{
upper *= 1 - 1e-6f;
float AtLeast(GeneratorPlanet child, float edge)
{
var m = OneBody(child);
var mInto = OneBody(into);
return m <= 0 || mInto <= 0 ? edge : Math.Max(edge, 2 * CuspMargin * Roche(m) * Strength(mInto) / Strength(m));
}
var lo = 0f;
var positions = new List<float>();
while (children.Count > 0)
{
lo = lower();
positions.Clear();
for (var k = 0; k < children.Count; k++)
positions.Add(AtLeast(children[k], k == 0 ? lo : Math.Max(lo, positions[k - 1] * q)));
if (positions[positions.Count - 1] <= upper) break;
var lightest = children.OrderBy(c => Family(c)).First();
Fold(lightest, into);
children.Remove(lightest);
}
if (children.Count == 0) return;
var g = children.Count > 1 ? pow(upper / positions[positions.Count - 1], 1f / (children.Count - 1)) : 1f;
for (var k = 0; k < children.Count; k++)
children[k].Distance = positions[k] * pow(g, k);
// Every child's slot: from the lower bound and the previous sibling's spacing out to the next sibling's spacing and the
// window. Place is the only writer of a slot; a belt fills its own, and the lane pass reads a body's. A belt is an
// interval inside its slot, the authored width the widest it may be.
var outer = 0f;
for (var k = 0; k < children.Count; k++)
{
var c = children[k];
c.SlotInner = Math.Max(lo, outer * q);
c.SlotOuter = k + 1 < children.Count ? Math.Min(upper, children[k + 1].Distance / q) : upper;
if (!c.Belt) { outer = c.Distance; continue; }
var inner = c.SlotInner;
var outerLimit = c.SlotOuter;
c.Distance = (inner + outerLimit) / 2;
var half = Math.Max(0f, Math.Min(c.Settings.AsteroidBeltWidth.Evaluate(c.Distance), outerLimit - inner) / 2);
c.BeltHalfWidth = half;
outer = c.Distance + half;
}
}
var chordCap = new Dictionary<GeneratorPlanet, float>(); // a ring member's children stay within .2 of the nearest partner's chord
void Settle(GeneratorPlanet node)
{
var isRoot = node.Parent == null;
var reach = isRoot ? rootReach : GravityField.Reach * node.Distance;
var share = isRoot ? 1 / GravityField.Beta : GravityField.Gamma;
var cap = isRoot ? .75f * radius : float.PositiveInfinity;
var upper = Math.Min(cap, share * reach / Math.Max(GravityField.Beta, 1 + GravityField.Reach));
if (chordCap.TryGetValue(node, out var chord)) upper = Math.Min(upper, chord);
if (WellMass(node) > 0)
{
PlaceChildren(node.Children.ToList(), () => RocheMargin * Roche(WellMass(node)), upper, node);
foreach (var child in node.Children.ToList()) Settle(child);
return;
}
var ring = node.Children.Where(c => c.Ring && OneBody(c) > 0).ToList();
if (ring.Count == 0) { foreach (var child in node.Children.ToList()) Settle(child); return; }
var heavy = ring.OrderByDescending(OneBody).First();
var ringDistance = Math.Min(.5f * cap, share * reach / (GravityField.Beta * GravityField.Beta * (1 + GravityField.RingReach)));
PlaceChildren(node.Children.Where(c => !ring.Contains(c)).ToList(),
() => (1 + GravityField.RingReach) * ringDistance * GravityField.Beta, upper, heavy);
foreach (var member in ring)
{
member.Distance = ringDistance;
chordCap[member] = .2f * 2 * ringDistance * sin(PI / ring.Count);
Settle(member);
}
// A member's mass is final only once it has settled (its own overflowing children fold into it).
if (ringDistance < RocheMargin * Roche(ring.Max(OneBody)))
{
// The ring cannot keep its heaviest member outside its Roche margin inside its node's reach: the node is one body.
node.Empty = false;
node.Mass = 0;
foreach (var child in node.Children.ToList()) Fold(child, node);
return;
}
foreach (var child in node.Children.Where(c => !ring.Contains(c)).ToList()) Settle(child);
}
Settle(root);
}
}
// All orbiting bodies are referred to here as "Planets"
// This goes back to the original etymology for the word planet, which is traveler,
// Because they were objects we observed to move in the sky instead of remaining fixed like the stars
public class GeneratorPlanet
{
public ZoneGenerationSettings Settings;
public float Distance;
public float Phase;
public float Mass;
// A belt's half width, written by ZoneGenerator.Place: the belt fills the slot between its siblings' spacing, the parent's
// Roche margin and the parent's window, up to the authored width (ruling belt-fills-its-slot).
public float BeltHalfWidth;
public bool Empty = false;
public bool Belt = false;
// The interval Place gives a child between its previous sibling's spacing and the next sibling's spacing or the window; zero
// on a ring member, which has no slot.
public float SlotInner, SlotOuter;
// A childless light body, not a binary or root rosette member, that could have become a belt (set by GenerateEntities).
public bool BeltCandidate;
// A member of a rosette's ring, which Place puts on one orbit round its bodyless parent.
public bool Ring = false;
public List<GeneratorPlanet> Children = new List<GeneratorPlanet>(); // Planets orbiting this one are referred to as children
public GeneratorPlanet Parent;
public float2 FixedPosition;
// Recursively gather all planets in the hierarchy
public IEnumerable<GeneratorPlanet> AllPlanets()
{
return new[]{this}.Concat(Children.SelectMany(c=>c.AllPlanets()));
}
// Create children that fill a single orbit, equally spaced; Place sets the orbit's radius
// https://en.wikipedia.org/wiki/Klemperer_rosette
public void ExpandRosette(ref Random random, int vertices)
{
// Rosette children replace the parent, parent orbital node is left empty
Empty = true;
// Masses in a rosette alternate, so every sequential pair has the same shared mass
var sharedMass = Mass / vertices * 2;
// Proportion of the masses of sequential pairs is random, but only for even vertex counts
var proportion = vertices % 2 == 0 ? random.NextFloat(.5f,.95f) : .5f;
for (int i = 0; i < vertices; i++)
{
Children.Add(new GeneratorPlanet
{
Settings = Settings,
Parent = this,
Mass = sharedMass * (i % 2 == 0 ? proportion : 1 - proportion), // Masses alternate
Phase = (float) i / vertices,
Ring = true
});
}
}
// Create children that mimic the distribution of planetary masses in the solar system, inside-out in list order;
// Place gives them their distances
public void ExpandSolar(ref Random random, int count, float massMulMin, float massMulMax, float jupiterJump, float massFraction)
{
if (count == 0)
return;
var masses = new float[count];
// Accumulate total mass
// Initialize first mass, actual number doesn't matter since total mass will be divided proportionally
float massTotal = masses[0] = 1;
// Masses multiply from one planet to the next
// Mass typically increases exponentially as you go further out
// jupiterJump is an additional mass multiplier applied after half of the planets
for (var i = 1; i < count; i++)
massTotal += masses[i] = masses[i - 1] * random.NextFloat(massMulMin, massMulMax) * (count/2==i ? jupiterJump : 1);
// Add some randomness to child masses
for (var i = 0; i < masses.Length; i++)
masses[i] *= random.NextFloat(.1f, 1f);
// Normalize the masses of the children
for (var i = 0; i < count; i++)
masses[i] = masses[i] / massTotal * Mass * massFraction;
for (var i = 0; i < count; i++)
{
// Only instantiate children above the mass floor
if (masses[i] > Settings.MassFloor)
Children.Add(new GeneratorPlanet
{
Settings = Settings,
Parent = this,
Mass = masses[i],
Phase = random.NextFloat()
});
}
}
}