From 3cf1a2698caaef12ce95e27e66633491802346df Mon Sep 17 00:00:00 2001
From: StannisMod
Date: Mon, 10 Aug 2026 20:46:24 +0300
Subject: [PATCH 01/42] refactor: name the astronomical constants by meaning,
not by value
- three unrelated quantities shared the literal 100 in one file
- distance scale, atmosphere scale and star-temperature scale now distinct
- year, lunar month, ticks-per-day and the star-radius scale named
- default rotational period named separately from the platform tick rate
- constants are int with explicit casts so each site keeps its own arithmetic
- pins the frame by value first; no computed number changes
---
.../dimension/DimensionManager.java | 3 +-
.../dimension/DimensionProperties.java | 10 ++-
.../mixin/MixinWorldServer.java | 3 +-
.../util/AstronomicalBodyHelper.java | 65 +++++++++++++----
.../world/weather/ARDimensionWorldInfo.java | 2 +-
.../test/unit/AstronomicalBodyHelperTest.java | 71 +++++++++++++++++++
6 files changed, 138 insertions(+), 16 deletions(-)
diff --git a/src/main/java/zmaster587/advancedRocketry/dimension/DimensionManager.java b/src/main/java/zmaster587/advancedRocketry/dimension/DimensionManager.java
index c0bf30aab..1890f7c68 100644
--- a/src/main/java/zmaster587/advancedRocketry/dimension/DimensionManager.java
+++ b/src/main/java/zmaster587/advancedRocketry/dimension/DimensionManager.java
@@ -337,7 +337,8 @@ public DimensionProperties generateRandom(int starId, String name, int baseAtmos
properties.ringColor[2] = properties.skyColor[2];
}
- properties.rotationalPeriod = (int) (Math.pow((1 / properties.gravitationalMultiplier), 3) * 24000);
+ properties.rotationalPeriod = (int) (Math.pow((1 / properties.gravitationalMultiplier), 3)
+ * DimensionProperties.DEFAULT_ROTATIONAL_PERIOD);
properties.addBiomes(properties.getViableBiomes(true));
properties.initDefaultAttributes();
diff --git a/src/main/java/zmaster587/advancedRocketry/dimension/DimensionProperties.java b/src/main/java/zmaster587/advancedRocketry/dimension/DimensionProperties.java
index 77cfee5b7..32403b53f 100644
--- a/src/main/java/zmaster587/advancedRocketry/dimension/DimensionProperties.java
+++ b/src/main/java/zmaster587/advancedRocketry/dimension/DimensionProperties.java
@@ -71,6 +71,14 @@ public class DimensionProperties implements Cloneable, IDimensionProperties {
public static final int MIN_DISTANCE = 1;
public static final int MAX_GRAVITY = 400;
public static final int MIN_GRAVITY = 0;
+ /**
+ * A planet's rotational period when nothing else determines it: the default day length, and the
+ * scale the gravity-derived period is expressed in. Numerically equal to
+ * {@link zmaster587.advancedRocketry.util.AstronomicalBodyHelper#TICKS_PER_DAY} but a DIFFERENT
+ * quantity — that one is the platform's tick rate, this one is a per-planet property that most
+ * planets do not keep. Do not collapse them.
+ */
+ public static final int DEFAULT_ROTATIONAL_PERIOD = 24000;
public static final int WEATHER_START_LENGTH = 168000;
public static final int WEATHER_PROLONGATION_LENGTH = 12000;
@@ -449,7 +457,7 @@ public void resetProperties() {
sunriseSunsetColors = new float[]{.7f, .2f, .2f, 1};
ringColor = new float[]{.4f, .4f, .7f};
gravitationalMultiplier = 1;
- rotationalPeriod = 24000;
+ rotationalPeriod = DEFAULT_ROTATIONAL_PERIOD;
orbitalDist = 100;
originalAtmosphereDensity = atmosphereDensity = 100;
childPlanets = new HashSet<>();
diff --git a/src/main/java/zmaster587/advancedRocketry/mixin/MixinWorldServer.java b/src/main/java/zmaster587/advancedRocketry/mixin/MixinWorldServer.java
index e5d844620..57689ed78 100644
--- a/src/main/java/zmaster587/advancedRocketry/mixin/MixinWorldServer.java
+++ b/src/main/java/zmaster587/advancedRocketry/mixin/MixinWorldServer.java
@@ -69,7 +69,8 @@ public abstract class MixinWorldServer {
*/
private void ar$tellSleepersWhenDawnIs(WorldServer self) {
int rotationalPeriod = self.provider instanceof IPlanetaryProvider
- ? ((IPlanetaryProvider) self.provider).getRotationalPeriod(null) : 24000;
+ ? ((IPlanetaryProvider) self.provider).getRotationalPeriod(null)
+ : zmaster587.advancedRocketry.dimension.DimensionProperties.DEFAULT_ROTATIONAL_PERIOD;
long ticksToDawn = ARDimensionWorldInfo.computeSleepWakeTime(self.getWorldTime(), rotationalPeriod)
- self.getWorldTime();
// Real minutes, rounded up and never zero: "in 0 minutes" reads as a bug, and the player
diff --git a/src/main/java/zmaster587/advancedRocketry/util/AstronomicalBodyHelper.java b/src/main/java/zmaster587/advancedRocketry/util/AstronomicalBodyHelper.java
index 3a8e81f44..a572af5a6 100644
--- a/src/main/java/zmaster587/advancedRocketry/util/AstronomicalBodyHelper.java
+++ b/src/main/java/zmaster587/advancedRocketry/util/AstronomicalBodyHelper.java
@@ -4,6 +4,43 @@
import zmaster587.advancedRocketry.api.dimension.solar.StellarBody;
public class AstronomicalBodyHelper {
+
+ // ─── The reference frame ───────────────────────────────────────────────────
+ // Named per MEANING, not per value. Three of these are 100 and they are NOT the same quantity:
+ // a distance scale, an atmosphere scale and a star-temperature scale all shared the literal in
+ // this one file, which made a search-and-replace on "100" a silent way to corrupt the
+ // temperature formula. Never collapse them because the numbers happen to match.
+ //
+ // They are ints so that every use site states the arithmetic it wants: the original mixed 100f
+ // and 100d for the SAME scale, and float-vs-double division is not always the same number once
+ // narrowed. The casts below are deliberate and preserve each site's original type exactly.
+
+ /** Distance units in one astronomical unit — the scale the whole system is written in. */
+ public static final int DISTANCE_UNITS_PER_AU = 100;
+ /** Atmosphere-density units in one Earth atmosphere. NOT the distance scale. */
+ public static final int ATM_PRESSURE_UNITS_PER_ATMOSPHERE = 100;
+ /** Star-temperature units in one Sol. NOT the distance scale either. */
+ public static final int TEMPERATURE_UNITS_PER_SOL = 100;
+ /** Kelvin per unit of {@link StellarBody#getTemperature()}. */
+ public static final int KELVIN_PER_STAR_TEMPERATURE_UNIT = 58;
+ /** Solar radii in one astronomical unit — carries a star's size into the distance frame. */
+ public static final int SOLAR_RADII_PER_AU = 215;
+
+ // ─── The calendar ──────────────────────────────────────────────────────────
+ // Inherited from upstream: "One MC Year is 48 MC days (16 IRL Hours), one month is 8 MC Days".
+ // The two are leading coefficients of the same power law at two reference distances — one for
+ // planets around a star, one for moons around a planet. Their ratio (six months to a year) is a
+ // FICTION CHOICE, not a derivation, which is why the month is written independently rather than
+ // as a fraction of the year: changing one does NOT change the other. If that relation is ever
+ // meant to hold, encode it deliberately and record the decision here.
+
+ /** Days in one year: the orbital period one AU from a size-1 star. */
+ public static final int DAYS_PER_YEAR = 48;
+ /** Days in one lunar month: a moon's period at the reference distance from a mass-1 parent. */
+ public static final int DAYS_PER_LUNAR_MONTH = 8;
+ /** Ticks in one day — the platform's rate, NOT a planet's rotational period (that is per-dim). */
+ public static final int TICKS_PER_DAY = 24000;
+
/**
* Returns the size multiplier for a body at the input distance, relative to either 1AU or the moon's orbital distance, depending on parent body
*
@@ -12,7 +49,7 @@ public class AstronomicalBodyHelper {
*/
public static float getBodySizeMultiplier(float orbitalDistance) {
//Returns size multiplier relative to Earth standard (1AU = 100 Distance)
- return 100f / orbitalDistance;
+ return (float) DISTANCE_UNITS_PER_AU / orbitalDistance;
}
/**
@@ -24,7 +61,8 @@ public static float getBodySizeMultiplier(float orbitalDistance) {
*/
public static double getOrbitalPeriod(int orbitalDistance, float solarSize) {
//One MC Year is 48 MC days (16 IRL Hours), one month is 8 MC Days
- return 48d * Math.pow(Math.pow((orbitalDistance / (100d * solarSize)), 3), 0.5d);
+ return DAYS_PER_YEAR
+ * Math.pow(Math.pow((orbitalDistance / ((double) DISTANCE_UNITS_PER_AU * solarSize)), 3), 0.5d);
}
/**
@@ -37,7 +75,8 @@ public static double getOrbitalPeriod(int orbitalDistance, float solarSize) {
public static double getMoonOrbitalPeriod(float orbitalDistance, float planetaryMass) {
//One (lunar) MC month is 8 MC days, so the moon orbits in 8
//The same a the function for planets, but since gravity is directly correlated with mass uses the gravity of the plant for mass
- return 8d * Math.pow(Math.pow((orbitalDistance / 100d), 3) / planetaryMass, 0.5d);
+ return DAYS_PER_LUNAR_MONTH
+ * Math.pow(Math.pow((orbitalDistance / (double) DISTANCE_UNITS_PER_AU), 3) / planetaryMass, 0.5d);
}
/**
@@ -60,7 +99,7 @@ public static double getOrbitalTheta(int orbitalDistance, float solarSize) {
* @return the angle around the star in RADIANS
*/
public static double getOrbitalThetaAt(int orbitalDistance, float solarSize, long worldTick) {
- double periodTicks = 24000d * getOrbitalPeriod(orbitalDistance, solarSize);
+ double periodTicks = (double) TICKS_PER_DAY * getOrbitalPeriod(orbitalDistance, solarSize);
if (!(periodTicks > 0d) || Double.isInfinite(periodTicks)) {
// A degenerate orbit (zero distance, or a star with no size recorded) does not move.
// Answering 0 keeps it addressable instead of handing every caller a NaN coordinate.
@@ -90,7 +129,8 @@ public static double getMoonOrbitalTheta(int orbitalDistance, float parentGravit
public static double getMoonOrbitalThetaAt(int orbitalDistance, float parentGravitationalMultiplier,
long worldTick) {
//Because the function is still in AU and solar mass, some correctional factors to convert to those units
- double periodTicks = 24000d * getMoonOrbitalPeriod(orbitalDistance, parentGravitationalMultiplier);
+ double periodTicks = (double) TICKS_PER_DAY
+ * getMoonOrbitalPeriod(orbitalDistance, parentGravitationalMultiplier);
if (!(periodTicks > 0d) || Double.isInfinite(periodTicks)) {
return 0d;
}
@@ -112,7 +152,7 @@ public static float getParentPlanetThetaFromMoon(int rotationalPeriod, int orbit
float degreeOrbitalTheta = (float) (currentOrbitalTheta * 180 / Math.PI);
//Computer the number of rotations per revolution and use that for how fast the planet would seem to orbit from the moon
//Planet will not move at all if it is tidally locked
- float planetPositionTheta = (((float) (AstronomicalBodyHelper.getMoonOrbitalPeriod(orbitalDistance, parentGravitationalMultiplier) * 24000) / rotationalPeriod) - 1) * degreeOrbitalTheta;
+ float planetPositionTheta = (((float) (AstronomicalBodyHelper.getMoonOrbitalPeriod(orbitalDistance, parentGravitationalMultiplier) * TICKS_PER_DAY) / rotationalPeriod) - 1) * degreeOrbitalTheta;
//Add the base orbital theta so the planet is in the correct place
return (planetPositionTheta + (float) (baseOrbitalTheta * 180 / Math.PI)) % 360;
}
@@ -126,15 +166,16 @@ public static float getParentPlanetThetaFromMoon(int rotationalPeriod, int orbit
* @return the temperature of the planet in Kelvin
*/
public static int getAverageTemperature(StellarBody star, int orbitalDistance, int atmPressure) {
- int starSurfaceTemperature = 58 * star.getTemperature();
- float starRadius = star.getSize() / 215f;
+ int starSurfaceTemperature = KELVIN_PER_STAR_TEMPERATURE_UNIT * star.getTemperature();
+ float starRadius = star.getSize() / (float) SOLAR_RADII_PER_AU;
//Gives output in AU
- float planetaryOrbitalRadius = orbitalDistance / 100f;
+ float planetaryOrbitalRadius = orbitalDistance / (float) DISTANCE_UNITS_PER_AU;
//Albedo is 0.3f hardcoded because of inability to easily calculate
double averageWithoutAtmosphere = starSurfaceTemperature * Math.pow(starRadius / (2 * planetaryOrbitalRadius), 0.5) * Math.pow((1f - 0.3f), 0.25);
//Slightly kludgey solution that works out mostly for Venus and well for Earth, without being overly complex
//Output is in Kelvin
- return (int) (averageWithoutAtmosphere * Math.max(1, (1.125d * Math.pow((atmPressure / 100d), 0.25))));
+ return (int) (averageWithoutAtmosphere
+ * Math.max(1, (1.125d * Math.pow((atmPressure / (double) ATM_PRESSURE_UNITS_PER_ATMOSPHERE), 0.25))));
}
/**
@@ -153,8 +194,8 @@ public static double getStellarBrightness(StellarBody star, int orbitalDistance)
//Normal stars are 1.0 times this value, black holes with accretion discs emit less and so modify it
float lightMultiplier = 1.0f;
//Make all values ratios of Earth normal to get ratio compared to Earth
- float normalizedStarTemperature = star.getTemperature() / 100f;
- float planetaryOrbitalRadius = orbitalDistance / 100f;
+ float normalizedStarTemperature = star.getTemperature() / (float) TEMPERATURE_UNITS_PER_SOL;
+ float planetaryOrbitalRadius = orbitalDistance / (float) DISTANCE_UNITS_PER_AU;
//Check to see if the star is a black hole
boolean blackHole = star.isBlackHole();
Iterable subs = star.getSubStars();
diff --git a/src/main/java/zmaster587/advancedRocketry/world/weather/ARDimensionWorldInfo.java b/src/main/java/zmaster587/advancedRocketry/world/weather/ARDimensionWorldInfo.java
index 7b0e07aa2..06f3350f9 100644
--- a/src/main/java/zmaster587/advancedRocketry/world/weather/ARDimensionWorldInfo.java
+++ b/src/main/java/zmaster587/advancedRocketry/world/weather/ARDimensionWorldInfo.java
@@ -78,7 +78,7 @@ public ARDimensionWorldInfo(WorldInfo delegate, PlanetWeatherState weatherState,
*/
public static long computeSleepWakeTime(long current, int rotationalPeriod) {
if (rotationalPeriod <= 0) {
- rotationalPeriod = 24000;
+ rotationalPeriod = zmaster587.advancedRocketry.dimension.DimensionProperties.DEFAULT_ROTATIONAL_PERIOD;
}
long next = current + rotationalPeriod;
return next - Math.floorMod(next, (long) rotationalPeriod);
diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/AstronomicalBodyHelperTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/AstronomicalBodyHelperTest.java
index 86f6c9c27..f0207781e 100644
--- a/src/test/java/zmaster587/advancedRocketry/test/unit/AstronomicalBodyHelperTest.java
+++ b/src/test/java/zmaster587/advancedRocketry/test/unit/AstronomicalBodyHelperTest.java
@@ -147,4 +147,75 @@ public void planetaryLightMultiplierWithinExpectedBounds() {
}
}
+ // ─────────────────────────────────────────────────────────────────────────────
+ // The reference frame, pinned by VALUE.
+ //
+ // The assertions above are mostly relative — thicker is warmer, farther is cooler — and a
+ // relative assertion cannot notice that a scale constant moved: rescale the atmosphere axis and
+ // "thicker is warmer" still holds while every temperature is wrong. These pin the absolute
+ // numbers instead, each derived from the frame's own definitions (100 distance units = 1 AU,
+ // 48 days = a year, 8 = a lunar month) rather than recorded from a run.
+ //
+ // They exist so that naming the scale constants can be shown to change nothing — and they stay
+ // afterwards as the guard for the next edit. The temperature ones matter most: the distance
+ // scale and the atmosphere scale are both 100 and live four lines apart, so a well-meant
+ // search-and-replace can silently corrupt one of them.
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ @Test
+ public void orbitalPeriodFollowsTheThreeHalvesPowerLawExactly() {
+ // Four times the distance is eight times the period.
+ assertEquals(384.0, AstronomicalBodyHelper.getOrbitalPeriod(400, 1.0f), 1e-9);
+ // A bigger star pulls the same distance into a shorter year.
+ assertEquals(31.176914536239792, AstronomicalBodyHelper.getOrbitalPeriod(150, 2.0f), 1e-9);
+ }
+
+ @Test
+ public void moonPeriodScalesWithParentMassAndDistanceExactly() {
+ // Four times the parent mass halves the period.
+ assertEquals(4.0, AstronomicalBodyHelper.getMoonOrbitalPeriod(100f, 4.0f), 1e-9);
+ assertEquals(22.627416997969522, AstronomicalBodyHelper.getMoonOrbitalPeriod(200f, 1.0f), 1e-9);
+ }
+
+ @Test
+ public void temperatureAtOneAuUnderOneAtmosphereIsPinned() {
+ // 1 AU, one atmosphere: the radiative balance times the greenhouse term.
+ assertEquals(287, AstronomicalBodyHelper.getAverageTemperature(sunLikeStar(), 100, 100));
+ }
+
+ @Test
+ public void aVacuumWorldGetsTheBareRadiativeBalance() {
+ // atmPressure 0 falls to the max(1, ...) floor — no greenhouse lift at all.
+ assertEquals(255, AstronomicalBodyHelper.getAverageTemperature(sunLikeStar(), 100, 0));
+ }
+
+ @Test
+ public void temperatureAtFourAuIsPinned() {
+ assertEquals(143, AstronomicalBodyHelper.getAverageTemperature(sunLikeStar(), 400, 100));
+ }
+
+ @Test
+ public void brightnessFallsWithTheSquareOfDistanceExactly() {
+ assertEquals(0.25, AstronomicalBodyHelper.getStellarBrightness(sunLikeStar(), 200), 1e-9);
+ }
+
+ // The tick-taking overloads of the theta helpers do NOT touch the mod proxy — only the no-arg
+ // forms do, which is what the class note above excludes. They carry the same law, so the wrap
+ // is checkable here as well as in the integration test.
+
+ @Test
+ public void orbitalThetaWrapsOncePerPeriod() {
+ long periodTicks = (long) (48.0 * 24000.0);
+ assertEquals(0.0, AstronomicalBodyHelper.getOrbitalThetaAt(100, 1.0f, 0L), 1e-9);
+ assertEquals(Math.PI / 2.0,
+ AstronomicalBodyHelper.getOrbitalThetaAt(100, 1.0f, periodTicks / 4L), 1e-9);
+ assertEquals(0.0, AstronomicalBodyHelper.getOrbitalThetaAt(100, 1.0f, periodTicks), 1e-9);
+ }
+
+ @Test
+ public void aDegenerateOrbitStaysAddressableRatherThanNaN() {
+ assertEquals(0.0, AstronomicalBodyHelper.getOrbitalThetaAt(0, 1.0f, 12345L), 1e-9);
+ assertEquals(0.0, AstronomicalBodyHelper.getMoonOrbitalThetaAt(100, 0f, 12345L), 1e-9);
+ }
+
}
From a91ffbc76452a6e482fd4246cafc013568e54568 Mon Sep 17 00:00:00 2001
From: StannisMod
Date: Tue, 11 Aug 2026 09:41:18 +0300
Subject: [PATCH 02/42] fix: a planet publishes its own world type, not the
save's
- install ARPlanetWorldInfo before the provider caches its values
- terrainGeneratorOptions per dimension, in XML and NBT
- one TerrainResolution so provider and WorldInfo cannot disagree
- forward getGeneratorOptions through the weather wrapper
- drop two setTerrainType calls that never did anything
- report the published identity from dim info and report_state
---
.../command/test/TestProbeCommand.java | 17 ++-
.../dimension/DimensionProperties.java | 20 ++++
.../dimension/TerrainResolution.java | 69 ++++++++++++
.../mixin/MixinWorldProvider.java | 34 ++++++
.../util/XMLPlanetLoader.java | 6 ++
.../world/ARPlanetWorldInfo.java | 102 ++++++++++++++++++
.../world/provider/WorldProviderAsteroid.java | 2 -
.../world/provider/WorldProviderPlanet.java | 47 +++-----
.../world/weather/ARDimensionWorldInfo.java | 11 ++
.../resources/mixins.advancedrocketry.json | 1 +
.../client/AbstractSharedClientE2ETest.java | 8 ++
.../WorldCommandClientGroupE2ETest.java | 53 +++++++++
.../server/PlanetTerrainSourceE2ETest.java | 94 ++++++++++++++++
.../forge/testing/client/ClientBot.java | 5 +
.../bridge/ForgeTestClientBootstrap.java | 10 ++
15 files changed, 444 insertions(+), 35 deletions(-)
create mode 100644 src/main/java/zmaster587/advancedRocketry/dimension/TerrainResolution.java
create mode 100644 src/main/java/zmaster587/advancedRocketry/mixin/MixinWorldProvider.java
create mode 100644 src/main/java/zmaster587/advancedRocketry/world/ARPlanetWorldInfo.java
diff --git a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java
index 85942bfc6..04517d03e 100644
--- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java
+++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java
@@ -4288,6 +4288,19 @@ private void handleDim(ICommandSender sender, String[] args) {
info.put("chunkGeneratorClass", chunkGeneratorClassOf(world));
info.put("saveDir", (world != null && world.provider.getSaveFolder() != null)
? world.provider.getSaveFolder() : "null");
+ // The world-generation identity this dimension PUBLISHES through the vanilla WorldInfo
+ // API — the channel a foreign WorldType reads when it configures itself. It is reported
+ // next to the overworld's own value because the failure mode is not "wrong name" but
+ // "somebody else's name": a secondary world's WorldInfo delegates both of these, so a
+ // planet can silently answer with the save's world type and an empty options string.
+ info.put("worldType", (world != null && world.getWorldInfo().getTerrainType() != null)
+ ? world.getWorldInfo().getTerrainType().getName() : "null");
+ info.put("generatorOptions", world != null ? world.getWorldInfo().getGeneratorOptions() : "null");
+ net.minecraft.world.WorldServer overworld = net.minecraftforge.common.DimensionManager.getWorld(0);
+ info.put("overworldWorldType", (overworld != null && overworld.getWorldInfo().getTerrainType() != null)
+ ? overworld.getWorldInfo().getTerrainType().getName() : "null");
+ info.put("overworldGeneratorOptions",
+ overworld != null ? overworld.getWorldInfo().getGeneratorOptions() : "null");
info.put("isARPlanet", DimensionManager.getInstance().isDimensionCreated(dim));
if (props != null) {
info.put("name", props.getName());
@@ -9239,7 +9252,7 @@ private void handleWorldgen(MinecraftServer server, ICommandSender sender, Strin
return;
}
if (args.length >= 4 && "create-terrain-dim".equalsIgnoreCase(args[0])) {
- // worldgen create-terrain-dim [param]
+ // worldgen create-terrain-dim [param] [generatorOptions]
// Register a new PLANET dimension by cloning an existing AR planet's
// DimensionProperties (inheriting star / atmosphere / gravity linkage so
// headless worldprovider-init doesn't NPE), re-id'ing it, and setting a
@@ -9251,6 +9264,7 @@ private void handleWorldgen(MinecraftServer server, ICommandSender sender, Strin
zmaster587.advancedRocketry.dimension.TerrainSource terrain =
zmaster587.advancedRocketry.dimension.TerrainSource.byName(args[3]);
String param = args.length >= 5 ? args[4] : "";
+ String generatorOptions = args.length >= 6 ? args[5] : "";
zmaster587.advancedRocketry.dimension.DimensionManager dm =
zmaster587.advancedRocketry.dimension.DimensionManager.getInstance();
if (dm.isDimensionCreated(newId)) {
@@ -9275,6 +9289,7 @@ private void handleWorldgen(MinecraftServer server, ICommandSender sender, Strin
props.setTerrainWorldType(param);
else if (terrain == zmaster587.advancedRocketry.dimension.TerrainSource.TEMPLATE)
props.setTerrainTemplate(param);
+ props.setTerrainGeneratorOptions(generatorOptions);
boolean registered = dm.registerDim(props, true);
// Belt-and-braces: ensure Forge knows the dim under the planet provider
// even if registerDim's internal guard skipped it.
diff --git a/src/main/java/zmaster587/advancedRocketry/dimension/DimensionProperties.java b/src/main/java/zmaster587/advancedRocketry/dimension/DimensionProperties.java
index 32403b53f..4e73ab4d6 100644
--- a/src/main/java/zmaster587/advancedRocketry/dimension/DimensionProperties.java
+++ b/src/main/java/zmaster587/advancedRocketry/dimension/DimensionProperties.java
@@ -189,6 +189,13 @@ private static float clampFeatureFrequencyMultiplier(float multiplier) {
private TerrainSource terrainSource = TerrainSource.NATIVE;
private String terrainWorldType = ""; // foreign WorldType name for MOD_WORLDTYPE
private String terrainTemplate = ""; // template folder name for TEMPLATE
+ /**
+ * The settings string handed to this dimension's chunk generator — vanilla's "generator options",
+ * per dimension instead of per save. A foreign {@link net.minecraft.world.WorldType} receives it
+ * as the second argument of {@code getChunkGenerator}, and reads it back off this world's
+ * {@code WorldInfo} when it identifies itself; an empty string means "your defaults".
+ */
+ private String terrainGeneratorOptions = "";
//public int target_sea_level;
// modId must be declared explicitly: this @SidedProxy lives outside the @Mod class, and the jar
@@ -255,6 +262,7 @@ public DimensionProperties(int id) {
terrainSource = TerrainSource.NATIVE;
terrainWorldType = "";
terrainTemplate = "";
+ terrainGeneratorOptions = "";
//target_sea_level = seaLevel;
//water_can_exist = true;
@@ -477,6 +485,7 @@ public void resetProperties() {
terrainSource = TerrainSource.NATIVE;
terrainWorldType = "";
terrainTemplate = "";
+ terrainGeneratorOptions = "";
laserDrillOres = new ArrayList<>();
}
@@ -1713,6 +1722,7 @@ else if (nbt.hasKey("biomes", NBT.TAG_INT_ARRAY)) {
terrainSource = nbt.hasKey("terrainSource") ? TerrainSource.byName(nbt.getString("terrainSource")) : TerrainSource.NATIVE;
terrainWorldType = nbt.getString("terrainWorldType");
terrainTemplate = nbt.getString("terrainTemplate");
+ terrainGeneratorOptions = nbt.getString("terrainGeneratorOptions");
canGenerateCraters = nbt.getBoolean("canGenerateCraters");
canGenerateGeodes = nbt.getBoolean("canGenerateGeodes");
canGenerateStructures = nbt.getBoolean("canGenerateStructures");
@@ -2088,6 +2098,8 @@ public void writeToNBT(NBTTagCompound nbt) {
nbt.setString("terrainWorldType", terrainWorldType);
if (!terrainTemplate.isEmpty())
nbt.setString("terrainTemplate", terrainTemplate);
+ if (!terrainGeneratorOptions.isEmpty())
+ nbt.setString("terrainGeneratorOptions", terrainGeneratorOptions);
nbt.setBoolean("canGenerateCraters", canGenerateCraters);
nbt.setBoolean("canGenerateGeodes", canGenerateGeodes);
nbt.setBoolean("canGenerateStructures", canGenerateStructures);
@@ -2369,6 +2381,14 @@ public void setTerrainTemplate(String terrainTemplate) {
this.terrainTemplate = terrainTemplate == null ? "" : terrainTemplate;
}
+ public String getTerrainGeneratorOptions() {
+ return terrainGeneratorOptions;
+ }
+
+ public void setTerrainGeneratorOptions(String terrainGeneratorOptions) {
+ this.terrainGeneratorOptions = terrainGeneratorOptions == null ? "" : terrainGeneratorOptions;
+ }
+
public void setGenerateCraters(boolean canGenerateCraters) {
this.canGenerateCraters = canGenerateCraters;
}
diff --git a/src/main/java/zmaster587/advancedRocketry/dimension/TerrainResolution.java b/src/main/java/zmaster587/advancedRocketry/dimension/TerrainResolution.java
new file mode 100644
index 000000000..9a01a68ce
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/dimension/TerrainResolution.java
@@ -0,0 +1,69 @@
+package zmaster587.advancedRocketry.dimension;
+
+import net.minecraft.world.WorldType;
+import zmaster587.advancedRocketry.AdvancedRocketry;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Resolves what a planet dimension's terrain is ACTUALLY produced by, after the fallbacks: an
+ * authored {@link TerrainSource} plus, for {@link TerrainSource#MOD_WORLDTYPE}, the foreign
+ * {@link WorldType} it names.
+ *
+ * This exists as one shared answer rather than one per caller because two very different places
+ * need it and must not be able to disagree: the {@code WorldProviderPlanet} that picks the chunk
+ * generator, and the per-dimension {@code WorldInfo} that publishes this world's generation identity
+ * to third-party code. A planet that generates with a foreign world type while telling everyone it
+ * is something else is the defect this class prevents from being re-introduced.
+ *
+ * Fallbacks are deliberate and quiet-ish: a MOD_WORLDTYPE naming a world type no installed mod
+ * registered, or a TEMPLATE with no template path, degrades to {@link TerrainSource#NATIVE} with one
+ * warning per dimension, so a mis-authored planet still generates instead of failing to load.
+ */
+public final class TerrainResolution {
+
+ /** Dimensions already warned about, so a per-chunk or per-lookup resolve cannot spam the log. */
+ private static final Set warnedDims = Collections.synchronizedSet(new HashSet());
+
+ /** The terrain source actually in force — never the authored value if that value was unusable. */
+ public final TerrainSource source;
+ /**
+ * The world type this dimension actually generates with: the foreign one when {@link #source} is
+ * {@link TerrainSource#MOD_WORLDTYPE}, otherwise Advanced Rocketry's own planet world type.
+ * Null only if AR's world type has not been registered yet (before {@code FMLInitializationEvent}).
+ */
+ public final WorldType worldType;
+
+ private TerrainResolution(TerrainSource source, WorldType worldType) {
+ this.source = source;
+ this.worldType = worldType;
+ }
+
+ /** @param props this dimension's properties; must not be null (a non-AR dimension has no resolution). */
+ public static TerrainResolution of(int dim, DimensionProperties props) {
+ TerrainSource requested = props.getTerrainSource();
+
+ if (requested == TerrainSource.MOD_WORLDTYPE) {
+ String name = props.getTerrainWorldType();
+ WorldType foreign = (name == null || name.isEmpty()) ? null : WorldType.parseWorldType(name);
+ if (foreign != null)
+ return new TerrainResolution(TerrainSource.MOD_WORLDTYPE, foreign);
+ warnOnce(dim, "requests MOD_WORLDTYPE '" + name
+ + "' which is not registered; falling back to NATIVE terrain");
+ } else if (requested == TerrainSource.TEMPLATE) {
+ String template = props.getTerrainTemplate();
+ if (template != null && !template.isEmpty())
+ return new TerrainResolution(TerrainSource.TEMPLATE, AdvancedRocketry.planetWorldType);
+ warnOnce(dim, "requests TEMPLATE terrain with no template path; falling back to NATIVE");
+ }
+
+ return new TerrainResolution(TerrainSource.NATIVE, AdvancedRocketry.planetWorldType);
+ }
+
+ private static void warnOnce(int dim, String message) {
+ if (warnedDims.add(dim))
+ AdvancedRocketry.logger.warn("Planet dimension " + dim + " " + message);
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/mixin/MixinWorldProvider.java b/src/main/java/zmaster587/advancedRocketry/mixin/MixinWorldProvider.java
new file mode 100644
index 000000000..58d6a683f
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/mixin/MixinWorldProvider.java
@@ -0,0 +1,34 @@
+package zmaster587.advancedRocketry.mixin;
+
+import net.minecraft.world.World;
+import net.minecraft.world.WorldProvider;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+import zmaster587.advancedRocketry.world.ARPlanetWorldInfo;
+
+/**
+ * Installs Advanced Rocketry's per-dimension {@link net.minecraft.world.storage.WorldInfo} on an AR
+ * dimension, at the only moment early enough to matter.
+ *
+ * {@code WorldProvider.setWorld} is where vanilla caches this world's terrain type and generator
+ * options into private fields ({@code WorldProvider:52-53}) and then calls {@code init()}; the
+ * enclosing {@code WorldServer} constructor builds the chunk provider on the very next line. Anything
+ * that swaps the {@code WorldInfo} later — a {@code WorldEvent.Load} handler, say — arrives after the
+ * biome provider and the chunk generator have already been built from the OVERWORLD's values.
+ * Injecting at HEAD puts the right info in place before any of that reads it.
+ *
+ * Deliberately NOT gated by the {@code perDimWorldInfo} config flag: that flag governs per-planet
+ * weather and time, and which terrain a planet generates is not weather's business. The guard lives
+ * in {@link ARPlanetWorldInfo#installIfNeeded(World)} instead, which touches only server-side AR
+ * dimensions whose info is still vanilla's shared-overworld one.
+ */
+@Mixin(WorldProvider.class)
+public abstract class MixinWorldProvider {
+
+ @Inject(method = "setWorld", at = @At("HEAD"))
+ private void ar$installPerDimensionWorldInfo(World worldIn, CallbackInfo ci) {
+ ARPlanetWorldInfo.installIfNeeded(worldIn);
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/util/XMLPlanetLoader.java b/src/main/java/zmaster587/advancedRocketry/util/XMLPlanetLoader.java
index c2815343b..588da3818 100644
--- a/src/main/java/zmaster587/advancedRocketry/util/XMLPlanetLoader.java
+++ b/src/main/java/zmaster587/advancedRocketry/util/XMLPlanetLoader.java
@@ -107,6 +107,7 @@ public class XMLPlanetLoader {
private static final String ELEMENT_TERRAIN_SOURCE = "terrainSource";
private static final String ELEMENT_TERRAIN_WORLDTYPE = "terrainWorldType";
private static final String ELEMENT_TERRAIN_TEMPLATE = "terrainTemplate";
+ private static final String ELEMENT_TERRAIN_GENERATOR_OPTIONS = "terrainGeneratorOptions";
private static final String ELEMENT_RIVER_OVERRIDE = "forceRiverGeneration";
private static final String ELEMENT_OREGEN = "oreGen";
private static final String ELEMENT_LASER_DRILL_ORES = "laserDrillOres";
@@ -448,6 +449,8 @@ private static Node writePlanet(Document doc, DimensionProperties properties) {
nodePlanet.appendChild(createTextNode(doc, ELEMENT_TERRAIN_WORLDTYPE, properties.getTerrainWorldType()));
if (!properties.getTerrainTemplate().isEmpty())
nodePlanet.appendChild(createTextNode(doc, ELEMENT_TERRAIN_TEMPLATE, properties.getTerrainTemplate()));
+ if (!properties.getTerrainGeneratorOptions().isEmpty())
+ nodePlanet.appendChild(createTextNode(doc, ELEMENT_TERRAIN_GENERATOR_OPTIONS, properties.getTerrainGeneratorOptions()));
if (properties.oreProperties != null) {
nodePlanet.appendChild(XMLOreLoader.writeOreEntryXML(doc, properties.oreProperties));
@@ -1074,6 +1077,9 @@ else if (planetPropertyNode.getNodeName().equalsIgnoreCase(ELEMENT_BIOMEIDS)) {
properties.setTerrainWorldType(planetPropertyNode.getTextContent().trim());
} else if (planetPropertyNode.getNodeName().equalsIgnoreCase(ELEMENT_TERRAIN_TEMPLATE)) {
properties.setTerrainTemplate(planetPropertyNode.getTextContent().trim());
+ } else if (planetPropertyNode.getNodeName().equalsIgnoreCase(ELEMENT_TERRAIN_GENERATOR_OPTIONS)) {
+ // NOT trimmed: a generator settings string is opaque to us and may be whitespace-significant.
+ properties.setTerrainGeneratorOptions(planetPropertyNode.getTextContent());
} else if (planetPropertyNode.getNodeName().equalsIgnoreCase(ELEMENT_HASRINGS))
properties.hasRings = Boolean.parseBoolean(planetPropertyNode.getTextContent());
else if (planetPropertyNode.getNodeName().equalsIgnoreCase(ELEMENT_CAN_DECORATE))
diff --git a/src/main/java/zmaster587/advancedRocketry/world/ARPlanetWorldInfo.java b/src/main/java/zmaster587/advancedRocketry/world/ARPlanetWorldInfo.java
new file mode 100644
index 000000000..4c47f089d
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/world/ARPlanetWorldInfo.java
@@ -0,0 +1,102 @@
+package zmaster587.advancedRocketry.world;
+
+import net.minecraft.world.WorldType;
+import net.minecraft.world.storage.DerivedWorldInfo;
+import net.minecraft.world.storage.WorldInfo;
+import zmaster587.advancedRocketry.dimension.DimensionManager;
+import zmaster587.advancedRocketry.dimension.DimensionProperties;
+import zmaster587.advancedRocketry.dimension.TerrainResolution;
+
+/**
+ * The {@link WorldInfo} an Advanced Rocketry dimension publishes, replacing the plain
+ * {@link DerivedWorldInfo} vanilla installs on every secondary world.
+ *
+ * It answers exactly two questions per dimension instead of per save — the world type and the
+ * generator options string — and inherits the delegation of everything else, so nothing about the
+ * shared level state changes. Two questions, because those two are what a third-party
+ * {@link WorldType} reads when it identifies and configures itself, and Advanced Rocketry cannot
+ * patch a foreign generator's read sites. Vanilla's {@code DerivedWorldInfo} answers both about the
+ * OVERWORLD: {@code setTerrainType} is an empty method there, so a planet's attempt to stamp its own
+ * is silently dropped, and {@code getGeneratorOptions} is never overridden at all, so the string is
+ * always empty.
+ *
+ * Why it is installed in the constructor and not from a world event: {@code WorldServer}'s
+ * constructor calls {@code provider.setWorld(this)} and then {@code createChunkProvider()} before it
+ * returns, and {@code WorldProvider.setWorld} caches both of these values into private fields. A
+ * {@code WorldInfo} swapped in later — at {@code WorldEvent.Load}, say — is already too late to
+ * reach the biome provider or the chunk generator, which is the whole point of having it.
+ *
+ * Values are read live from {@link DimensionProperties} rather than snapshotted: the properties
+ * are the source of truth, and a copy taken at construction would go stale the moment a dimension's
+ * terrain is re-authored.
+ */
+public class ARPlanetWorldInfo extends DerivedWorldInfo {
+
+ private final int dimension;
+ private final WorldInfo delegate;
+
+ public ARPlanetWorldInfo(WorldInfo delegate, int dimension) {
+ super(delegate);
+ this.delegate = delegate;
+ this.dimension = dimension;
+ }
+
+ /** The dimension this info speaks for. */
+ public int getDimension() {
+ return dimension;
+ }
+
+ /**
+ * Replaces {@code world}'s vanilla {@link DerivedWorldInfo} with a per-dimension one, if this is
+ * an Advanced Rocketry dimension that still carries the shared-overworld info. Idempotent, and a
+ * no-op for every world it is not about: the client, the overworld, and any dimension AR did not
+ * create keep exactly the info they had.
+ *
+ * @return whether an info was installed by this call
+ */
+ public static boolean installIfNeeded(net.minecraft.world.World world) {
+ if (!(world instanceof net.minecraft.world.WorldServer))
+ return false;
+ if (world.provider == null)
+ return false;
+ WorldInfo current = world.getWorldInfo();
+ // Only vanilla's shared-overworld info is replaced. Anything else is either the overworld's
+ // real WorldInfo, our own (already installed), or another mod's — none of them ours to swap.
+ if (!(current instanceof DerivedWorldInfo) || current instanceof ARPlanetWorldInfo)
+ return false;
+ int dim = world.provider.getDimension();
+ if (dim == 0 || !DimensionManager.getInstance().isDimensionCreated(dim))
+ return false;
+ world.worldInfo = new ARPlanetWorldInfo(current, dim);
+ return true;
+ }
+
+ @Override
+ public WorldType getTerrainType() {
+ TerrainResolution resolved = resolve();
+ if (resolved == null || resolved.worldType == null)
+ return delegate.getTerrainType();
+ return resolved.worldType;
+ }
+
+ /**
+ * Kept a no-op like the superclass. The per-dimension world type is derived from
+ * {@link DimensionProperties}, so accepting a write here would create a second source of truth
+ * that only the writer could see — and every existing caller of this setter is passing the value
+ * this class already derives.
+ */
+ @Override
+ public void setTerrainType(WorldType type) {
+ }
+
+ @Override
+ public String getGeneratorOptions() {
+ DimensionProperties props = DimensionManager.getInstance().getDimensionProperties(dimension);
+ return props == null ? delegate.getGeneratorOptions() : props.getTerrainGeneratorOptions();
+ }
+
+ private TerrainResolution resolve() {
+ DimensionProperties props = DimensionManager.getInstance().getDimensionProperties(dimension);
+ return props == null ? null : TerrainResolution.of(dimension, props);
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/world/provider/WorldProviderAsteroid.java b/src/main/java/zmaster587/advancedRocketry/world/provider/WorldProviderAsteroid.java
index f4638dc29..705a016a5 100644
--- a/src/main/java/zmaster587/advancedRocketry/world/provider/WorldProviderAsteroid.java
+++ b/src/main/java/zmaster587/advancedRocketry/world/provider/WorldProviderAsteroid.java
@@ -6,7 +6,6 @@
import net.minecraftforge.client.IRenderHandler;
import net.minecraftforge.fml.relauncher.Side;
import net.minecraftforge.fml.relauncher.SideOnly;
-import zmaster587.advancedRocketry.AdvancedRocketry;
import zmaster587.advancedRocketry.api.ARConfiguration;
import zmaster587.advancedRocketry.api.AdvancedRocketryBiomes;
import zmaster587.advancedRocketry.client.render.planet.RenderAsteroidSky;
@@ -52,7 +51,6 @@ public float calculateCelestialAngle(long worldTime, float p_76563_3_) {
@Override
protected void init() {
this.hasSkyLight = true;
- world.getWorldInfo().setTerrainType(AdvancedRocketry.planetWorldType);
this.biomeProvider = new BiomeProviderSingle(AdvancedRocketryBiomes.spaceBiome);//new ChunkManagerPlanet(worldObj, worldObj.getWorldInfo().getGeneratorOptions(), DimensionManager.getInstance().getDimensionProperties(worldObj.provider.getDimension()).getBiomes());
diff --git a/src/main/java/zmaster587/advancedRocketry/world/provider/WorldProviderPlanet.java b/src/main/java/zmaster587/advancedRocketry/world/provider/WorldProviderPlanet.java
index 40ba0fa86..290af794d 100644
--- a/src/main/java/zmaster587/advancedRocketry/world/provider/WorldProviderPlanet.java
+++ b/src/main/java/zmaster587/advancedRocketry/world/provider/WorldProviderPlanet.java
@@ -18,7 +18,6 @@
import net.minecraftforge.fml.relauncher.Side;
import net.minecraftforge.fml.relauncher.SideOnly;
import org.apache.commons.lang3.ArrayUtils;
-import zmaster587.advancedRocketry.AdvancedRocketry;
import zmaster587.advancedRocketry.api.ARConfiguration;
import zmaster587.advancedRocketry.api.AdvancedRocketryItems;
import zmaster587.advancedRocketry.api.IAtmosphere;
@@ -30,6 +29,7 @@
import zmaster587.advancedRocketry.client.render.planet.RenderPlanetarySky;
import zmaster587.advancedRocketry.dimension.DimensionManager;
import zmaster587.advancedRocketry.dimension.DimensionProperties;
+import zmaster587.advancedRocketry.dimension.TerrainResolution;
import zmaster587.advancedRocketry.dimension.TerrainSource;
import zmaster587.advancedRocketry.util.AstronomicalBodyHelper;
import zmaster587.advancedRocketry.world.ChunkManagerPlanet;
@@ -69,48 +69,32 @@ public IChunkGenerator createChunkGenerator() {
resolveTerrainSource();
if (effectiveTerrainSource == TerrainSource.MOD_WORLDTYPE)
- return foreignWorldType.getChunkGenerator(world, world.getWorldInfo().getGeneratorOptions());
+ return foreignWorldType.getChunkGenerator(world, generatorOptions());
if (effectiveTerrainSource == TerrainSource.TEMPLATE)
return new ChunkProviderTemplate(this.world);
int genType = DimensionManager.getInstance().getDimensionProperties(world.provider.getDimension()).getGenType();
if (genType == 1) {
- return new ChunkProviderCavePlanet(this.world, false, this.world.getSeed(), world.getWorldInfo().getGeneratorOptions());
+ return new ChunkProviderCavePlanet(this.world, false, this.world.getSeed(), generatorOptions());
} else
- return new ChunkProviderPlanet(this.world, this.world.getSeed(), ARConfiguration.getCurrentConfig().generateVanillaStructures, world.getWorldInfo().getGeneratorOptions());
+ return new ChunkProviderPlanet(this.world, this.world.getSeed(), ARConfiguration.getCurrentConfig().generateVanillaStructures, generatorOptions());
}
/**
* Resolves {@link #effectiveTerrainSource} (and {@link #foreignWorldType}) once from this dimension's
- * {@link DimensionProperties}. A MOD_WORLDTYPE whose name is blank or unregistered, or a TEMPLATE with no
- * template path, falls back to NATIVE with a warning so a mis-authored planet still generates.
+ * {@link DimensionProperties}, through the shared {@link TerrainResolution} so that this provider and
+ * the dimension's {@code WorldInfo} cannot answer differently about the same planet.
*/
private void resolveTerrainSource() {
- DimensionProperties props = getDimensionProperties();
- TerrainSource requested = props.getTerrainSource();
- if (requested == TerrainSource.MOD_WORLDTYPE) {
- String name = props.getTerrainWorldType();
- foreignWorldType = (name == null || name.isEmpty()) ? null : WorldType.parseWorldType(name);
- if (foreignWorldType == null) {
- AdvancedRocketry.logger.warn("Planet dimension " + getDimension() + " requests MOD_WORLDTYPE '" + name
- + "' which is not registered; falling back to NATIVE terrain");
- effectiveTerrainSource = TerrainSource.NATIVE;
- } else {
- effectiveTerrainSource = TerrainSource.MOD_WORLDTYPE;
- }
- } else if (requested == TerrainSource.TEMPLATE) {
- String template = props.getTerrainTemplate();
- if (template == null || template.isEmpty()) {
- AdvancedRocketry.logger.warn("Planet dimension " + getDimension()
- + " requests TEMPLATE terrain with no template path; falling back to NATIVE");
- effectiveTerrainSource = TerrainSource.NATIVE;
- } else {
- effectiveTerrainSource = TerrainSource.TEMPLATE;
- }
- } else {
- effectiveTerrainSource = TerrainSource.NATIVE;
- }
+ TerrainResolution resolved = TerrainResolution.of(getDimension(), getDimensionProperties());
+ effectiveTerrainSource = resolved.source;
+ foreignWorldType = resolved.source == TerrainSource.MOD_WORLDTYPE ? resolved.worldType : null;
+ }
+
+ /** The settings string this dimension's chunk generator is configured with. Per dimension, not per save. */
+ private String generatorOptions() {
+ return getDimensionProperties().getTerrainGeneratorOptions();
}
@Override
@@ -144,7 +128,6 @@ public BiomeGenBase getBiomeGenForCoords(int x, int z) {
@Override
protected void init() {
this.hasSkyLight = true;
- world.getWorldInfo().setTerrainType(AdvancedRocketry.planetWorldType);
resolveTerrainSource();
@@ -153,7 +136,7 @@ protected void init() {
if (effectiveTerrainSource == TerrainSource.MOD_WORLDTYPE)
this.biomeProvider = foreignWorldType.getBiomeProvider(world);
else
- this.biomeProvider = new ChunkManagerPlanet(world, world.getWorldInfo().getGeneratorOptions(), DimensionManager.getInstance().getDimensionProperties(world.provider.getDimension()).getBiomes());
+ this.biomeProvider = new ChunkManagerPlanet(world, generatorOptions(), DimensionManager.getInstance().getDimensionProperties(world.provider.getDimension()).getBiomes());
//AdvancedRocketry.planetWorldType.getChunkManager(worldObj);
}
diff --git a/src/main/java/zmaster587/advancedRocketry/world/weather/ARDimensionWorldInfo.java b/src/main/java/zmaster587/advancedRocketry/world/weather/ARDimensionWorldInfo.java
index 06f3350f9..98172a35a 100644
--- a/src/main/java/zmaster587/advancedRocketry/world/weather/ARDimensionWorldInfo.java
+++ b/src/main/java/zmaster587/advancedRocketry/world/weather/ARDimensionWorldInfo.java
@@ -304,6 +304,17 @@ public WorldType getTerrainType() {
public void setTerrainType(WorldType type) {
}
+ /**
+ * Delegated like every other read. This wrapper's own {@code super()} state is inert, so an
+ * un-overridden getter answers from a {@link WorldInfo} that was never populated — here that
+ * would be the empty string, silently replacing whatever the wrapped info publishes and
+ * un-configuring the dimension's chunk generator.
+ */
+ @Override
+ public String getGeneratorOptions() {
+ return delegate.getGeneratorOptions();
+ }
+
@Override
public boolean areCommandsAllowed() {
return delegate.areCommandsAllowed();
diff --git a/src/main/resources/mixins.advancedrocketry.json b/src/main/resources/mixins.advancedrocketry.json
index 6714a5ebe..b71dfb5c1 100644
--- a/src/main/resources/mixins.advancedrocketry.json
+++ b/src/main/resources/mixins.advancedrocketry.json
@@ -16,6 +16,7 @@
"MixinEntityPlayerMPInventoryAccess",
"MixinPlayerList",
"MixinTileAdvancedFlightComputer",
+ "MixinWorldProvider",
"MixinWorldServer",
"MixinWorldServerMulti",
"MixinWorldServerShipManager",
diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/AbstractSharedClientE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/AbstractSharedClientE2ETest.java
index 181301892..40c718354 100644
--- a/src/test/java/zmaster587/advancedRocketry/test/client/AbstractSharedClientE2ETest.java
+++ b/src/test/java/zmaster587/advancedRocketry/test/client/AbstractSharedClientE2ETest.java
@@ -297,6 +297,14 @@ private void resetBetweenScenarios() throws Exception {
String screen = state.has("screen") ? state.get("screen").getAsString() : "";
int overlayTicks = chat.has("overlayTicks") ? chat.get("overlayTicks").getAsInt() : -1;
int chatLines = chat.has("count") ? chat.get("count").getAsInt() : -1;
+ // `report_state` omits the player block entirely when the client has no player — mid
+ // dimension change, or after the connection died. Dereferencing it raises a bare NPE from
+ // this line, which names the RESET as the failure and hides the previous scenario that
+ // actually wedged the client; measured 2026-08-10, that cost an investigation aimed at an
+ // unrelated production change for want of one sentence.
+ assertTrue("the client has no player, so this scenario cannot be arranged at all — the"
+ + " PREVIOUS scenario left the connection or the dimension change unfinished."
+ + " client state=" + state, state.has("playerX") && state.has("playerZ"));
double px = state.get("playerX").getAsDouble();
double pz = state.get("playerZ").getAsDouble();
diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/WorldCommandClientGroupE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/WorldCommandClientGroupE2ETest.java
index 6a62ff299..47e90d30b 100644
--- a/src/test/java/zmaster587/advancedRocketry/test/client/WorldCommandClientGroupE2ETest.java
+++ b/src/test/java/zmaster587/advancedRocketry/test/client/WorldCommandClientGroupE2ETest.java
@@ -65,6 +65,9 @@ public class WorldCommandClientGroupE2ETest extends AbstractSharedClientE2ETest
/** The space dim, where {@code /ar goto station} lands the player. */
private static final int SPACE_DIM = -2;
+ /** The registered name of AR's planet world type (WorldTypePlanetGen). */
+ private static final String AR_PLANET_WORLD_TYPE = "PlanetGen";
+
@Override
protected String subsystem() {
return "world-command";
@@ -262,6 +265,56 @@ public void arGotoTransfersPlayerToTargetDim() throws Exception {
}
}
+ /**
+ * The planet's own world type has to reach the CLIENT, because client-side terrain code
+ * identifies a world by it — and a secondary world's {@code WorldInfo} used to answer with the
+ * SAVE's world type, so every planet a player entered claimed to be the overworld's kind.
+ *
+ * The overworld's value is read first, in this same scenario, and the assertion is that the
+ * value CHANGED on crossing. Asserting the planet's name alone would also pass on a build that
+ * hard-codes one world type everywhere, which is the failure this is about.
+ */
+ @Test
+ public void arGotoMakesTheClientRenderThePlanetsOwnWorldType() throws Exception {
+ scenario().arranging("op the bot and generate a planet to travel to");
+ opTheBot();
+ String home = clientWorldType();
+ scenario().record("homeWorldType", home);
+ scenario().requireArranged("the client must name the world type it starts in, else the"
+ + " comparison below has nothing to change FROM; got '" + home + "'", !home.isEmpty());
+
+ String before = exec("ar planet list");
+ exec("ar planet generate 0 WorldTypeTarget 10 10 10");
+ String after = exec("ar planet list");
+ int targetDim = newDimFromDiff(before, after);
+ scenario().record("targetDim", targetDim);
+ scenario().requireArranged("planet generate must yield a new dim id; before=" + before
+ + " after=" + after, targetDim != -1);
+ try {
+ exec("artest dim load " + targetDim);
+
+ scenario().asserting("the client renders the planet's own world type after arriving");
+ bot().sendChat("/ar goto dimension " + targetDim);
+ waitForClientDim(targetDim);
+
+ String onPlanet = clientWorldType();
+ scenario().record("planetWorldType", onPlanet);
+ assertEquals("the client must learn the planet's own world type on arrival, not the"
+ + " one the save was created with", AR_PLANET_WORLD_TYPE, onPlanet);
+ assertNotEquals("the world type the client renders must differ between the overworld"
+ + " and a planet, or it is not per-dimension at all", home, onPlanet);
+ } finally {
+ exec("artest tp " + plot().dim);
+ exec("ar planet delete " + targetDim);
+ }
+ }
+
+ /** The world type the CLIENT believes it is in, by name. */
+ private String clientWorldType() throws Exception {
+ JsonObject state = bot().reportState();
+ return state != null && state.has("worldType") ? state.get("worldType").getAsString() : "";
+ }
+
// ── /ar goto station ──────────────────────────────────────────────────────
/** From {@code WorldCommandPlayerEquippedE2ETest}: a station's spawn is in the space dim, and
diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/PlanetTerrainSourceE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/PlanetTerrainSourceE2ETest.java
index 0878bd3a9..421adfade 100644
--- a/src/test/java/zmaster587/advancedRocketry/test/server/PlanetTerrainSourceE2ETest.java
+++ b/src/test/java/zmaster587/advancedRocketry/test/server/PlanetTerrainSourceE2ETest.java
@@ -6,6 +6,7 @@
import java.util.regex.Matcher;
import java.util.regex.Pattern;
+import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
@@ -32,6 +33,13 @@ public class PlanetTerrainSourceE2ETest extends AbstractSharedServerTest {
private static final int MOD_WT_DIM = 9990;
private static final int TEMPLATE_DIM = 9991;
private static final int FALLBACK_DIM = 9992;
+ private static final int OPTIONS_DIM = 9993;
+
+ /** The registered name of {@code AdvancedRocketry.planetWorldType} (see {@code WorldTypePlanetGen}). */
+ private static final String AR_PLANET_WORLD_TYPE = "PlanetGen";
+
+ /** A flat preset no default world could produce, so "the options arrived" is visible in blocks. */
+ private static final String FLAT_DIAMOND_PRESET = "3;minecraft:bedrock,3*minecraft:diamond_block;1";
private static final String AR_PLANET_PROVIDER =
"\"providerClass\":\"zmaster587.advancedRocketry.world.provider.WorldProviderPlanet\"";
@@ -108,6 +116,92 @@ public void unregisteredModWorldtypeFallsBackToNativeGenerator() throws Exceptio
info.contains("ChunkGeneratorFlat"));
}
+ /**
+ * A planet publishes ITS OWN world-generation identity through the vanilla {@code WorldInfo}
+ * API, because that is the channel a third-party {@code WorldType} reads when it identifies and
+ * configures itself — Advanced Rocketry cannot patch a foreign generator's read sites.
+ *
+ * Vanilla's secondary-world {@code WorldInfo} answers this about the OVERWORLD: the getter
+ * delegates and the setter is an empty method, so a planet's own stamp used to be dropped in
+ * silence. The overworld's value is reported beside the planet's here to keep the assertion
+ * honest — the two must now DIFFER, which is only meaningful because both name a real type.
+ */
+ @Test
+ public void planetPublishesItsOwnWorldTypeThroughWorldInfo() throws Exception {
+ int planet = firstTemplateArDimOrSkip();
+ exec("artest dim load " + planet);
+ String info = exec("artest dim info " + planet);
+
+ assertTrue("the dim must be loaded, or every field below is about a world that is not there: "
+ + info, info.contains("\"loaded\":true"));
+ assertTrue("this case is about a NATIVE planet: " + info, info.contains("\"terrainSource\":\"NATIVE\""));
+ String published = field(info, "worldType");
+ String overworld = field(info, "overworldWorldType");
+
+ // Both values must name something REAL before they are compared: two absences would read as
+ // agreement, and a comparison of two sources that are equal because neither exists cannot fail.
+ assertNamesAWorldType("worldType", published);
+ assertNamesAWorldType("overworldWorldType", overworld);
+
+ assertEquals("a NATIVE planet generates with AR's own world type and must say so: " + info,
+ AR_PLANET_WORLD_TYPE, published);
+ assertFalse("the planet must no longer be answering with the SAVE's world type: " + info,
+ overworld.equals(published));
+ }
+
+ /**
+ * The generator-options channel, which is what makes third-party terrain more than decorative:
+ * a planet's chunk generator is configured from the planet's own settings string instead of the
+ * empty one a secondary world's {@code WorldInfo} used to hand out.
+ *
+ * Asserted at three depths, because the first two alone would pass on a build where the
+ * string is published but never reaches the generator: the published value, the world type the
+ * dimension runs, and the BLOCKS on the ground. The preset below is deliberately absurd — three
+ * layers of diamond — so the last assertion cannot be satisfied by any default flat world.
+ */
+ @Test
+ public void modWorldtypePlanetConfiguresItsForeignGeneratorFromItsOwnOptions() throws Exception {
+ int template = firstTemplateArDimOrSkip();
+ String create = exec("artest worldgen create-terrain-dim "
+ + OPTIONS_DIM + " " + template + " MOD_WORLDTYPE flat " + FLAT_DIAMOND_PRESET);
+ assertTrue("create-terrain-dim must succeed: " + create, create.contains("\"ok\":true"));
+
+ exec("artest dim load " + OPTIONS_DIM);
+ String info = exec("artest dim info " + OPTIONS_DIM);
+
+ assertTrue("the dim must actually be running the foreign generator, else there is no "
+ + "options channel to measure: " + info, info.contains("ChunkGeneratorFlat"));
+ assertNamesAWorldType("worldType", field(info, "worldType"));
+ assertEquals("a MOD_WORLDTYPE planet must publish the foreign world type it actually runs: " + info,
+ "flat", field(info, "worldType"));
+ assertEquals("the planet must publish its OWN generator options: " + info,
+ FLAT_DIAMOND_PRESET, field(info, "generatorOptions"));
+ assertEquals("the save-global options string must be untouched — this is a per-dimension "
+ + "channel, not a write to the overworld: " + info,
+ "", field(info, "overworldGeneratorOptions"));
+
+ // The player-visible half: the authored preset is what the generator actually built.
+ String stats = exec("artest worldgen ore-stats " + OPTIONS_DIM + " 0 0 2 minecraft:diamond_block");
+ Matcher m = COUNT.matcher(stats);
+ assertTrue("ore-stats must report a count: " + stats, m.find());
+ int count = Integer.parseInt(m.group(1));
+ assertTrue("the authored flat preset must be the terrain that got generated; a default flat "
+ + "world has no diamond in it at all. count=" + count + " stats=" + stats, count > 0);
+ }
+
+ /** A reported world-type name must be a real registered name, not an absence dressed as one. */
+ private static void assertNamesAWorldType(String key, String value) {
+ assertFalse(key + " must name a world type, got the probe's null marker", "null".equals(value));
+ assertFalse(key + " must name a world type, got an empty string", value.isEmpty());
+ }
+
+ /** Reads a flat string field out of a probe's JSON answer. */
+ private static String field(String json, String key) {
+ Matcher m = Pattern.compile("\"" + key + "\":\"([^\"]*)\"").matcher(json);
+ assertTrue("probe answer has no string field '" + key + "': " + json, m.find());
+ return m.group(1);
+ }
+
/** A registered non-overworld AR planet to clone, excluding the dims this test creates. */
private int firstTemplateArDimOrSkip() throws Exception {
String joined = exec("artest dim list");
diff --git a/testframework/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java b/testframework/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java
index 933e29504..1e483e518 100644
--- a/testframework/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java
+++ b/testframework/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java
@@ -191,6 +191,11 @@ public void pressEnterAfterTyping(String text) throws IOException {
assertOk(execute(command));
}
+ /**
+ * The client's own view of itself: screen, GUI geometry, player position / health / held item,
+ * and — when a world is loaded — the {@code dimension} it renders and that world's
+ * {@code worldType} name, as the client learned it from the join/respawn packet.
+ */
public JsonObject reportState() throws IOException {
return assertOk(execute(command("report_state")));
}
diff --git a/testframework/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java b/testframework/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java
index 8f0940fb3..bbb53e7d0 100644
--- a/testframework/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java
+++ b/testframework/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java
@@ -499,6 +499,16 @@ private static JsonObject handleCommand(JsonObject request) {
response.addProperty("guiXSize", intField(containerScreen, "xSize"));
response.addProperty("guiYSize", intField(containerScreen, "ySize"));
}
+ if (mc.world != null) {
+ // What the CLIENT believes about the world it is in. The world type arrives
+ // in the join/respawn packet and is what client-side generator and terrain
+ // code identifies the world by, so a mod publishing it per dimension is only
+ // verifiable from here.
+ response.addProperty("dimension", mc.world.provider.getDimension());
+ response.addProperty("worldType",
+ mc.world.getWorldInfo().getTerrainType() == null
+ ? "" : mc.world.getWorldInfo().getTerrainType().getName());
+ }
if (mc.player != null) {
response.addProperty("selectedHotbar", mc.player.inventory.currentItem);
response.addProperty("playerX", mc.player.posX);
From d9a115adbfd9cad9c4531c5827163e97b1cb9d7a Mon Sep 17 00:00:00 2001
From: StannisMod
Date: Tue, 11 Aug 2026 16:47:04 +0300
Subject: [PATCH 03/42] feat: derive planet properties, realize bodies, and
grow the retinue
- planet physics derived from (seed, cell); type is an XML preset
- mass and radius primary, gravity derived as M/R^2
- a descent realizes a procedural body into a dimension, idempotently
- long-tailed retinue with moons, belts, rings and distinct cells
- 43 new unit tests and one server e2e
---
.../command/test/TestProbeCommand.java | 155 ++++++
.../dimension/DimensionManager.java | 17 +
.../dimension/DimensionProperties.java | 176 +++++-
.../space/SystemBodiesProducer.java | 7 +-
.../tile/TileAdvancedFlightComputer.java | 25 +-
.../universe/BodyProfile.java | 155 ++++++
.../advancedRocketry/universe/CellHash.java | 59 ++
.../universe/ClusteredGalaxyGenerator.java | 264 +++++++--
.../universe/PlanetDerivation.java | 351 ++++++++++++
.../universe/PlanetRealizer.java | 260 +++++++++
.../universe/PlanetTypePreset.java | 284 ++++++++++
.../universe/PlanetTypes.java | 310 +++++++++++
.../advancedRocketry/universe/SystemBody.java | 53 +-
.../universe/SystemContent.java | 12 +-
.../universe/TerrainOption.java | 129 +++++
.../universe/UniverseRegistry.java | 78 +++
.../util/OreGenProperties.java | 64 +++
.../util/XMLPlanetLoader.java | 321 +++++++++--
.../world/provider/WorldProviderPlanet.java | 15 +
.../ProceduralPlanetRealizationE2ETest.java | 173 ++++++
.../unit/ClusteredGalaxyGeneratorTest.java | 19 +-
.../test/unit/PlanetDerivationTest.java | 508 ++++++++++++++++++
.../test/unit/PlanetRealizationTest.java | 248 +++++++++
.../test/unit/SystemRetinueTest.java | 340 ++++++++++++
24 files changed, 3912 insertions(+), 111 deletions(-)
create mode 100644 src/main/java/zmaster587/advancedRocketry/universe/BodyProfile.java
create mode 100644 src/main/java/zmaster587/advancedRocketry/universe/CellHash.java
create mode 100644 src/main/java/zmaster587/advancedRocketry/universe/PlanetDerivation.java
create mode 100644 src/main/java/zmaster587/advancedRocketry/universe/PlanetRealizer.java
create mode 100644 src/main/java/zmaster587/advancedRocketry/universe/PlanetTypePreset.java
create mode 100644 src/main/java/zmaster587/advancedRocketry/universe/PlanetTypes.java
create mode 100644 src/main/java/zmaster587/advancedRocketry/universe/TerrainOption.java
create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/ProceduralPlanetRealizationE2ETest.java
create mode 100644 src/test/java/zmaster587/advancedRocketry/test/unit/PlanetDerivationTest.java
create mode 100644 src/test/java/zmaster587/advancedRocketry/test/unit/PlanetRealizationTest.java
create mode 100644 src/test/java/zmaster587/advancedRocketry/test/unit/SystemRetinueTest.java
diff --git a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java
index 261fe9b48..0f785acbe 100644
--- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java
+++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java
@@ -4099,6 +4099,161 @@ private void handleSpace(MinecraftServer server, ICommandSender sender, String[]
send(sender, out.toString());
return;
}
+ // gen-install [seed]: install a procedural
+ // galaxy generator and bind a seed. A world with no in its planetDefs runs the
+ // authored-anchors-only default, so without this there are no procedural systems to realize at
+ // all and every test about them would be a test about an empty universe. `gen-reset` puts the
+ // default back; a shared-server class MUST call it, because the generator is a JVM global.
+ if (args.length >= 5 && "gen-install".equalsIgnoreCase(args[0])) {
+ zmaster587.advancedRocketry.universe.UniverseRegistry reg =
+ zmaster587.advancedRocketry.universe.UniverseRegistry.get(server);
+ if (reg == null) {
+ send(sender, "{\"error\":\"registry unavailable\"}");
+ return;
+ }
+ double density = parseDoubleOr(args[1], 0.9d);
+ int minSpacing = parseIntOr(args[2], 8);
+ int clusterScale = parseIntOr(args[3], 8);
+ double voidFraction = parseDoubleOr(args[4], 0d);
+ long seed = args.length >= 6 ? parseLongOr(args[5], 0L) : reg.worldSeed();
+ zmaster587.advancedRocketry.universe.UniverseRegistry.setGenerator(
+ new zmaster587.advancedRocketry.universe.ClusteredGalaxyGenerator(
+ new zmaster587.advancedRocketry.universe.GalaxyGenConfig(density, minSpacing,
+ clusterScale, voidFraction, null)));
+ reg.bindWorldSeed(seed);
+ send(sender, "{\"ok\":true,\"seed\":" + seed + ",\"minSpacing\":" + minSpacing + "}");
+ return;
+ }
+ if (args.length >= 1 && "gen-reset".equalsIgnoreCase(args[0])) {
+ zmaster587.advancedRocketry.universe.UniverseRegistry.setGenerator(null);
+ send(sender, "{\"ok\":true}");
+ return;
+ }
+ // find-procedural : the first cell in a box around the origin that holds a body a ship
+ // could land on but that has NO dimension yet — the precondition of every realization test, and
+ // the thing that is impossible to write down as a literal because it depends on the seed.
+ if (args.length >= 2 && "find-procedural".equalsIgnoreCase(args[0])) {
+ zmaster587.advancedRocketry.universe.UniverseRegistry reg =
+ zmaster587.advancedRocketry.universe.UniverseRegistry.get(server);
+ if (reg == null) {
+ send(sender, "{\"error\":\"registry unavailable\"}");
+ return;
+ }
+ long r = parseIntOr(args[1], 8);
+ for (long x = -r; x <= r; x++) {
+ for (long y = -r; y <= r; y++) {
+ for (long z = -r; z <= r; z++) {
+ zmaster587.advancedRocketry.space.GalacticCoord cell =
+ zmaster587.advancedRocketry.space.GalacticCoord.ofSectorLocal(x, y, z,
+ 0L, 0L, 0L);
+ for (zmaster587.advancedRocketry.universe.SystemBody b : reg.bodiesAt(cell)) {
+ if (b.kind().canDescend()
+ && b.dimId() == zmaster587.advancedRocketry.api.Constants.INVALID_PLANET) {
+ send(sender, "{\"ok\":true,\"sx\":" + x + ",\"sy\":" + y + ",\"sz\":" + z
+ + ",\"cellKey\":\"" + cell.cellKey() + "\",\"kind\":\"" + b.kind()
+ + "\",\"orbitalDist\":" + b.orbitalDistance()
+ + ",\"starId\":" + b.starId() + "}");
+ return;
+ }
+ }
+ }
+ }
+ }
+ send(sender, "{\"ok\":false,\"reason\":\"no unrealized landable body in range\"}");
+ return;
+ }
+ // derived : what the DERIVATION says about the body in that cell, without
+ // realizing anything. This is the answer a telescope gives from across the system, and the whole
+ // point of it is that a landing has to agree with it — so a test compares this against the
+ // realized dimension's own properties rather than against a literal it wrote itself.
+ if (args.length >= 4 && "derived".equalsIgnoreCase(args[0])) {
+ zmaster587.advancedRocketry.universe.UniverseRegistry reg =
+ zmaster587.advancedRocketry.universe.UniverseRegistry.get(server);
+ if (reg == null) {
+ send(sender, "{\"error\":\"registry unavailable\"}");
+ return;
+ }
+ zmaster587.advancedRocketry.space.GalacticCoord cell =
+ zmaster587.advancedRocketry.space.GalacticCoord.ofSectorLocal(
+ parseIntOr(args[1], 0), parseIntOr(args[2], 0), parseIntOr(args[3], 0),
+ 0L, 0L, 0L);
+ java.util.Optional anchor =
+ reg.anchorForCell(cell);
+ java.util.Optional star =
+ reg.starAt(cell);
+ zmaster587.advancedRocketry.universe.SystemBody target = null;
+ int variant = 0;
+ int seen = 0;
+ for (zmaster587.advancedRocketry.universe.SystemBody b : reg.bodiesAt(cell)) {
+ if (b.kind() == zmaster587.advancedRocketry.universe.SystemBodyKind.STAR
+ || b.kind() == zmaster587.advancedRocketry.universe.SystemBodyKind.STATION_SLOT
+ || b.kind() == zmaster587.advancedRocketry.universe.SystemBodyKind.ASTEROID_BELT) {
+ continue;
+ }
+ if (target == null && b.kind().canDescend()) {
+ target = b;
+ variant = seen;
+ }
+ seen++;
+ }
+ if (target == null || !anchor.isPresent() || !star.isPresent()) {
+ send(sender, "{\"ok\":false,\"reason\":\"no landable body derivable at that cell\"}");
+ return;
+ }
+ zmaster587.advancedRocketry.universe.BodyProfile p =
+ zmaster587.advancedRocketry.universe.PlanetDerivation.derive(reg.worldSeed(),
+ anchor.get(), target.name(), variant, star.get(),
+ target.kind() == zmaster587.advancedRocketry.universe.SystemBodyKind.MOON,
+ target.orbitalDistance());
+ send(sender, "{\"ok\":true,\"type\":\"" + p.typeName() + "\",\"orbitalDist\":"
+ + p.orbitalDistance() + ",\"mass\":" + p.massEarths() + ",\"radius\":"
+ + p.radiusEarths() + ",\"gravity\":" + p.gravityPercent() + ",\"pressure\":"
+ + p.pressure() + ",\"temperature\":" + p.temperatureKelvin() + ",\"oxygen\":"
+ + p.hasOxygen() + ",\"locked\":" + p.tidallyLocked() + ",\"metallicity\":"
+ + p.metallicity() + ",\"terrainSource\":\"" + p.terrain().source() + "\"}");
+ return;
+ }
+ // realize : mint the dimension for the landable body in that cell and report what
+ // the world it produced actually carries. The realization path a descent drives, called
+ // directly, so the properties can be compared with `derived` without flying anything.
+ if (args.length >= 4 && "realize".equalsIgnoreCase(args[0])) {
+ zmaster587.advancedRocketry.space.GalacticCoord cell =
+ zmaster587.advancedRocketry.space.GalacticCoord.ofSectorLocal(
+ parseIntOr(args[1], 0), parseIntOr(args[2], 0), parseIntOr(args[3], 0),
+ 0L, 0L, 0L);
+ int dimId = zmaster587.advancedRocketry.universe.PlanetRealizer.realize(server, cell);
+ if (dimId == zmaster587.advancedRocketry.api.Constants.INVALID_PLANET) {
+ send(sender, "{\"ok\":false,\"reason\":\"nothing landable in that cell\"}");
+ return;
+ }
+ zmaster587.advancedRocketry.dimension.DimensionProperties props =
+ zmaster587.advancedRocketry.dimension.DimensionManager.getInstance()
+ .getDimensionPropertiesOrNull(dimId);
+ if (props == null) {
+ send(sender, "{\"ok\":false,\"dim\":" + dimId + ",\"reason\":\"no properties registered\"}");
+ return;
+ }
+ zmaster587.advancedRocketry.universe.UniverseRegistry reg =
+ zmaster587.advancedRocketry.universe.UniverseRegistry.get(server);
+ boolean descendTarget = false;
+ if (reg != null) {
+ for (zmaster587.advancedRocketry.universe.SystemBody b : reg.bodiesAt(cell)) {
+ if (b.dimId() == dimId && b.isDescendTarget()) {
+ descendTarget = true;
+ }
+ }
+ }
+ send(sender, "{\"ok\":true,\"dim\":" + dimId + ",\"name\":\"" + props.getName()
+ + "\",\"orbitalDist\":" + props.getOrbitalDist() + ",\"mass\":" + props.getMass()
+ + ",\"radius\":" + props.getRadius() + ",\"gravity\":"
+ + Math.round(props.getGravitationalMultiplier() * 100f) + ",\"pressure\":"
+ + props.getAtmosphereDensity() + ",\"temperature\":" + props.averageTemperature
+ + ",\"oxygen\":" + props.hasOxygen + ",\"locked\":" + props.isTidallyLocked()
+ + ",\"metallicity\":" + props.getMetallicity() + ",\"gasGiant\":"
+ + props.isGasGiant() + ",\"terrainSource\":\"" + props.getTerrainSource()
+ + "\",\"descendTarget\":" + descendTarget + ",\"starId\":" + props.getStarId() + "}");
+ return;
+ }
// find-afc : report a subspace block position + durable ship id of the settled ship in slot
// , so a descent e2e can drive requestDescent for it. Located via the ledger coord (headless
// the AFC does not tick, so the coord stays the settle coord) -> world pose -> the queryable ship
diff --git a/src/main/java/zmaster587/advancedRocketry/dimension/DimensionManager.java b/src/main/java/zmaster587/advancedRocketry/dimension/DimensionManager.java
index 1890f7c68..8ffc6471c 100644
--- a/src/main/java/zmaster587/advancedRocketry/dimension/DimensionManager.java
+++ b/src/main/java/zmaster587/advancedRocketry/dimension/DimensionManager.java
@@ -384,6 +384,17 @@ public DimensionProperties generateRandomGasGiant(int starId, String name, int b
properties.averageTemperature = AstronomicalBodyHelper.getAverageTemperature(properties.getStar(), properties.getSolarOrbitalDistance(), properties.getAtmosphereDensity());
properties.setGasGiant(true);
+ // Rings belong to giants, and on a giant they are the RULE rather than a flourish: all four of
+ // the Solar System's have them, because only a body that massive has a Roche limit reaching far
+ // enough past its own surface for a moon to have come apart out there. The rocky-planet path
+ // still rolls its rare 1-in-50; this is the same story told where it actually happens.
+ if (random.nextInt(4) != 0) {
+ properties.setHasRings(true);
+ properties.ringColor[0] = properties.skyColor[0];
+ properties.ringColor[1] = properties.skyColor[1];
+ properties.ringColor[2] = properties.skyColor[2];
+ }
+
// Add all gasses for the default world
for (FluidGasGiantGas gas : AdvancedRocketryFluids.getGasGiantGasses()) {
if (((properties.gravitationalMultiplier * 100) >= gas.getMinGravity()) && (gas.getMaxGravity() >= (properties.gravitationalMultiplier * 100)) && 0 > (Math.random() - gas.getChance())) {
@@ -1079,6 +1090,12 @@ public void createAndLoadDimensions(boolean resetFromXml) {
? null
: new zmaster587.advancedRocketry.universe.ClusteredGalaxyGenerator(galaxyGenConfig));
+ // Install the authored planet-type table for the same reason and on the same terms: it is a
+ // JVM-global, so an absent (or trimmed) section must restore the stock set rather
+ // than leave the previous world's presets standing.
+ zmaster587.advancedRocketry.universe.PlanetTypes.setPresets(
+ dimCouplingList == null ? null : dimCouplingList.planetTypes);
+
// make sure to set dim offset back to original to make things consistant
DimensionManager.dimOffset = dimOffset;
diff --git a/src/main/java/zmaster587/advancedRocketry/dimension/DimensionProperties.java b/src/main/java/zmaster587/advancedRocketry/dimension/DimensionProperties.java
index 4e73ab4d6..0547fd8f2 100644
--- a/src/main/java/zmaster587/advancedRocketry/dimension/DimensionProperties.java
+++ b/src/main/java/zmaster587/advancedRocketry/dimension/DimensionProperties.java
@@ -196,6 +196,54 @@ private static float clampFeatureFrequencyMultiplier(float multiplier) {
* {@code WorldInfo} when it identifies itself; an empty string means "your defaults".
*/
private String terrainGeneratorOptions = "";
+
+ // ─── Bulk properties: mass and radius are PRIMARY, gravity is derived from them ────────────────
+ /**
+ * This body's mass in Earth masses, or {@link #BULK_UNSET} when nothing has stated one.
+ *
+ * Mass and radius are the PRIMARY bulk properties and surface gravity is what falls out of them
+ * ({@code g = M/R²}) — not the other way round. That ordering is what lets a scan advertise a
+ * planet's mass ({@code PlanetInfoField.MASS} is promised at telescope tier, for every planet,
+ * authored ones included) and what makes the zoning of a procedural system physical rather than
+ * tabulated: a big cold body accretes gas and becomes a giant, a small hot one cannot hold air.
+ *
+ * {@link #gravitationalMultiplier} REMAINS an explicit override. A planet whose XML states a
+ * gravity keeps exactly that gravity, whatever its mass and radius say, so no authored world moves
+ * when this arrives; the derivation only fills in a gravity nobody stated.
+ */
+ private double mass = BULK_UNSET;
+ /** This body's radius in Earth radii, or {@link #BULK_UNSET}. See {@link #mass}. */
+ private double radius = BULK_UNSET;
+ /**
+ * Whether {@link #gravitationalMultiplier} was STATED rather than derived. The single bit that keeps
+ * "authored planets are unchanged" true: it is set by the XML element, by the public setter and by
+ * anything that assigns the field directly through the legacy path, and it makes
+ * {@link #setBulk} leave the gravity alone.
+ */
+ private boolean gravityAuthored;
+ /**
+ * Whether this world keeps one face permanently to its star.
+ *
+ * An explicit flag and not a {@code rotationalPeriod} of zero: zero is mapped back to a full day
+ * by the sleep arithmetic, so it would silently mean "an ordinary planet" — the one value that
+ * cannot express this. A locked world has no day/night cycle at all, which is a different statement
+ * from "its day is long".
+ */
+ private boolean tidallyLocked;
+ /**
+ * The parent star's metal content relative to Sol, and therefore how metal-rich this world's ore is.
+ *
+ * It scales the METALLIC entries of whatever ore palette this world's climate earns it; it does
+ * not decide which kinds of deposit are possible. Climate answers "what sort of deposits", the star
+ * answers "how much metal is in them", and the two multiply rather than compete.
+ */
+ private double metallicity = 1d;
+ /** Lazily-built, never persisted: this world's own scaled copy of the shared climate ore table. */
+ private transient OreGenProperties scaledOreCache;
+ private transient double scaledOreCacheFor = Double.NaN;
+
+ /** Sentinel for {@link #mass} / {@link #radius}: nobody has stated one. */
+ public static final double BULK_UNSET = 0d;
//public int target_sea_level;
// modId must be declared explicitly: this @SidedProxy lives outside the @Mod class, and the jar
@@ -453,7 +501,19 @@ public Object clone() {
public OreGenProperties getOreGenProperties(World world) {
if (oreProperties != null)
return oreProperties;
- return OreGenProperties.getOresForPressure(AtmosphereTypes.getAtmosphereTypeFromValue(originalAtmosphereDensity), Temps.getTempFromValue(getAverageTemp()));
+ OreGenProperties climate = OreGenProperties.getOresForPressure(
+ AtmosphereTypes.getAtmosphereTypeFromValue(originalAtmosphereDensity),
+ Temps.getTempFromValue(getAverageTemp()));
+ if (climate == null || metallicity == 1d)
+ return climate;
+ // The climate table is a SHARED static object — one instance per (pressure, temperature) cell,
+ // handed to every world that lands in it — so a per-planet scaling must never mutate it. This
+ // world gets its own copy instead, cached because ore generation asks per chunk.
+ if (scaledOreCache == null || scaledOreCacheFor != metallicity) {
+ scaledOreCache = climate.withMetalsScaled(metallicity);
+ scaledOreCacheFor = metallicity;
+ }
+ return scaledOreCache;
}
/**
@@ -487,6 +547,91 @@ public void resetProperties() {
terrainTemplate = "";
terrainGeneratorOptions = "";
laserDrillOres = new ArrayList<>();
+ mass = BULK_UNSET;
+ radius = BULK_UNSET;
+ gravityAuthored = false;
+ tidallyLocked = false;
+ metallicity = 1d;
+ scaledOreCache = null;
+ scaledOreCacheFor = Double.NaN;
+ }
+
+ // ─── Bulk properties ───────────────────────────────────────────────────────
+
+ /** This body's mass in Earth masses, or {@link #BULK_UNSET} when nobody has stated one. */
+ public double getMass() {
+ return mass;
+ }
+
+ /** This body's radius in Earth radii, or {@link #BULK_UNSET}. */
+ public double getRadius() {
+ return radius;
+ }
+
+ public boolean hasBulkProperties() {
+ return mass > BULK_UNSET && radius > BULK_UNSET;
+ }
+
+ /**
+ * State this body's mass and radius, deriving surface gravity from them unless a gravity was
+ * explicitly authored.
+ *
+ * @param massEarths mass in Earth masses
+ * @param radiusEarths radius in Earth radii
+ */
+ public void setBulk(double massEarths, double radiusEarths) {
+ this.mass = Math.max(0d, massEarths);
+ this.radius = Math.max(0d, radiusEarths);
+ if (!gravityAuthored && hasBulkProperties()) {
+ gravitationalMultiplier = (float) derivedGravity(this.mass, this.radius);
+ }
+ }
+
+ /**
+ * Surface gravity in Earth gravities from mass and radius — {@code g = M/R²} — clamped to the range
+ * the game can actually run a player in. The floor is the same one the legacy random generator has
+ * always used; the ceiling is {@link #MAX_GRAVITY}.
+ */
+ public static double derivedGravity(double massEarths, double radiusEarths) {
+ double g = massEarths / Math.max(1e-6d, radiusEarths * radiusEarths);
+ double lo = 0.05d;
+ double hi = MAX_GRAVITY / 100d;
+ if (Double.isNaN(g) || g < lo) {
+ return lo;
+ }
+ return g > hi ? hi : g;
+ }
+
+ /** Whether a gravity was STATED for this body rather than derived from its bulk. */
+ public boolean isGravityAuthored() {
+ return gravityAuthored;
+ }
+
+ /** Mark this body's {@link #gravitationalMultiplier} as authored — the XML/override path. */
+ public void setGravityAuthored(boolean authored) {
+ this.gravityAuthored = authored;
+ }
+
+ /**
+ * Whether this world keeps one face to its star: no day/night cycle at all, rather than a long day.
+ */
+ public boolean isTidallyLocked() {
+ return tidallyLocked;
+ }
+
+ public void setTidallyLocked(boolean locked) {
+ this.tidallyLocked = locked;
+ }
+
+ /** The parent star's metal content relative to Sol — see {@link #metallicity}. */
+ public double getMetallicity() {
+ return metallicity;
+ }
+
+ public void setMetallicity(double value) {
+ this.metallicity = (Double.isNaN(value) || value <= 0d) ? 1d : value;
+ this.scaledOreCache = null;
+ this.scaledOreCacheFor = Double.NaN;
}
public List getHarvestableGasses() {
@@ -505,6 +650,9 @@ public float getGravitationalMultiplier() {
@Override
public void setGravitationalMultiplier(float mult) {
gravitationalMultiplier = mult;
+ // Stating a gravity is what makes it an override: from here on the mass/radius derivation must
+ // not touch it, or an authored planet would silently change the moment it gained a mass.
+ gravityAuthored = true;
}
public List getSpawnListEntries() {
@@ -1687,6 +1835,15 @@ else if (nbt.hasKey("biomes", NBT.TAG_INT_ARRAY)) {
}
gravitationalMultiplier = nbt.getFloat("gravitationalMultiplier");
+ // Bulk properties, written only when stated: an absent key leaves the sentinel, so a world
+ // saved before planets had a mass reloads with exactly the gravity it already had.
+ mass = nbt.hasKey("mass") ? nbt.getDouble("mass") : BULK_UNSET;
+ radius = nbt.hasKey("radius") ? nbt.getDouble("radius") : BULK_UNSET;
+ gravityAuthored = nbt.getBoolean("gravityAuthored");
+ tidallyLocked = nbt.getBoolean("tidallyLocked");
+ metallicity = nbt.hasKey("metallicity") ? nbt.getDouble("metallicity") : 1d;
+ scaledOreCache = null;
+ scaledOreCacheFor = Double.NaN;
orbitalDist = nbt.getInteger("orbitalDist");
orbitTheta = nbt.getDouble("orbitTheta");
baseOrbitTheta = nbt.getDouble("baseOrbitTheta");
@@ -2066,6 +2223,23 @@ public void writeToNBT(NBTTagCompound nbt) {
nbt.setInteger("starId", starId);
nbt.setFloat("gravitationalMultiplier", gravitationalMultiplier);
+ // Non-default-only, the terrainSource idiom: a planet that never stated a mass writes no mass
+ // key, so its NBT stays byte-identical to what it wrote before bulk properties existed.
+ if (mass > BULK_UNSET) {
+ nbt.setDouble("mass", mass);
+ }
+ if (radius > BULK_UNSET) {
+ nbt.setDouble("radius", radius);
+ }
+ if (gravityAuthored) {
+ nbt.setBoolean("gravityAuthored", true);
+ }
+ if (tidallyLocked) {
+ nbt.setBoolean("tidallyLocked", true);
+ }
+ if (metallicity != 1d) {
+ nbt.setDouble("metallicity", metallicity);
+ }
nbt.setInteger("orbitalDist", orbitalDist);
nbt.setDouble("orbitTheta", orbitTheta);
nbt.setDouble("baseOrbitTheta", baseOrbitTheta);
diff --git a/src/main/java/zmaster587/advancedRocketry/space/SystemBodiesProducer.java b/src/main/java/zmaster587/advancedRocketry/space/SystemBodiesProducer.java
index 56c054d8d..0aaf3fad3 100644
--- a/src/main/java/zmaster587/advancedRocketry/space/SystemBodiesProducer.java
+++ b/src/main/java/zmaster587/advancedRocketry/space/SystemBodiesProducer.java
@@ -119,8 +119,13 @@ public static Map> buildByDim(Map loa
if (found != null) {
for (SystemBody b : found) {
BlockDelta dir = b.absoluteAt(worldTick).minus(observer);
+ // "Can a ship land here", not "does a world already exist". A procedural planet has
+ // no dimension until a descent mints one, so highlighting only realized bodies
+ // would hide the descent boundary of every world nobody has visited — which is
+ // precisely the set a pilot is out there looking for. The flag is a render hint;
+ // the logic that needs a real dimension still asks isDescendTarget().
bodies.add(new RenderBody(b.kind().ordinal(), dir.dx(), dir.dy(), dir.dz(),
- renderDimIdOf(b), b.isDescendTarget()));
+ renderDimIdOf(b), b.kind().canDescend()));
}
}
byDim.put(slotDim, bodies);
diff --git a/src/main/java/zmaster587/advancedRocketry/tile/TileAdvancedFlightComputer.java b/src/main/java/zmaster587/advancedRocketry/tile/TileAdvancedFlightComputer.java
index f82722bb5..37b919db8 100644
--- a/src/main/java/zmaster587/advancedRocketry/tile/TileAdvancedFlightComputer.java
+++ b/src/main/java/zmaster587/advancedRocketry/tile/TileAdvancedFlightComputer.java
@@ -417,7 +417,11 @@ public void update() {
zmaster587.advancedRocketry.space.GalacticCoord shipCoord = settled.coord;
long radius = zmaster587.advancedRocketry.space.ShipEntryController.DESCENT_RADIUS_BLOCKS;
for (zmaster587.advancedRocketry.universe.SystemBody body : reg.bodiesAt(shipCoord)) {
- if (!body.isDescendTarget()) {
+ // A procedural body has no dimension until somebody flies down to it, so the
+ // filter here is "can this be landed on", not "does it already have a world".
+ // The world is minted below, once the ship is genuinely close enough to
+ // descend — a scan must never allocate a dimension.
+ if (!body.kind().canDescend()) {
continue;
}
// Ship and body are in the SAME cell here (bodiesAt filters by name), so
@@ -427,10 +431,21 @@ public void update() {
double distance = Math.sqrt(shipCoord.staticFrameDistanceSqTo(
body.addressAt(zmaster587.advancedRocketry.space.SpaceSubsystem
.spaceClock())));
- if (zmaster587.advancedRocketry.space.DescentController
- .shouldTriggerDescent(true, true, distance, radius)
- && descentCtl.requestDescent(world.provider.getDimension(),
- getPos(), shipId, body.dimId())) {
+ if (!zmaster587.advancedRocketry.space.DescentController
+ .shouldTriggerDescent(true, true, distance, radius)) {
+ continue;
+ }
+ int targetDim = body.dimId();
+ if (targetDim == zmaster587.advancedRocketry.api.Constants.INVALID_PLANET) {
+ targetDim = zmaster587.advancedRocketry.universe.PlanetRealizer
+ .realize(server, body.name());
+ if (targetDim
+ == zmaster587.advancedRocketry.api.Constants.INVALID_PLANET) {
+ continue; // nothing landable here after all
+ }
+ }
+ if (descentCtl.requestDescent(world.provider.getDimension(),
+ getPos(), shipId, targetDim)) {
// The crossing started: this tile was cut out of the slot world - stop
// publishing from a stale tick. The re-assembled ship resumes planet-side.
return;
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/BodyProfile.java b/src/main/java/zmaster587/advancedRocketry/universe/BodyProfile.java
new file mode 100644
index 000000000..a731630d3
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/universe/BodyProfile.java
@@ -0,0 +1,155 @@
+package zmaster587.advancedRocketry.universe;
+
+/**
+ * Everything a procedural body IS, derived from {@code (seed, cell)} alone — the object that crosses
+ * the universe→dimension layer boundary.
+ *
+ * The UNIVERSE layer produces one of these ({@link PlanetDerivation}); the DIMENSION layer consumes
+ * it when a body is realized into a real world. Nothing here is a world, a block, a biome or a
+ * dimension id: a profile can be computed for a body nobody has ever visited, which is precisely what
+ * lets a telescope report a world's mass, atmosphere and temperature from across the system while the
+ * player still has no suit for it.
+ *
+ * Determinism is the contract, not an optimisation. The scan and the landing must describe
+ * the same world, so realization MATERIALIZES this profile rather than rolling fresh values. Terrain is
+ * the one field a scan does not promise from afar ({@code TERRAIN_TYPE} sits at the approach tier), and
+ * it is still derived here so that everything about a body has exactly one origin.
+ *
+ * Immutable value object.
+ */
+public final class BodyProfile {
+
+ private final SystemBodyKind kind;
+ private final String typeName;
+ private final PlanetTypePreset preset;
+ private final int orbitalDistance;
+ private final double massEarths;
+ private final double radiusEarths;
+ private final int gravityPercent;
+ private final int pressure;
+ private final int temperatureKelvin;
+ private final boolean hasOxygen;
+ private final boolean tidallyLocked;
+ private final boolean hasRings;
+ private final double metallicity;
+ private final TerrainOption terrain;
+
+ public BodyProfile(SystemBodyKind kind, String typeName, PlanetTypePreset preset, int orbitalDistance,
+ double massEarths, double radiusEarths, int gravityPercent, int pressure,
+ int temperatureKelvin, boolean hasOxygen, boolean tidallyLocked, boolean hasRings,
+ double metallicity, TerrainOption terrain) {
+ this.kind = kind;
+ this.typeName = typeName;
+ this.preset = preset;
+ this.orbitalDistance = orbitalDistance;
+ this.massEarths = massEarths;
+ this.radiusEarths = radiusEarths;
+ this.gravityPercent = gravityPercent;
+ this.pressure = pressure;
+ this.temperatureKelvin = temperatureKelvin;
+ this.hasOxygen = hasOxygen;
+ this.tidallyLocked = tidallyLocked;
+ this.hasRings = hasRings;
+ this.metallicity = metallicity;
+ this.terrain = terrain;
+ }
+
+ /** What this body is as an addressable object — planet, giant, moon or belt. */
+ public SystemBodyKind kind() {
+ return kind;
+ }
+
+ /** The planet type's name, or {@link PlanetTypes#UNCLASSIFIED} when no preset admitted this world. */
+ public String typeName() {
+ return typeName;
+ }
+
+ /** The admitting preset, or {@code null} when none did. */
+ public PlanetTypePreset preset() {
+ return preset;
+ }
+
+ /** Orbital radius in Advanced Rocketry distance units (100 = 1 AU). */
+ public int orbitalDistance() {
+ return orbitalDistance;
+ }
+
+ /** Mass in Earth masses — PRIMARY, not derived from gravity. */
+ public double massEarths() {
+ return massEarths;
+ }
+
+ /** Radius in Earth radii — PRIMARY. */
+ public double radiusEarths() {
+ return radiusEarths;
+ }
+
+ /** Surface gravity in percent of Earth's, derived as {@code M/R²} and clamped to the game's range. */
+ public int gravityPercent() {
+ return gravityPercent;
+ }
+
+ /** Surface pressure in atmosphere-density units (100 = 1 atm). */
+ public int pressure() {
+ return pressure;
+ }
+
+ /** Surface temperature in Kelvin, computed WITH the derived atmosphere. */
+ public int temperatureKelvin() {
+ return temperatureKelvin;
+ }
+
+ /** Whether the atmosphere is breathable — an independent rare roll, never a consequence of the rest. */
+ public boolean hasOxygen() {
+ return hasOxygen;
+ }
+
+ /**
+ * Whether this world keeps one face to its star: permanent day, permanent night, and a habitable
+ * terminator strip between them as the only temperate ground.
+ */
+ public boolean tidallyLocked() {
+ return tidallyLocked;
+ }
+
+ /**
+ * Whether this body wears a ring system.
+ *
+ * Rings are where the "something was torn apart" story actually lives: a moon that wandered
+ * inside its planet's Roche limit came apart into a disc, and only a body massive enough for that
+ * limit to reach beyond its own surface can hold the result. Every one of the Solar System's four
+ * giants has rings, so on a giant this is COMMON rather than a rare flourish; on a rocky world it
+ * effectively never happens.
+ */
+ public boolean hasRings() {
+ return hasRings;
+ }
+
+ /**
+ * The parent star's metal content, relative to Sol. A metal-poor star formed a metal-poor disk, so
+ * this scales the METAL fraction of whatever ore palette the world's climate earns it — it does not
+ * change which kinds of deposit are possible.
+ */
+ public double metallicity() {
+ return metallicity;
+ }
+
+ /** How this world's terrain is generated, drawn from its type's weighted list. */
+ public TerrainOption terrain() {
+ return terrain;
+ }
+
+ /** Whether this body can be stood on at all — the giants cannot. */
+ public boolean hasSurface() {
+ return kind != SystemBodyKind.GAS_GIANT && kind != SystemBodyKind.ASTEROID_BELT
+ && kind != SystemBodyKind.STAR;
+ }
+
+ @Override
+ public String toString() {
+ return "BodyProfile[" + kind + " " + typeName + " d=" + orbitalDistance + " M=" + massEarths
+ + " R=" + radiusEarths + " g=" + gravityPercent + "% p=" + pressure + " T="
+ + temperatureKelvin + "K" + (hasOxygen ? " O2" : "") + (tidallyLocked ? " locked" : "")
+ + (hasRings ? " rings" : "") + " Z=" + metallicity + " " + terrain + ']';
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/CellHash.java b/src/main/java/zmaster587/advancedRocketry/universe/CellHash.java
new file mode 100644
index 000000000..b91e8e61e
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/universe/CellHash.java
@@ -0,0 +1,59 @@
+package zmaster587.advancedRocketry.universe;
+
+import zmaster587.advancedRocketry.space.GalacticCoord;
+
+/**
+ * The one mixer every procedural answer about a cell is drawn from.
+ *
+ * A splitmix-style mix of the world seed, an integer coordinate triple and a per-field salt, uniform
+ * over 64 bits. Distinct salts are what keep the independent draws — blob mask, occupancy, star type,
+ * body count, a planet's radius — from correlating with each other.
+ *
+ * This arithmetic is a save-compatibility surface for the LIFE of a world, not an implementation
+ * detail. Every unpinned procedural system is re-derived from it on every query, so changing a
+ * constant here silently moves stars and reshapes planets in every existing save that has not been
+ * touched. It lives in one place for exactly that reason: two copies of a mixer are two things to
+ * forget about.
+ */
+final class CellHash {
+
+ private CellHash() {
+ }
+
+ /** Mix {@code seed}, the triple {@code (a,b,c)} and {@code salt} into a uniform 64-bit value. */
+ static long of(long seed, long a, long b, long c, long salt) {
+ long h = seed + salt * 0x9E3779B97F4A7C15L;
+ h ^= a;
+ h *= 0xFF51AFD7ED558CCDL;
+ h ^= h >>> 33;
+ h ^= b;
+ h *= 0xC4CEB9FE1A85EC53L;
+ h ^= h >>> 33;
+ h ^= c;
+ h *= 0xFF51AFD7ED558CCDL;
+ h ^= h >>> 33;
+ return h;
+ }
+
+ /** Mix a cell's own field draw. */
+ static long ofCell(long seed, GalacticCoord cell, long salt) {
+ return of(seed, cell.sectorX(), cell.sectorY(), cell.sectorZ(), salt);
+ }
+
+ /**
+ * Mix a per-BODY field draw inside a cell's system.
+ *
+ * The body index is XORed into the seed through a different multiplier than {@link #of} uses for
+ * the salt, so the two cannot merge into {@code (i + salt) * G} and correlate neighbouring bodies'
+ * draws — which would make body {@code i}'s radius a near-copy of body {@code i+1}'s.
+ */
+ static long ofBody(long seed, GalacticCoord cell, int index, long salt) {
+ return of(seed ^ (index * 0xD1B54A32D192ED03L), cell.sectorX(), cell.sectorY(), cell.sectorZ(),
+ salt);
+ }
+
+ /** Map a 64-bit hash to a double in {@code [0, 1)}. */
+ static double norm(long h) {
+ return (h >>> 11) * 0x1.0p-53;
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/ClusteredGalaxyGenerator.java b/src/main/java/zmaster587/advancedRocketry/universe/ClusteredGalaxyGenerator.java
index 639a4e218..2631cc968 100644
--- a/src/main/java/zmaster587/advancedRocketry/universe/ClusteredGalaxyGenerator.java
+++ b/src/main/java/zmaster587/advancedRocketry/universe/ClusteredGalaxyGenerator.java
@@ -3,13 +3,16 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.Set;
import zmaster587.advancedRocketry.api.Constants;
import zmaster587.advancedRocketry.api.dimension.solar.StellarBody;
import zmaster587.advancedRocketry.space.GalacticCoord;
+import zmaster587.advancedRocketry.util.AstronomicalBodyHelper;
/**
* A deterministic, addon-default {@link IGalaxyGenerator} producing a CLUSTERED procedural galaxy
@@ -51,10 +54,57 @@ public final class ClusteredGalaxyGenerator implements IGalaxyGenerator {
// by the super-cell partition (minSpacing/2 - margin) so two systems' neighbourhoods never interleave.
private static final long SALT_BODYCOUNT = 0x11L;
private static final long SALT_BODYANG = 0x12L;
- private static final long SALT_BODYRAD = 0x13L;
+ // 0x13 was SALT_BODYRAD, the uniform cell-radius draw. Retired: a body's cell radius now FOLLOWS
+ // its orbital distance (PlanetDerivation.orbitFraction), so the two layouts cannot disagree. The
+ // number stays burned so a future draw cannot silently inherit an old galaxy's stream.
private static final long SALT_BODYY = 0x14L;
- private static final long SALT_BELT = 0x15L;
- private static final int MAX_PROC_PLANETS = 6;
+ // 0x15 was SALT_BELT, the "roughly a third of systems end in a belt" roll. Retired: an outer belt is
+ // now MANDATORY and an inner one is derived from a giant, so a belt is never a coin toss. The number
+ // stays burned so a future draw cannot inherit an old galaxy's stream.
+ private static final long SALT_MOONCOUNT = 0x16L;
+ private static final long SALT_MOONANG = 0x17L;
+ private static final long SALT_MOONRAD = 0x18L;
+
+ // ─── The retinue: how many bodies a system has, and where they sit ─────────
+ // Every number here is a balance knob. What is NOT a knob is the shape: a long tail, a mandatory
+ // outer belt, and moons on the bodies big enough to hold them.
+
+ /**
+ * Body count is drawn from a shifted exponential: a median around five or six, and a thin tail that
+ * occasionally produces a system of fifteen or more. A rich system is itself a find, which is what
+ * makes exploring for one worth doing — a fixed ceiling of six made every system the same size.
+ */
+ private static final int MIN_PROC_PLANETS = 3;
+ private static final double PLANET_COUNT_SCALE = 3.385d;
+ /**
+ * Hard ceiling on the retinue. Not a balance number: {@code bodiesFor} runs on EVERY registry query
+ * — the render feed, the console's forecast, every proximity check — so the tail has to be bounded
+ * by something other than luck.
+ */
+ private static final int MAX_PROC_PLANETS = 24;
+
+ /** Moons per body, drawn as {@code floor(u^BIAS · (MAX+1))}: most bodies have none, giants have several. */
+ private static final int MAX_MOONS_ROCKY = 2;
+ private static final int MAX_MOONS_GIANT = 5;
+ private static final double MOON_COUNT_BIAS = 1.9d;
+ /** A moon's orbit about its parent, in the parent-relative units the moon ephemeris is written in. */
+ private static final int MOON_MIN_ORBIT = 20;
+ private static final int MOON_ORBIT_SPAN = 110;
+
+ /** The outer belt sits this far beyond the outermost major body — the Kuiper analogue. */
+ private static final double OUTER_BELT_FACTOR = 1.6d;
+ /**
+ * An inner belt sits at the resonance-cleared gap inside a giant. A belt is not a destroyed planet:
+ * it is material that never accreted because a nearby giant pumped relative velocities past the
+ * point where collisions stick — so a belt is DERIVED from a giant, and a system with no giant has
+ * no inner belt.
+ */
+ private static final double INNER_BELT_RESONANCE = 1.8d;
+
+ /** Deterministic angular step used when a body's first-choice cell is already occupied. */
+ private static final double NUDGE_ANGLE = 2.399963229728653d; // the golden angle, in radians
+ /** How many relocations a body gets before its system is declared full. */
+ private static final int NUDGE_ATTEMPTS = 96;
/** Neighbourhood margin (cells) kept clear of the super-cell boundary. */
private static final int NEIGHBOURHOOD_MARGIN_CELLS = 2;
/** Thin-disk half-thickness as a fraction of the orbit radius (bodies keep honest 3D Y — A#1a e1). */
@@ -139,6 +189,7 @@ public List bodiesFor(long seed, GalacticCoord systemCoord) {
return Collections.emptyList();
}
int starId = sys.get().starId();
+ StellarBody star = sys.get().star();
List bodies = new ArrayList<>();
// The star sits at the anchor cell's centre.
bodies.add(new SystemBody(cell, SystemBodyKind.STAR, Constants.INVALID_PLANET, starId));
@@ -148,31 +199,164 @@ public List bodiesFor(long seed, GalacticCoord systemCoord) {
// face), so a radius <= 3s/8 - margin keeps every body inside the anchor's super-cell — member-cell
// attribution by floorDiv stays exact. (The per-body box clamp below covers the tiny-spacing floor.)
long s = config.minSpacing;
+
+ // AT MOST ONE REAL BODY PER CELL, moons excepted. The draw picks each body's angle and radius
+ // independently, so two of them CAN land on the same cell — and two real bodies in one cell are
+ // two destinations a player can neither tell apart nor choose between. Claiming cells as they
+ // are used, and relocating a body that finds its first choice taken, is what keeps the
+ // generator's own output out of that state; the audit that reports it would otherwise fire on
+ // the generator itself, and the more bodies a system has the likelier that becomes.
+ Set taken = new HashSet<>();
+ taken.add(cell.cellKey());
+
+ int count = retinueSize(seed, cell);
+ int outermostOrbit = 0;
+ int innermostGiantOrbit = 0;
+ for (int i = 0; i < count; i++) {
+ // The ORBIT is drawn first and the cell radius follows it, rather than the other way round:
+ // a body's physics is derived from its orbit, so letting the placement pick the radius would
+ // make every world's climate a function of the layout arithmetic.
+ int orbit = PlanetDerivation.orbitalDistanceOf(seed, cell, i, count, star);
+ GalacticCoord addr = placeBody(seed, cell, i, orbit, star, s, taken);
+ if (addr == null) {
+ continue; // this system's neighbourhood is full — a bound of the layout, not a failure
+ }
+ // Planet or giant is not a roll of its own: it falls out of the body's derived physics,
+ // which is what makes the zoning (rock inside, giants past the snow line) emerge instead
+ // of being authored. Kept here rather than at realization because the nav list, the sky
+ // and the descent trigger all read the kind long before anyone lands.
+ BodyProfile profile = PlanetDerivation.derive(seed, cell, addr, 0, star, false, orbit);
+ // Procedural bodies have no realized dimension yet — a descent (Layer 2) realizes one.
+ bodies.add(new SystemBody(addr, profile.kind(), Constants.INVALID_PLANET, starId, orbit));
+ outermostOrbit = Math.max(outermostOrbit, orbit);
+ if (profile.kind() == SystemBodyKind.GAS_GIANT
+ && (innermostGiantOrbit == 0 || orbit < innermostGiantOrbit)) {
+ innermostGiantOrbit = orbit;
+ }
+ addMoons(bodies, seed, cell, addr, orbit, star, starId, profile);
+ }
+
+ // An inner belt is DERIVED from a giant and never rolled: it is material a giant's resonances
+ // stopped from accreting, so it belongs in the gap inside one and a system with no giant has none.
+ if (innermostGiantOrbit > 0) {
+ addBelt(bodies, seed, cell, (int) (innermostGiantOrbit / INNER_BELT_RESONANCE), star, s,
+ starId, taken, count + 1);
+ }
+ // The outer belt is MANDATORY on every system — the Kuiper analogue, and the reason every system
+ // is worth arriving in: it is a gravity-well-free mining site that needs no landing, so a ship
+ // that drifts into any system at all has something to work.
+ int outerBelt = (int) Math.max(outermostOrbit * OUTER_BELT_FACTOR,
+ PlanetDerivation.innerOrbit(star) * 2d);
+ addBelt(bodies, seed, cell, outerBelt, star, s, starId, taken, count + 2);
+ return bodies;
+ }
+
+ /**
+ * How many major bodies a system has. A shifted exponential: most systems are ordinary, a few are
+ * enormous, and the ceiling exists to bound the per-query cost rather than the fiction.
+ */
+ public static int retinueSize(long seed, GalacticCoord anchor) {
+ double u = CellHash.norm(CellHash.ofCell(seed, anchor, SALT_BODYCOUNT));
+ double tail = -Math.log(Math.max(1e-12d, 1d - u)) * PLANET_COUNT_SCALE;
+ int n = MIN_PROC_PLANETS + (int) tail;
+ return Math.max(1, Math.min(MAX_PROC_PLANETS, n));
+ }
+
+ /**
+ * Claim a free cell for a body orbiting at {@code orbit}, or {@code null} when the neighbourhood has
+ * no room left.
+ *
+ * The first choice puts the body at the cell radius its orbit maps to, at a drawn angle. If that
+ * cell is already spoken for, the body is walked around the ring by the golden angle — which keeps
+ * its radius, and therefore keeps the system's cell layout in the same order as its orbits — and
+ * only then allowed to drift outward. A body that still finds nothing is dropped: a neighbourhood
+ * holds what it holds, and inventing a second occupant for a cell is the one outcome that is worse
+ * than a smaller system.
+ */
+ private static GalacticCoord placeBody(long seed, GalacticCoord anchor, int index, int orbit,
+ StellarBody star, long s, Set taken) {
long maxRadiusCells = Math.max(1L, 3L * s / 8L - NEIGHBOURHOOD_MARGIN_CELLS);
double maxRadiusBlocks = (double) maxRadiusCells * GalacticCoord.CELL;
double minRadiusBlocks = GalacticCoord.CELL;
+ double baseAngle = CellHash.norm(CellHash.ofBody(seed, anchor, index, SALT_BODYANG)) * 2d * Math.PI;
+ double baseRadius = minRadiusBlocks + PlanetDerivation.orbitFraction(orbit, star)
+ * Math.max(0d, maxRadiusBlocks - minRadiusBlocks);
+ double heightFraction = CellHash.norm(CellHash.ofBody(seed, anchor, index, SALT_BODYY)) - 0.5d;
- int count = 1 + (int) Math.floorMod(
- hash(seed, cell.sectorX(), cell.sectorY(), cell.sectorZ(), SALT_BODYCOUNT), MAX_PROC_PLANETS);
- for (int i = 0; i < count; i++) {
- double angle = norm(hashBody(seed, cell, i, SALT_BODYANG)) * 2d * Math.PI;
- double radius = minRadiusBlocks
- + norm(hashBody(seed, cell, i, SALT_BODYRAD)) * Math.max(0d, maxRadiusBlocks - minRadiusBlocks);
+ for (int attempt = 0; attempt < NUDGE_ATTEMPTS; attempt++) {
+ double angle = baseAngle + attempt * NUDGE_ANGLE;
+ // Radius is held for a full turn of the ring before it is allowed to grow, so a relocation
+ // costs the body its angle long before it costs it its place in the orbital order.
+ double radius = Math.min(maxRadiusBlocks, baseRadius * (1d + 0.06d * (attempt / 16)));
long lx = (long) (radius * Math.cos(angle));
long lz = (long) (radius * Math.sin(angle));
- long ly = (long) ((norm(hashBody(seed, cell, i, SALT_BODYY)) - 0.5d) * radius * PROC_DISK_FRACTION);
+ long ly = (long) (heightFraction * radius * PROC_DISK_FRACTION);
// The body's address is its OWN cell's centre (zone content sits near the cell centre — A#1a),
// box-clamped into the anchor's super-cell so member attribution stays exact at ANY minSpacing
// (at tiny spacings the floor above can otherwise push a body across the super-cell face).
- GalacticCoord addr = clampIntoSuperCell(cell.plusLocal(lx, ly, lz).cellCentre(), cell, s);
- // Roughly a third of systems' outermost body is an asteroid belt rather than a planet.
- SystemBodyKind kind = (i == count - 1 && norm(hashBody(seed, cell, i, SALT_BELT)) < 0.3d)
- ? SystemBodyKind.ASTEROID_BELT
- : SystemBodyKind.PLANET;
- // Procedural bodies have no realized dimension yet — a descent (Layer 2) realizes one.
- bodies.add(new SystemBody(addr, kind, Constants.INVALID_PLANET, starId));
+ GalacticCoord addr = clampIntoSuperCell(anchor.plusLocal(lx, ly, lz).cellCentre(), anchor, s);
+ if (taken.add(addr.cellKey())) {
+ return addr;
+ }
+ }
+ return null;
+ }
+
+ /** Append an asteroid belt at {@code orbit}, if the neighbourhood still has a cell for one. */
+ private static void addBelt(List bodies, long seed, GalacticCoord anchor, int orbit,
+ StellarBody star, long s, int starId, Set taken, int index) {
+ int clamped = Math.max(1, orbit);
+ GalacticCoord addr = placeBody(seed, anchor, index, clamped, star, s, taken);
+ if (addr != null) {
+ bodies.add(new SystemBody(addr, SystemBodyKind.ASTEROID_BELT, Constants.INVALID_PLANET,
+ starId, clamped));
}
- return bodies;
+ }
+
+ /**
+ * Append this body's moons. They share their parent's CELL by construction — a planet and its moons
+ * are one destination, which is the whole reason the one-real-body-per-cell invariant exempts them —
+ * and each carries its own live offset inside it.
+ *
+ * Their {@code orbitalDistance} is the PARENT's distance from the star, not their own distance
+ * from the parent: that field is what a moon's climate is derived from, and what warms a moon is
+ * where its planet is. How far the moon sits from the planet lives in its ephemeris, which is the
+ * thing that actually positions it.
+ */
+ private void addMoons(List bodies, long seed, GalacticCoord anchor, GalacticCoord parent,
+ int parentOrbit, StellarBody star, int starId, BodyProfile parentProfile) {
+ boolean giant = parentProfile.kind() == SystemBodyKind.GAS_GIANT;
+ int max = giant ? MAX_MOONS_GIANT : MAX_MOONS_ROCKY;
+ double u = CellHash.norm(CellHash.ofCell(seed, parent, SALT_MOONCOUNT));
+ int moons = (int) (Math.pow(u, MOON_COUNT_BIAS) * (max + 1));
+ if (moons > max) {
+ moons = max;
+ }
+ double parentGravity = Math.max(0.05d, parentProfile.gravityPercent() / 100d);
+ for (int j = 1; j <= moons; j++) {
+ int moonOrbit = MOON_MIN_ORBIT + (int) (CellHash.norm(
+ CellHash.ofBody(seed, parent, j, SALT_MOONRAD)) * MOON_ORBIT_SPAN);
+ double theta = CellHash.norm(CellHash.ofBody(seed, parent, j, SALT_MOONANG)) * 2d * Math.PI;
+ double periodTicks = AstronomicalBodyHelper.TICKS_PER_DAY
+ * AstronomicalBodyHelper.getMoonOrbitalPeriod(moonOrbit, (float) parentGravity);
+ BodyEphemeris law = BodyEphemeris.orbit(moonOrbit, theta, 0d, false, periodTicks,
+ SystemContent.MOON_UNIT_BLOCKS);
+ bodies.add(new SystemBody(parent, CellFrame.staticAt(parent), law, SystemBodyKind.MOON,
+ Constants.INVALID_PLANET, starId, parentOrbit));
+ }
+ }
+
+ /**
+ * The full derived profile of one of this generator's bodies — what realization materializes.
+ *
+ * Answerable for a body nobody has visited, because it is the same pure derivation the kind above
+ * came from. The body carries its own orbit, so this stays correct for a PINNED system whose layout
+ * the live generator would no longer reproduce.
+ */
+ public BodyProfile profileOf(long seed, GalacticCoord anchor, SystemBody body, StellarBody star,
+ int variant) {
+ return PlanetDerivation.derive(seed, anchor.cellCentre(), body.name(), variant, star,
+ body.kind() == SystemBodyKind.MOON, body.orbitalDistance());
}
@Override
@@ -211,22 +395,16 @@ private static long clampAxis(long sector, long anchorSector, long s, long margi
return sector > hi ? hi : sector;
}
- private static long hashBody(long seed, GalacticCoord cell, int i, long field) {
- // XOR the body index in with a DIFFERENT multiplier than hash() uses for the field salt, so the two
- // don't merge into (i + field)*G and correlate neighbouring bodies' draws.
- return hash(seed ^ (i * 0xD1B54A32D192ED03L), cell.sectorX(), cell.sectorY(), cell.sectorZ(), field);
- }
-
/** The single system a super-cell hosts (its cell coordinate + fabricated system), or empty. */
private Optional systemForSuperCell(long seed, long supX, long supY, long supZ) {
long cs = config.clusterScale;
// Void mask: a super-cell whose blob is below the void fraction hosts nothing.
- double blob = norm(hash(seed, Math.floorDiv(supX, cs), Math.floorDiv(supY, cs), Math.floorDiv(supZ, cs),
- SALT_BLOB));
+ double blob = CellHash.norm(CellHash.of(seed, Math.floorDiv(supX, cs), Math.floorDiv(supY, cs),
+ Math.floorDiv(supZ, cs), SALT_BLOB));
if (blob < config.voidFraction) {
return Optional.empty();
}
- if (norm(hash(seed, supX, supY, supZ, SALT_OCC)) >= config.density) {
+ if (CellHash.norm(CellHash.of(seed, supX, supY, supZ, SALT_OCC)) >= config.density) {
return Optional.empty();
}
long s = config.minSpacing;
@@ -235,17 +413,17 @@ private Optional systemForSuperCell(long seed, long supX, long supY,
// neighbouring super-cell (A#1a attribution guarantee).
long band = Math.max(1L, s / 4L);
long base = 3L * s / 8L;
- long ox = base + Math.floorMod(hash(seed, supX, supY, supZ, SALT_OX), band);
- long oy = base + Math.floorMod(hash(seed, supX, supY, supZ, SALT_OY), band);
- long oz = base + Math.floorMod(hash(seed, supX, supY, supZ, SALT_OZ), band);
+ long ox = base + Math.floorMod(CellHash.of(seed, supX, supY, supZ, SALT_OX), band);
+ long oy = base + Math.floorMod(CellHash.of(seed, supX, supY, supZ, SALT_OY), band);
+ long oz = base + Math.floorMod(CellHash.of(seed, supX, supY, supZ, SALT_OZ), band);
GalacticCoord cell = GalacticCoord.ofSectorLocal(supX * s + ox, supY * s + oy, supZ * s + oz,
0L, 0L, 0L);
return Optional.of(new Generated(cell, fabricate(seed, supX, supY, supZ)));
}
private StarSystem fabricate(long seed, long supX, long supY, long supZ) {
- GalaxyGenConfig.StarType type = pickType(hash(seed, supX, supY, supZ, SALT_TYPE));
- double sizeFrac = norm(hash(seed, supX, supY, supZ, SALT_SIZE));
+ GalaxyGenConfig.StarType type = pickType(CellHash.of(seed, supX, supY, supZ, SALT_TYPE));
+ double sizeFrac = CellHash.norm(CellHash.of(seed, supX, supY, supZ, SALT_SIZE));
StellarBody star = new StellarBody();
star.setTemperature(type.temperature);
@@ -269,27 +447,7 @@ private GalaxyGenConfig.StarType pickType(long h) {
}
private static int syntheticId(long seed, long supX, long supY, long supZ) {
- return -(1 + (int) Math.floorMod(hash(seed, supX, supY, supZ, SALT_ID), SYNTHETIC_ID_RANGE));
- }
-
- /** A splitmix-style mix of the seed, an integer coordinate triple, and a salt. Uniform over 64 bits. */
- private static long hash(long seed, long a, long b, long c, long salt) {
- long h = seed + salt * 0x9E3779B97F4A7C15L;
- h ^= a;
- h *= 0xFF51AFD7ED558CCDL;
- h ^= h >>> 33;
- h ^= b;
- h *= 0xC4CEB9FE1A85EC53L;
- h ^= h >>> 33;
- h ^= c;
- h *= 0xFF51AFD7ED558CCDL;
- h ^= h >>> 33;
- return h;
- }
-
- /** Map a 64-bit hash to a double in {@code [0, 1)}. */
- private static double norm(long h) {
- return (h >>> 11) * 0x1.0p-53;
+ return -(1 + (int) Math.floorMod(CellHash.of(seed, supX, supY, supZ, SALT_ID), SYNTHETIC_ID_RANGE));
}
private static final class Generated {
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/PlanetDerivation.java b/src/main/java/zmaster587/advancedRocketry/universe/PlanetDerivation.java
new file mode 100644
index 000000000..a0a97654b
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/universe/PlanetDerivation.java
@@ -0,0 +1,351 @@
+package zmaster587.advancedRocketry.universe;
+
+import zmaster587.advancedRocketry.api.dimension.solar.StellarBody;
+import zmaster587.advancedRocketry.dimension.DimensionProperties;
+import zmaster587.advancedRocketry.space.GalacticCoord;
+import zmaster587.advancedRocketry.util.AstronomicalBodyHelper;
+
+/**
+ * Where a procedural body's PHYSICS comes from, and therefore where its TYPE comes from.
+ *
+ * A pure function of {@code (seed, cell, body index)}: no world, no {@code Random}, no registry, no
+ * tick. Ask it twice and it answers the same, which is the whole point — a telescope reports a world
+ * from across the system, and the landing has to match what the telescope said.
+ *
+ * The order, and why it is this order
+ *
+ * - Metallicity — one more seeded property of the star beside temperature and size. A
+ * metal-poor star formed a metal-poor disk, which is what makes the ore profile physical rather
+ * than tabulated.
+ * - Orbital radius, drawn LOGARITHMICALLY: real systems are spaced roughly geometrically, and
+ * the range is anchored on the star's own {@linkplain #referenceDistance reference distance}, so
+ * zoning follows the star instead of a fixed table. A cool dwarf gets a compact system and a hot
+ * giant a sprawling one, for free.
+ * - Bare temperature at that radius, with NO atmosphere. The snow line is this temperature
+ * crossing a threshold — never a separate parameter.
+ * - Radius and mass, correlated with the zone: small rock inside, giants past the snow line.
+ * Gravity is DERIVED from them ({@code g = M/R²}); it is not drawn.
+ * - Pressure, from the world's ability to hold an atmosphere against its own heat — heavy and
+ * cold retains, light and hot does not.
+ * - Temperature again, now with that atmosphere. The greenhouse term needs a pressure, and
+ * the pressure needed a temperature; one pass each way resolves it without iterating, and the bare
+ * reading is kept for the zoning decisions that must not depend on the atmosphere.
+ * - Type = a weighted draw among the presets that admit the resulting point. Zoning
+ * therefore EMERGES from the physics; no preset is placed anywhere by hand.
+ * - Terrain from that type's weighted list, and finally the oxygen roll — biology on
+ * top of an already-suitable world, never a consequence of it.
+ *
+ *
+ * Every constant below is a balance knob. None is a contract, and the class deliberately exposes the
+ * intermediate steps so a test can pin the RELATIONS (colder past the snow line, heavier holds more air)
+ * without pinning any of the numbers.
+ */
+public final class PlanetDerivation {
+
+ // Salts, disjoint from ClusteredGalaxyGenerator's placement salts (0x1..0x15) and from each other.
+ private static final long SALT_METALLICITY = 0x21L;
+ private static final long SALT_ORBIT = 0x22L;
+ private static final long SALT_GIANT = 0x23L;
+ private static final long SALT_RADIUS = 0x24L;
+ private static final long SALT_DENSITY = 0x25L;
+ private static final long SALT_PRESSURE = 0x26L;
+ private static final long SALT_TYPE = 0x27L;
+ private static final long SALT_TERRAIN = 0x28L;
+ private static final long SALT_OXYGEN = 0x29L;
+ private static final long SALT_RINGS = 0x2AL;
+
+ /**
+ * The temperature, in Kelvin, that defines a star's REFERENCE distance — Earth's equilibrium
+ * temperature with no atmosphere. Every orbital radius is drawn as a multiple of the distance at
+ * which this star produces it, so "the warm zone" means the same thing around every star.
+ */
+ private static final double REFERENCE_TEMPERATURE_K = 255d;
+
+ /** Innermost / outermost drawn orbit, as multiples of {@link #referenceDistance}. */
+ private static final double INNER_ORBIT_FACTOR = 0.2d;
+ private static final double OUTER_ORBIT_FACTOR = 45d;
+
+ /**
+ * Bare temperature below which volatiles freeze out — the SNOW LINE, expressed as the threshold it
+ * really is. Numerically the {@code FRIGID} band's floor, and deliberately the same number: a world
+ * the game calls frigid and a world past the snow line must be the same world.
+ */
+ private static final int SNOW_LINE_K = 175;
+
+ /** Probability that a body past the snow line accreted into a giant rather than staying a rock. */
+ private static final double GIANT_CHANCE_OUTER = 0.34d;
+ /** The same, in the cool-but-not-frozen band just inside it. */
+ private static final double GIANT_CHANCE_COOL = 0.06d;
+ /** Bare temperature below which the cool-band giant chance applies at all. */
+ private static final int COOL_BAND_K = 260;
+
+ /** Giant radius range, in Earth radii (Neptune ~3.9, Jupiter ~11). */
+ private static final double GIANT_MIN_RADIUS = 3.0d;
+ private static final double GIANT_MAX_RADIUS = 11.0d;
+ /** Jupiter's mass in Earth masses, and the exponent that carries a smaller giant down from it. */
+ private static final double JUPITER_MASSES = 318d;
+ private static final double GIANT_MASS_EXPONENT = 2.3d;
+
+ /** Rocky radius draw: {@code MIN + u^BIAS · SPAN}, biased small so Earth-sized is the median. */
+ private static final double ROCK_MIN_RADIUS = 0.2d;
+ private static final double ROCK_RADIUS_SPAN = 2.3d;
+ private static final double ROCK_RADIUS_BIAS = 1.7d;
+ /** A moon is drawn from the same law with a smaller span — moons are small by construction. */
+ private static final double MOON_RADIUS_SPAN = 0.55d;
+
+ /** Bulk density relative to Earth's, and the exponent that makes big rocky worlds denser. */
+ private static final double MIN_DENSITY = 0.75d;
+ private static final double DENSITY_SPAN = 0.5d;
+ private static final double ROCK_MASS_EXPONENT = 3.7d;
+
+ /** Gravity floor in g — the same floor the legacy random generator has always used. */
+ private static final double MIN_GRAVITY_G = 0.05d;
+
+ /**
+ * Atmospheric retention. {@code (M/R)} is escape velocity squared in Earth units; dividing by the
+ * bare temperature gives the Jeans-parameter shape — heavy and cold holds air, light and hot loses
+ * it. Normalised so Earth sits at 1, then raised to a steep power because the real transition from
+ * airless to crushing happens over a narrow range of that ratio.
+ */
+ private static final double EARTH_RETENTION = 1d / (255d / 288d);
+ private static final double RETENTION_EXPONENT = 2.6d;
+ private static final double PRESSURE_SCATTER_MIN = 0.4d;
+ private static final double PRESSURE_SCATTER_SPAN = 2.6d;
+
+ /** Chance that a world whose type PERMITS oxygen actually has it. Biology, so: rare. */
+ private static final double OXYGEN_CHANCE = 0.18d;
+
+ /**
+ * Ring chance for a giant, and for everything else. Rings are the debris of a moon that came apart
+ * inside its planet's Roche limit, and only a giant's limit reaches far enough beyond its own body
+ * for that to be a place a moon could ever have been — which is why all four Solar giants have them
+ * and none of the rocky planets does.
+ */
+ private static final double RING_CHANCE_GIANT = 0.7d;
+ private static final double RING_CHANCE_ROCKY = 0.02d;
+
+ /**
+ * Tidal-locking radius at one solar radius, in AU. Beyond a scale factor this is the real
+ * astronomical embarrassment about M-dwarf habitability: the locking radius shrinks far more slowly
+ * with the star than the warm zone does, so a cool dwarf's habitable orbits sit WELL inside it and
+ * its temperate worlds are locked, while a sunlike star's are not.
+ */
+ private static final double TIDAL_LOCK_AU = 0.5d;
+
+ /** Metallicity draw, relative to Sol. */
+ private static final double MIN_METALLICITY = 0.35d;
+ private static final double METALLICITY_SPAN = 1.25d;
+ private static final double METALLICITY_BIAS = 1.3d;
+
+ private PlanetDerivation() {
+ }
+
+ // ─── The pieces, each answerable on its own ────────────────────────────────
+
+ /**
+ * The parent star's metal content relative to Sol. Keyed on the system's ANCHOR cell, not on the
+ * body, because it is a property of the star: every body of one system shares it.
+ */
+ public static double metallicityOf(long seed, GalacticCoord anchor) {
+ double u = CellHash.norm(CellHash.ofCell(seed, anchor.cellCentre(), SALT_METALLICITY));
+ return MIN_METALLICITY + Math.pow(u, METALLICITY_BIAS) * METALLICITY_SPAN;
+ }
+
+ /**
+ * The orbital distance, in Advanced Rocketry units, at which this star warms a bare world to
+ * {@link #REFERENCE_TEMPERATURE_K}. One AU for Sol by construction; a tenth of that for a cool red
+ * dwarf; a dozen AU for a hot blue giant.
+ */
+ public static int referenceDistance(StellarBody star) {
+ if (star == null) {
+ return AstronomicalBodyHelper.DISTANCE_UNITS_PER_AU;
+ }
+ // T falls as 1/sqrt(distance), so one probe at 1 AU fixes the whole curve.
+ int atOneAu = AstronomicalBodyHelper.getAverageTemperature(star,
+ AstronomicalBodyHelper.DISTANCE_UNITS_PER_AU, 0);
+ if (atOneAu <= 0) {
+ return AstronomicalBodyHelper.DISTANCE_UNITS_PER_AU;
+ }
+ double ratio = atOneAu / REFERENCE_TEMPERATURE_K;
+ double ref = AstronomicalBodyHelper.DISTANCE_UNITS_PER_AU * ratio * ratio;
+ return (int) clamp(ref, DimensionProperties.MIN_DISTANCE, 100_000d);
+ }
+
+ /**
+ * The orbital distance of body {@code index} of {@code count}, drawn log-uniformly across the
+ * star's zone.
+ *
+ * Each body owns a SLOT of the logarithmic range and is jittered inside it by less than half a
+ * slot, so the draw is irregular but the ordering is not: body {@code i} is always inside body
+ * {@code i+1}. That is what lets the placement map an orbit onto a cell radius monotonically, and it
+ * is why two bodies of one system cannot swap places when a tuning constant moves.
+ */
+ public static int orbitalDistanceOf(long seed, GalacticCoord anchor, int index, int count,
+ StellarBody star) {
+ double lo = innerOrbit(star);
+ double hi = outerOrbit(star);
+ int slots = Math.max(1, count);
+ double jitter = 0.6d * (CellHash.norm(CellHash.ofBody(seed, anchor.cellCentre(), index, SALT_ORBIT))
+ - 0.5d);
+ double f = (Math.min(index, slots - 1) + 0.5d + jitter) / slots;
+ double distance = lo * Math.pow(hi / lo, clamp(f, 0d, 1d));
+ return (int) clamp(distance, DimensionProperties.MIN_DISTANCE, 1_000_000d);
+ }
+
+ /** The innermost orbit this star's system may hold, in Advanced Rocketry distance units. */
+ public static double innerOrbit(StellarBody star) {
+ return Math.max(DimensionProperties.MIN_DISTANCE, referenceDistance(star) * INNER_ORBIT_FACTOR);
+ }
+
+ /** The outermost orbit this star's system may hold. Always comfortably above {@link #innerOrbit}. */
+ public static double outerOrbit(StellarBody star) {
+ return Math.max(innerOrbit(star) * 1.5d, referenceDistance(star) * OUTER_ORBIT_FACTOR);
+ }
+
+ /**
+ * Where {@code orbitalDistance} sits in this star's zone, as a fraction in {@code [0,1]} on a
+ * LOGARITHMIC scale — the inverse of the orbital draw.
+ *
+ * This is what lets the galactic placement map an orbit onto a cell radius: the two layouts then
+ * agree by construction, so a body that is third from its star is also third out from the anchor
+ * cell, and neither can be re-tuned without the other following.
+ */
+ public static double orbitFraction(int orbitalDistance, StellarBody star) {
+ double lo = innerOrbit(star);
+ double hi = outerOrbit(star);
+ if (!(hi > lo)) {
+ return 0d;
+ }
+ return clamp(Math.log(Math.max(lo, orbitalDistance) / lo) / Math.log(hi / lo), 0d, 1d);
+ }
+
+ /** The bare (no-atmosphere) equilibrium temperature at a distance — the zoning reading. */
+ public static int bareTemperature(StellarBody star, int orbitalDistance) {
+ return AstronomicalBodyHelper.getAverageTemperature(star, Math.max(1, orbitalDistance), 0);
+ }
+
+ /** Whether a body this close to this star keeps one face to it. */
+ public static boolean tidallyLockedAt(StellarBody star, int orbitalDistance) {
+ if (star == null) {
+ return false;
+ }
+ double lockDistance = AstronomicalBodyHelper.DISTANCE_UNITS_PER_AU * TIDAL_LOCK_AU
+ * Math.cbrt(Math.max(0.05d, star.getSize()));
+ return orbitalDistance <= lockDistance;
+ }
+
+ /** Whether the body at this index accreted into a giant, given how cold its orbit is. */
+ public static boolean isGiantAt(long seed, GalacticCoord anchor, int index, int bareTemperatureK) {
+ double chance = bareTemperatureK < SNOW_LINE_K ? GIANT_CHANCE_OUTER
+ : (bareTemperatureK < COOL_BAND_K ? GIANT_CHANCE_COOL : 0d);
+ if (chance <= 0d) {
+ return false;
+ }
+ return CellHash.norm(CellHash.ofBody(seed, anchor.cellCentre(), index, SALT_GIANT)) < chance;
+ }
+
+ // ─── The whole derivation ──────────────────────────────────────────────────
+
+ /**
+ * The full profile of a body, keyed on the cell it OCCUPIES rather than on its position in a list.
+ *
+ * That choice is what makes a profile survive a pin. A cell name is durable for the life of the
+ * save; a body's index in the generator's output is not — it moves the moment a tuning constant
+ * changes the body count, and every planet in the system would then be a different world than the
+ * one a player scanned. Metallicity is the deliberate exception: it is a property of the STAR, so it
+ * is keyed on the anchor and shared by every body of the system.
+ *
+ * @param variant disambiguates bodies that legitimately SHARE a cell — a planet is 0 and its
+ * moons are 1, 2, … Without it a moon would draw its parent's exact physics,
+ * because it draws from its parent's cell by construction
+ * @param moon a satellite: never a giant, and drawn from a smaller size law
+ * @param orbitalDistance where the body sits, in Advanced Rocketry distance units. A moon takes its
+ * PARENT's, because what a moon's climate depends on is where the parent is
+ */
+ public static BodyProfile derive(long seed, GalacticCoord anchor, GalacticCoord bodyCell, int variant,
+ StellarBody star, boolean moon, int orbitalDistance) {
+ GalacticCoord key = bodyCell.cellCentre();
+ double metallicity = metallicityOf(seed, anchor);
+ int bareTemp = bareTemperature(star, orbitalDistance);
+ boolean giant = !moon && isGiantAt(seed, key, variant, bareTemp);
+
+ double radius = radiusOf(seed, key, variant, giant, moon);
+ double mass = massOf(seed, key, variant, radius, giant);
+ int gravityPercent = gravityPercentOf(mass, radius);
+ int pressure = pressureOf(seed, key, variant, mass, radius, bareTemp, giant);
+ int temperature = AstronomicalBodyHelper.getAverageTemperature(star,
+ Math.max(1, orbitalDistance), pressure);
+
+ PlanetTypePreset preset = PlanetTypes.drawType(pressure, temperature, gravityPercent, giant,
+ CellHash.ofBody(seed, key, variant, SALT_TYPE));
+ TerrainOption terrain = PlanetTypes.drawTerrain(preset,
+ CellHash.ofBody(seed, key, variant, SALT_TERRAIN));
+
+ boolean oxygen = preset != null && preset.allowsOxygen()
+ && CellHash.norm(CellHash.ofBody(seed, key, variant, SALT_OXYGEN)) < OXYGEN_CHANCE;
+ boolean locked = (preset == null || preset.tidallyLockable()) && !giant
+ && tidallyLockedAt(star, orbitalDistance);
+ boolean rings = !moon
+ && CellHash.norm(CellHash.ofBody(seed, key, variant, SALT_RINGS))
+ < (giant ? RING_CHANCE_GIANT : RING_CHANCE_ROCKY);
+
+ SystemBodyKind kind = giant ? SystemBodyKind.GAS_GIANT
+ : (moon ? SystemBodyKind.MOON : SystemBodyKind.PLANET);
+ return new BodyProfile(kind, preset == null ? PlanetTypes.UNCLASSIFIED : preset.name(), preset,
+ orbitalDistance, mass, radius, gravityPercent, pressure, temperature, oxygen, locked,
+ rings, metallicity, terrain);
+ }
+
+ // ─── The individual laws ───────────────────────────────────────────────────
+
+ private static double radiusOf(long seed, GalacticCoord cell, int index, boolean giant, boolean moon) {
+ double u = CellHash.norm(CellHash.ofBody(seed, cell, index, SALT_RADIUS));
+ if (giant) {
+ return GIANT_MIN_RADIUS + u * (GIANT_MAX_RADIUS - GIANT_MIN_RADIUS);
+ }
+ double span = moon ? MOON_RADIUS_SPAN : ROCK_RADIUS_SPAN;
+ return ROCK_MIN_RADIUS + Math.pow(u, ROCK_RADIUS_BIAS) * span;
+ }
+
+ private static double massOf(long seed, GalacticCoord cell, int index, double radius, boolean giant) {
+ if (giant) {
+ return JUPITER_MASSES * Math.pow(radius / GIANT_MAX_RADIUS, GIANT_MASS_EXPONENT);
+ }
+ double density = MIN_DENSITY
+ + CellHash.norm(CellHash.ofBody(seed, cell, index, SALT_DENSITY)) * DENSITY_SPAN;
+ // M = ρ·R^3.7 rather than ρ·R³: a bigger rocky world compresses its own interior, which is what
+ // stops a super-Earth's surface gravity from running away with the cube of its radius.
+ return density * Math.pow(radius, ROCK_MASS_EXPONENT);
+ }
+
+ private static int gravityPercentOf(double mass, double radius) {
+ double g = mass / Math.max(1e-6d, radius * radius);
+ double clamped = clamp(g, MIN_GRAVITY_G, DimensionProperties.MAX_GRAVITY / 100d);
+ return (int) Math.round(clamped * 100d);
+ }
+
+ private static int pressureOf(long seed, GalacticCoord cell, int index, double mass, double radius,
+ int bareTemperatureK, boolean giant) {
+ if (giant) {
+ return DimensionProperties.MAX_ATM_PRESSURE;
+ }
+ double retention = (mass / Math.max(1e-6d, radius))
+ / Math.max(0.2d, bareTemperatureK / 288d);
+ double scatter = PRESSURE_SCATTER_MIN
+ + CellHash.norm(CellHash.ofBody(seed, cell, index, SALT_PRESSURE)) * PRESSURE_SCATTER_SPAN;
+ double raw = AstronomicalBodyHelper.ATM_PRESSURE_UNITS_PER_ATMOSPHERE
+ * Math.pow(retention / EARTH_RETENTION, RETENTION_EXPONENT) * scatter;
+ if (!(raw > 0d) || Double.isNaN(raw)) {
+ return DimensionProperties.MIN_ATM_PRESSURE;
+ }
+ return (int) clamp(Math.round(raw), DimensionProperties.MIN_ATM_PRESSURE,
+ DimensionProperties.MAX_ATM_PRESSURE);
+ }
+
+ private static double clamp(double v, double lo, double hi) {
+ if (Double.isNaN(v)) {
+ return lo;
+ }
+ return v < lo ? lo : (v > hi ? hi : v);
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/PlanetRealizer.java b/src/main/java/zmaster587/advancedRocketry/universe/PlanetRealizer.java
new file mode 100644
index 000000000..f737e6880
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/universe/PlanetRealizer.java
@@ -0,0 +1,260 @@
+package zmaster587.advancedRocketry.universe;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.OptionalInt;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import net.minecraft.block.Block;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.util.ResourceLocation;
+
+import zmaster587.advancedRocketry.api.Constants;
+import zmaster587.advancedRocketry.api.dimension.solar.StellarBody;
+import zmaster587.advancedRocketry.dimension.DimensionManager;
+import zmaster587.advancedRocketry.dimension.DimensionProperties;
+import zmaster587.advancedRocketry.space.GalacticCoord;
+import zmaster587.advancedRocketry.util.AstronomicalBodyHelper;
+import zmaster587.advancedRocketry.util.XMLPlanetLoader;
+
+/**
+ * The seam where a scanned dot becomes a world: turning a procedural {@link SystemBody} into a real
+ * dimension a ship can put down on.
+ *
+ * Without this the procedural galaxy is look-but-do-not-touch. Every body the generator places
+ * carries {@link Constants#INVALID_PLANET}, and {@code isDescendTarget()} is false for all of them, so a
+ * system full of planets has nowhere to land.
+ *
+ * The four rules this class exists to keep
+ *
+ * - A DESCENT realizes, and nothing else does. Scanning is cheap, remote and repeatable, and
+ * the tier schema answers a scan from the derivation on purpose — so minting on a scan would let
+ * one telescope sweep allocate dimensions by the dozen. Moons obey the same rule on their own
+ * account rather than being realized eagerly with a parent.
+ * - Realization MATERIALIZES what was already derived; it never rolls fresh values. Mass,
+ * atmosphere, temperature and water are promised to a telescope from across the system, so a
+ * landing that disagreed with the scan would make the whole tier schema a lie. This is why
+ * {@code generateRandom} cannot be reused here: it walks a shared {@code Random}, allocates an id
+ * immediately, and seeds a biome roll from {@code System.nanoTime()} — none of which can answer
+ * the same question twice.
+ * - After realization the SAVE is authoritative. The body is pinned, the dimension is
+ * registered and its properties are written down; a later seed, config, XML or modset change must
+ * not move or reshape a planet somebody has stood on.
+ * - A realized planet is never un-realized. There is no eviction path here on purpose. A long
+ * game accumulates dimensions in proportion to the planets a player has actually LANDED on, which
+ * is bounded by play rather than by the size of the galaxy — and rule 1 is what keeps that bound
+ * tight.
+ *
+ *
+ * Server main thread only.
+ */
+public final class PlanetRealizer {
+
+ private static final Logger LOGGER = LogManager.getLogger("AdvancedRocketry|Universe");
+
+ private PlanetRealizer() {
+ }
+
+ /**
+ * Realize the descend-target body standing in {@code bodyCell}, returning its dimension id — or
+ * {@link Constants#INVALID_PLANET} when that cell holds nothing anyone could land on.
+ *
+ * Idempotent. A cell whose body already has a world answers with that world; a second
+ * descent into the same cell therefore reuses the dimension instead of minting another. This is the
+ * only entry point, so that "one body, one world" cannot be true in one caller and false in
+ * another.
+ */
+ public static int realize(MinecraftServer server, GalacticCoord bodyCell) {
+ if (server == null || bodyCell == null) {
+ return Constants.INVALID_PLANET;
+ }
+ UniverseRegistry registry = UniverseRegistry.get(server);
+ if (registry == null) {
+ return Constants.INVALID_PLANET;
+ }
+
+ // Pin FIRST. A touch is what freezes a procedural system into the save, and by the time this
+ // body has a dimension its surroundings must already be unable to drift away from under it.
+ registry.pinSystem(bodyCell);
+
+ OptionalInt existing = registry.realizedDimAt(bodyCell);
+ if (existing.isPresent()) {
+ return existing.getAsInt();
+ }
+
+ Optional anchorOpt = registry.anchorForCell(bodyCell);
+ if (!anchorOpt.isPresent()) {
+ return Constants.INVALID_PLANET;
+ }
+ GalacticCoord anchor = anchorOpt.get();
+
+ List here = registry.bodiesAt(bodyCell);
+ SystemBody target = null;
+ int variant = 0;
+ int seen = 0;
+ for (SystemBody body : here) {
+ if (body.kind() == SystemBodyKind.STAR || body.kind() == SystemBodyKind.STATION_SLOT
+ || body.kind() == SystemBodyKind.ASTEROID_BELT) {
+ continue;
+ }
+ // The variant is a body's rank among the worlds SHARING this cell, and it must be counted
+ // exactly the way the generator assigned it — a planet is 0 and its moons follow — or a
+ // realized moon would materialize a different world than the one that was scanned.
+ if (target == null && body.kind().canDescend()
+ && body.dimId() == Constants.INVALID_PLANET) {
+ target = body;
+ variant = seen;
+ }
+ seen++;
+ }
+ if (target == null) {
+ return Constants.INVALID_PLANET;
+ }
+
+ Optional starOpt = registry.starAt(bodyCell);
+ if (!starOpt.isPresent()) {
+ LOGGER.warn("[UNIVERSE] cannot realize the body at {}: its system has no star", bodyCell.cellKey());
+ return Constants.INVALID_PLANET;
+ }
+ StellarBody star = starOpt.get();
+
+ // A procedural star keeps its SYNTHETIC NEGATIVE id — the pin already made that id a durable key
+ // in the save — but the catalogue has to learn about it, because a planet resolves its sun,
+ // its sky colour and its orbital period through the star list.
+ if (DimensionManager.getInstance().getStar(star.getId()) == null) {
+ DimensionManager.getInstance().addStar(star);
+ } else {
+ star = DimensionManager.getInstance().getStar(star.getId());
+ }
+
+ int dimId = DimensionManager.getInstance().getNextFreeDim(DimensionManager.dimOffset);
+ if (dimId == Constants.INVALID_PLANET) {
+ LOGGER.error("[UNIVERSE] no free dimension id left to realize the body at {}", bodyCell.cellKey());
+ return Constants.INVALID_PLANET;
+ }
+
+ BodyProfile profile = PlanetDerivation.derive(registry.worldSeed(), anchor, target.name(), variant,
+ star, target.kind() == SystemBodyKind.MOON, target.orbitalDistance());
+ DimensionProperties props = materialize(dimId, profile, star, anchor, target);
+
+ if (!DimensionManager.getInstance().registerDim(props, true)) {
+ LOGGER.error("[UNIVERSE] dimension {} was already registered while realizing {}", dimId,
+ bodyCell.cellKey());
+ return Constants.INVALID_PLANET;
+ }
+ star.addPlanet(props);
+ if (!registry.realizeBody(bodyCell, dimId)) {
+ LOGGER.error("[UNIVERSE] realized dimension {} for {} but the body could not be rewritten - "
+ + "the world exists and nothing points at it", dimId, bodyCell.cellKey());
+ return Constants.INVALID_PLANET;
+ }
+ LOGGER.info("[UNIVERSE] realized {} '{}' as dim {} at cell {} (type {}, {} K, {} atm-units, {}% g)",
+ profile.kind(), props.getName(), dimId, bodyCell.cellKey(), profile.typeName(),
+ profile.temperatureKelvin(), profile.pressure(), profile.gravityPercent());
+ return dimId;
+ }
+
+ /**
+ * Write a derived profile into a real {@link DimensionProperties}. Everything physical comes from
+ * the profile; everything cosmetic is derived from those same numbers, so nothing here consults a
+ * {@code Random}.
+ */
+ private static DimensionProperties materialize(int dimId, BodyProfile profile, StellarBody star,
+ GalacticCoord anchor, SystemBody body) {
+ DimensionProperties props = new DimensionProperties(dimId);
+ props.setName(star.getName() + " " + dimId);
+ props.setStar(star);
+
+ props.orbitalDist = Math.max(DimensionProperties.MIN_DISTANCE, profile.orbitalDistance());
+ // The orbital angle is READ OFF the body's cell rather than drawn again, so the planet the sky
+ // shows and the planet the orbital elements describe are in the same place.
+ props.baseOrbitTheta = angleOf(anchor, body.name());
+ props.orbitTheta = props.baseOrbitTheta;
+
+ props.setAtmosphereDensityDirect(profile.pressure());
+ props.averageTemperature = profile.temperatureKelvin();
+ props.hasOxygen = profile.hasOxygen();
+ props.setBulk(profile.massEarths(), profile.radiusEarths());
+ props.setTidallyLocked(profile.tidallyLocked());
+ props.setHasRings(profile.hasRings());
+ props.setMetallicity(profile.metallicity());
+ props.setGasGiant(profile.kind() == SystemBodyKind.GAS_GIANT);
+ props.rotationalPeriod = rotationalPeriodOf(profile, star);
+
+ applyTerrain(props, profile.terrain());
+
+ PlanetTypePreset preset = profile.preset();
+ if (preset != null) {
+ if (!preset.biomes().isEmpty()) {
+ XMLPlanetLoader.applyBiomeList(props, preset.biomes());
+ }
+ if (preset.seaLevel() != PlanetTypePreset.SEA_LEVEL_UNSET) {
+ props.setSeaLevel(preset.seaLevel());
+ }
+ if (!preset.oceanBlock().isEmpty()) {
+ Block block = Block.REGISTRY.getObject(new ResourceLocation(preset.oceanBlock()));
+ if (block != null) {
+ props.setOceanBlock(block.getDefaultState());
+ }
+ }
+ if (preset.oreProperties() != null) {
+ props.oreProperties = preset.oreProperties();
+ }
+ }
+ // No palette from the type: let the world derive one from its own climate, which is what an
+ // authored planet with no does.
+ if (props.getBiomes().isEmpty() && props.hasSurface()) {
+ props.addBiomes(props.getViableBiomes(true));
+ }
+ props.initDefaultAttributes();
+ return props;
+ }
+
+ private static void applyTerrain(DimensionProperties props, TerrainOption terrain) {
+ if (terrain == null) {
+ return;
+ }
+ // Fixed HERE and never re-derived: from this point the save owns how this world generates, so a
+ // pack that later adds or removes a world generator cannot reshape ground somebody has walked on.
+ props.setTerrainSource(terrain.source());
+ props.setTerrainWorldType(terrain.worldType());
+ props.setTerrainTemplate(terrain.template());
+ props.setTerrainGeneratorOptions(terrain.options());
+ props.setGenType(terrain.genType());
+ }
+
+ /**
+ * How long this world's day is. A locked world's rotation IS its orbit — that is what locking means
+ * — and every other world keeps the legacy gravity-derived period so procedural planets have the
+ * same spread of day lengths the game has always had.
+ */
+ private static int rotationalPeriodOf(BodyProfile profile, StellarBody star) {
+ if (profile.tidallyLocked()) {
+ double days = AstronomicalBodyHelper.getOrbitalPeriod(profile.orbitalDistance(), star.getSize());
+ double ticks = days * AstronomicalBodyHelper.TICKS_PER_DAY;
+ if (!(ticks > 0d) || ticks > Integer.MAX_VALUE) {
+ return Integer.MAX_VALUE;
+ }
+ return (int) ticks;
+ }
+ double gravity = Math.max(0.05d, profile.gravityPercent() / 100d);
+ double period = Math.pow(1d / gravity, 3) * DimensionProperties.DEFAULT_ROTATIONAL_PERIOD;
+ if (!(period > 0d) || period > Integer.MAX_VALUE) {
+ return DimensionProperties.DEFAULT_ROTATIONAL_PERIOD;
+ }
+ return Math.max(1, (int) period);
+ }
+
+ /** The angle of a body's cell about its system's anchor, in radians. */
+ private static double angleOf(GalacticCoord anchor, GalacticCoord bodyCell) {
+ long dx = bodyCell.sectorX() - anchor.sectorX();
+ long dz = bodyCell.sectorZ() - anchor.sectorZ();
+ if (dx == 0L && dz == 0L) {
+ return 0d;
+ }
+ double theta = Math.atan2((double) dz, (double) dx);
+ return theta < 0d ? theta + 2d * Math.PI : theta;
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/PlanetTypePreset.java b/src/main/java/zmaster587/advancedRocketry/universe/PlanetTypePreset.java
new file mode 100644
index 000000000..b2f01d19c
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/universe/PlanetTypePreset.java
@@ -0,0 +1,284 @@
+package zmaster587.advancedRocketry.universe;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import zmaster587.advancedRocketry.util.OreGenProperties;
+
+/**
+ * A planet type: the named region of physical parameter space a world can land in, together with
+ * everything that follows from being that kind of world.
+ *
+ * There is no second "subtype" concept — a type IS this preset. One language: it declares the
+ * property ranges that admit a world, the weighted list of ways its terrain may be generated, its ore
+ * table and its native biome palette. Advanced Rocketry ships a stock set ({@link PlanetTypes}); a pack
+ * overrides them or adds its own, and a genuinely new class of world needs no code.
+ *
+ * This object straddles the layer boundary on purpose, and the two halves are read by different
+ * layers. The UNIVERSE layer (a pure {@code (seed, cell)} derivation) reads only the numeric
+ * admission ranges, {@link #weight()}, {@link #gasGiant()}, {@link #allowsOxygen()} and the
+ * {@link #terrain()} weights — none of which touch a world, a registry or a block. The DIMENSION layer
+ * reads {@link #biomeIds()}, {@link #oreProperties()}, {@link #seaLevel()} and {@link #oceanBlock()}
+ * when it materializes a body into a real dimension. Nothing in the first list may be made to depend on
+ * the second, or the derivation stops being answerable from afar — which is the whole reason a scan can
+ * describe a world before anyone has been there.
+ *
+ * Immutable; built through {@link #builder(String)}.
+ */
+public final class PlanetTypePreset {
+
+ private final String name;
+ private final int weight;
+ private final int minPressure;
+ private final int maxPressure;
+ private final int minTemperature;
+ private final int maxTemperature;
+ private final int minGravity;
+ private final int maxGravity;
+ private final boolean gasGiant;
+ private final boolean allowsOxygen;
+ private final boolean tidallyLockable;
+ private final int seaLevel;
+ private final String oceanBlock;
+ private final List terrain;
+ private final String biomes;
+ private final OreGenProperties oreProperties;
+
+ private PlanetTypePreset(Builder b) {
+ this.name = b.name;
+ this.weight = Math.max(1, b.weight);
+ this.minPressure = Math.min(b.minPressure, b.maxPressure);
+ this.maxPressure = Math.max(b.minPressure, b.maxPressure);
+ this.minTemperature = Math.min(b.minTemperature, b.maxTemperature);
+ this.maxTemperature = Math.max(b.minTemperature, b.maxTemperature);
+ this.minGravity = Math.min(b.minGravity, b.maxGravity);
+ this.maxGravity = Math.max(b.minGravity, b.maxGravity);
+ this.gasGiant = b.gasGiant;
+ this.allowsOxygen = b.allowsOxygen;
+ this.tidallyLockable = b.tidallyLockable;
+ this.seaLevel = b.seaLevel;
+ this.oceanBlock = b.oceanBlock == null ? "" : b.oceanBlock;
+ this.terrain = b.terrain.isEmpty()
+ ? Collections.singletonList(TerrainOption.ofNative(0, 1))
+ : Collections.unmodifiableList(new ArrayList<>(b.terrain));
+ this.biomes = b.biomes == null ? "" : b.biomes.trim();
+ this.oreProperties = b.oreProperties;
+ }
+
+ /** The type's name — what a scan reports and what a pack overrides by. */
+ public String name() {
+ return name;
+ }
+
+ /** Relative frequency among the presets that ALSO admit a given world. Never zero. */
+ public int weight() {
+ return weight;
+ }
+
+ /** Atmospheric pressure bound, in {@code DimensionProperties} atmosphere-density units (100 = 1 atm). */
+ public int minPressure() {
+ return minPressure;
+ }
+
+ public int maxPressure() {
+ return maxPressure;
+ }
+
+ /** Surface temperature bound, in KELVIN — the unit {@code averageTemperature} is stored in. */
+ public int minTemperature() {
+ return minTemperature;
+ }
+
+ public int maxTemperature() {
+ return maxTemperature;
+ }
+
+ /** Surface gravity bound, in PERCENT of Earth's ({@code MIN_GRAVITY}/{@code MAX_GRAVITY} units). */
+ public int minGravity() {
+ return minGravity;
+ }
+
+ public int maxGravity() {
+ return maxGravity;
+ }
+
+ /** Whether this type describes a body with NO SURFACE — a giant, which is never landed on. */
+ public boolean gasGiant() {
+ return gasGiant;
+ }
+
+ /**
+ * Whether a world of this type may draw a breathable atmosphere at all. Oxygen is BIOLOGY, not
+ * physics: it is an independent rare roll over a world this flag permits, never a consequence of
+ * landing in the right pressure and temperature band.
+ */
+ public boolean allowsOxygen() {
+ return allowsOxygen;
+ }
+
+ /**
+ * Whether a world of this type can be tidally locked when it orbits close enough to be. A giant is
+ * excluded because nobody stands on one, so the permanent-day/permanent-night difficulty axis has
+ * nothing to act on.
+ */
+ public boolean tidallyLockable() {
+ return tidallyLockable;
+ }
+
+ /** Sea level for a realized world of this type, or {@link #SEA_LEVEL_UNSET} to keep the default. */
+ public int seaLevel() {
+ return seaLevel;
+ }
+
+ /** Registry name of the ocean fluid block, or empty for the default (water). */
+ public String oceanBlock() {
+ return oceanBlock;
+ }
+
+ /** Sentinel for {@link #seaLevel()}: this preset does not move the sea. */
+ public static final int SEA_LEVEL_UNSET = -1;
+
+ /** The weighted ways a world of this type may be generated. Never empty. */
+ public List terrain() {
+ return terrain;
+ }
+
+ /**
+ * This type's native biome palette, in the SAME authored form as a planet's {@code }
+ * element: a comma-separated list of {@code name;weight} or {@code id;weight} entries, empty for
+ * "let the world derive its own from its climate".
+ *
+ * It is kept as the raw authored string rather than resolved ids because a biome's numeric id is
+ * assigned at registration time and differs between modsets — and because one format with one
+ * parser means a preset and a planet can never disagree about what an entry means.
+ */
+ public String biomes() {
+ return biomes;
+ }
+
+ /** This type's ore table, or {@code null} to fall back to the climate matrix. */
+ public OreGenProperties oreProperties() {
+ return oreProperties;
+ }
+
+ /**
+ * Whether a world at {@code pressure} / {@code temperatureKelvin} / {@code gravityPercent} lands
+ * inside this type's declared region, and agrees with it about having a surface.
+ *
+ * Bounds are INCLUSIVE at both ends, so adjacent presets authored to touch ({@code max="175"}
+ * and {@code min="175"}) both admit the boundary rather than leaving a world with no type at all.
+ * Overlap is expected and resolved by a weighted draw — see {@link PlanetTypes}.
+ */
+ public boolean admits(int pressure, int temperatureKelvin, int gravityPercent, boolean isGasGiant) {
+ return isGasGiant == gasGiant
+ && pressure >= minPressure && pressure <= maxPressure
+ && temperatureKelvin >= minTemperature && temperatureKelvin <= maxTemperature
+ && gravityPercent >= minGravity && gravityPercent <= maxGravity;
+ }
+
+ public static Builder builder(String name) {
+ return new Builder(name);
+ }
+
+ @Override
+ public String toString() {
+ return "PlanetTypePreset[" + name + " w=" + weight + " p=" + minPressure + ".." + maxPressure
+ + " T=" + minTemperature + ".." + maxTemperature + " g=" + minGravity + ".." + maxGravity
+ + (gasGiant ? " giant" : "") + ']';
+ }
+
+ /** Mutable builder — the authored form, used by both the stock table and the XML reader. */
+ public static final class Builder {
+ private final String name;
+ private int weight = 10;
+ private int minPressure;
+ private int maxPressure = 1600;
+ private int minTemperature;
+ private int maxTemperature = 5000;
+ private int minGravity;
+ private int maxGravity = 400;
+ private boolean gasGiant;
+ private boolean allowsOxygen;
+ private boolean tidallyLockable = true;
+ private int seaLevel = SEA_LEVEL_UNSET;
+ private String oceanBlock = "";
+ private final List terrain = new ArrayList<>();
+ private String biomes = "";
+ private OreGenProperties oreProperties;
+
+ private Builder(String name) {
+ this.name = name == null ? "" : name.trim();
+ }
+
+ public Builder weight(int w) {
+ this.weight = w;
+ return this;
+ }
+
+ public Builder pressure(int min, int max) {
+ this.minPressure = min;
+ this.maxPressure = max;
+ return this;
+ }
+
+ public Builder temperature(int min, int max) {
+ this.minTemperature = min;
+ this.maxTemperature = max;
+ return this;
+ }
+
+ public Builder gravity(int min, int max) {
+ this.minGravity = min;
+ this.maxGravity = max;
+ return this;
+ }
+
+ public Builder gasGiant(boolean g) {
+ this.gasGiant = g;
+ return this;
+ }
+
+ public Builder allowsOxygen(boolean o) {
+ this.allowsOxygen = o;
+ return this;
+ }
+
+ public Builder tidallyLockable(boolean t) {
+ this.tidallyLockable = t;
+ return this;
+ }
+
+ public Builder seaLevel(int level) {
+ this.seaLevel = level;
+ return this;
+ }
+
+ public Builder oceanBlock(String registryName) {
+ this.oceanBlock = registryName;
+ return this;
+ }
+
+ public Builder terrain(TerrainOption option) {
+ if (option != null) {
+ this.terrain.add(option);
+ }
+ return this;
+ }
+
+ /** The raw {@code } palette string — see {@link PlanetTypePreset#biomes()}. */
+ public Builder biomes(String authoredList) {
+ this.biomes = authoredList;
+ return this;
+ }
+
+ public Builder ores(OreGenProperties ores) {
+ this.oreProperties = ores;
+ return this;
+ }
+
+ public PlanetTypePreset build() {
+ return new PlanetTypePreset(this);
+ }
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/PlanetTypes.java b/src/main/java/zmaster587/advancedRocketry/universe/PlanetTypes.java
new file mode 100644
index 000000000..09c03dfe8
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/universe/PlanetTypes.java
@@ -0,0 +1,310 @@
+package zmaster587.advancedRocketry.universe;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Predicate;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+/**
+ * The catalogue of {@link PlanetTypePreset planet types} and the two draws that use it: which type a
+ * derived world IS, and which of that type's terrain generators it gets.
+ *
+ * The stock set ships in CODE and is overridden wholesale by the {@code } elements of
+ * {@code planetDefs.xml}. XML is authoritative when present; the code answers when it is not, so a
+ * trimmed or broken config degrades to stock worlds instead of producing worlds with no type at all.
+ * Same shape as {@code }.
+ *
+ * Two rules that are not obvious from the signatures
+ *
+ * - Overlap is resolved by a WEIGHTED DRAW among every admitting preset, never by first
+ * match. First match would make the XML's document ORDER load-bearing — a silent dependency an
+ * author cannot see — and an explicit priority attribute would be a second ordering language for
+ * something weights already express. The consequence, which is tuning and not design: a preset
+ * with wide ranges soaks probability from narrow ones, so the stock ranges are authored tight.
+ * - The availability filter runs BEFORE the terrain draw, never after. An entry naming a
+ * {@code WorldType} this modset does not have is dropped and the remaining weights renormalize by
+ * themselves. Filtering after the draw would silently convert that entry's whole share into the
+ * fallback — so removing one mod would not merely remove its worlds, it would make some other
+ * kind of world commoner in exact proportion.
+ *
+ *
+ * Static state, server-side authored config — the same lifetime and the same reset points as the
+ * star catalogue it is loaded beside.
+ */
+public final class PlanetTypes {
+
+ // A self-contained logger rather than AdvancedRocketry.logger: loading the mod class triggers Forge
+ // bootstrap, which would break pure unit tests of the derivation this class feeds.
+ private static final Logger LOGGER = LogManager.getLogger("AdvancedRocketry|Universe");
+
+ /**
+ * The name reported for a world no preset admits. It is never drawn — it exists so that a hole in
+ * the authored coverage produces a world that is still landable and still describable, rather than
+ * a null type nothing downstream can render. Seeing it in a log means the preset table has a gap.
+ */
+ public static final String UNCLASSIFIED = "unclassified";
+
+ /**
+ * Whether a foreign {@code WorldType} of this name exists in the running modset. A seam, so the
+ * filter is unit-testable without a Minecraft registry; production resolves it against
+ * {@code WorldType.byName}.
+ */
+ private static volatile Predicate worldTypeAvailable = PlanetTypes::worldTypeIsRegistered;
+
+ private static volatile List presets = stockPresets();
+
+ private PlanetTypes() {
+ }
+
+ // ─── The catalogue ─────────────────────────────────────────────────────────
+
+ /** Every preset currently in force, in authored order. Never empty. */
+ public static List presets() {
+ return presets;
+ }
+
+ /** Install an authored table (the {@code } elements). An empty list restores stock. */
+ public static void setPresets(List authored) {
+ if (authored == null || authored.isEmpty()) {
+ presets = stockPresets();
+ return;
+ }
+ presets = Collections.unmodifiableList(new ArrayList<>(authored));
+ }
+
+ /** Restore the code-shipped table — the world-unload / config-reset path. */
+ public static void resetToStock() {
+ presets = stockPresets();
+ }
+
+ /** The preset of that name, or {@code null}. */
+ public static PlanetTypePreset byName(String name) {
+ if (name == null) {
+ return null;
+ }
+ for (PlanetTypePreset p : presets) {
+ if (p.name().equalsIgnoreCase(name)) {
+ return p;
+ }
+ }
+ return null;
+ }
+
+ /** Override the {@code WorldType}-availability probe (tests, or an addon with its own registry). */
+ public static void setWorldTypeAvailability(Predicate probe) {
+ worldTypeAvailable = probe == null ? PlanetTypes::worldTypeIsRegistered : probe;
+ }
+
+ // ─── The draws ─────────────────────────────────────────────────────────────
+
+ /** Every preset whose declared region admits this world. May be empty (an authoring gap). */
+ public static List candidates(int pressure, int temperatureKelvin,
+ int gravityPercent, boolean gasGiant) {
+ List out = new ArrayList<>();
+ for (PlanetTypePreset p : presets) {
+ if (p.admits(pressure, temperatureKelvin, gravityPercent, gasGiant)) {
+ out.add(p);
+ }
+ }
+ return out;
+ }
+
+ /**
+ * The type of a world at these parameters, drawn by weight among everything that admits it.
+ * {@code hash} is the derivation's own draw — the same {@code (seed, cell)} always lands on the
+ * same type.
+ *
+ * When nothing admits the world, the WIDEST admitting-by-temperature stock shape is not
+ * substituted and no preset is invented: the answer is {@code null}, and the caller reports the
+ * world as {@link #UNCLASSIFIED}. A silent substitution would hide the authoring gap forever.
+ */
+ public static PlanetTypePreset drawType(int pressure, int temperatureKelvin, int gravityPercent,
+ boolean gasGiant, long hash) {
+ List admitting = candidates(pressure, temperatureKelvin, gravityPercent, gasGiant);
+ if (admitting.isEmpty()) {
+ if (SystemContent.reportOnce("noPlanetType:" + gasGiant + ':' + pressure / 50 + ':'
+ + temperatureKelvin / 25 + ':' + gravityPercent / 25)) {
+ LOGGER.warn("no planet type admits a world at pressure {}, {} K, gravity {}% (gasGiant={})"
+ + " - it will be reported as '{}'. Widen a range to cover it.",
+ pressure, temperatureKelvin, gravityPercent, gasGiant, UNCLASSIFIED);
+ }
+ return null;
+ }
+ long total = 0L;
+ for (PlanetTypePreset p : admitting) {
+ total += p.weight();
+ }
+ long r = Math.floorMod(hash, Math.max(1L, total));
+ for (PlanetTypePreset p : admitting) {
+ if (r < p.weight()) {
+ return p;
+ }
+ r -= p.weight();
+ }
+ return admitting.get(admitting.size() - 1);
+ }
+
+ /**
+ * The terrain generator a world of type {@code preset} gets, drawn by weight over the entries this
+ * modset can actually run. Never {@code null}: a preset whose every entry names a missing mod falls
+ * back to Advanced Rocketry's own generator, which is the one thing always present.
+ */
+ public static TerrainOption drawTerrain(PlanetTypePreset preset, long hash) {
+ if (preset == null) {
+ return TerrainOption.ofNative(0, 1);
+ }
+ // D6: drop the unavailable entries FIRST, then draw over what is left.
+ List available = new ArrayList<>();
+ for (TerrainOption option : preset.terrain()) {
+ if (!option.needsForeignWorldType() || worldTypeAvailable.test(option.worldType())) {
+ available.add(option);
+ }
+ }
+ if (available.isEmpty()) {
+ if (SystemContent.reportOnce("noTerrain:" + preset.name())) {
+ LOGGER.warn("planet type '{}' has no runnable terrain source in this modset (every "
+ + " entry names a WorldType that is not registered) - falling back to the "
+ + "native generator.", preset.name());
+ }
+ return TerrainOption.ofNative(0, 1);
+ }
+ long total = 0L;
+ for (TerrainOption option : available) {
+ total += option.weight();
+ }
+ long r = Math.floorMod(hash, Math.max(1L, total));
+ for (TerrainOption option : available) {
+ if (r < option.weight()) {
+ return option;
+ }
+ r -= option.weight();
+ }
+ return available.get(available.size() - 1);
+ }
+
+ // ─── The stock table ───────────────────────────────────────────────────────
+
+ /**
+ * The code-shipped presets. Ranges are authored TIGHT and made to TOUCH rather than overlap
+ * broadly: a wide preset soaks probability from every narrow one it contains, so an "everything
+ * else" catch-all would quietly become the commonest world in the galaxy.
+ *
+ * Astronomy on the left of each comment, the Advanced Rocketry lever it is expressed through on
+ * the right. Every number here is a balance knob and none of them is a contract.
+ */
+ public static List stockPresets() {
+ List l = new ArrayList<>();
+
+ // The commonest body class of all — every airless moon, Mercury. Defined by having no air at
+ // all, which is why its pressure band is the tight one and its temperature band is not: an
+ // airless rock is as plausible baking beside its star as frozen far from it.
+ l.add(PlanetTypePreset.builder("barren").weight(30)
+ .pressure(0, 25).temperature(0, 1500).gravity(1, 90)
+ .biomes("advancedrocketry:moon;30,advancedrocketry:moondark;20")
+ .terrain(TerrainOption.ofNative(0, 1))
+ .build());
+
+ // Everything past the snow line, thin-aired or thick: Europa and Titan are the same class of
+ // world, and which of the two you get is how much nitrogen the gravity managed to keep.
+ l.add(PlanetTypePreset.builder("ice").weight(22)
+ .pressure(0, 1600).temperature(0, 200).gravity(1, 400)
+ .biomes("advancedrocketry:moondark;10,minecraft:ice_flats;30,minecraft:ice_mountains;20")
+ .terrain(TerrainOption.ofNative(0, 1))
+ .build());
+
+ // Tight inner orbits, common around M dwarfs. A molten surface under whatever the rock itself
+ // boiled off, which can be a great deal — hence no pressure ceiling.
+ l.add(PlanetTypePreset.builder("lava").weight(12)
+ .pressure(0, 1600).temperature(700, 6000).gravity(5, 400)
+ .biomes("advancedrocketry:volcanic;30,advancedrocketry:volcanicbarren;20,"
+ + "advancedrocketry:hotdryrock;10")
+ .terrain(TerrainOption.ofNative(0, 1))
+ .build());
+
+ // Venus-like, and likely common in the hot zone: a thick atmosphere doing the warming, which is
+ // why the band is keyed on the PRESSURE floor rather than on where the world orbits.
+ l.add(PlanetTypePreset.builder("greenhouse").weight(14)
+ .pressure(150, 1600).temperature(275, 1000).gravity(20, 400)
+ .biomes("advancedrocketry:hotdryrock;30,advancedrocketry:volcanicbarren;10")
+ .terrain(TerrainOption.ofNative(0, 1))
+ .build());
+
+ // The commonest planet class in the galaxy, and absent from the Solar System entirely. Defined
+ // by MASS, not by climate: a super-Earth is one whether it is frozen or baked.
+ l.add(PlanetTypePreset.builder("superearth").weight(16)
+ .pressure(0, 1600).temperature(0, 900).gravity(160, 400)
+ .biomes("advancedrocketry:stormland;30,advancedrocketry:hotdryrock;10")
+ .terrain(TerrainOption.ofNative(0, 1))
+ .build());
+
+ // A common end state of water loss: warm, dry, and holding just enough air to blow it around.
+ l.add(PlanetTypePreset.builder("desert").weight(16)
+ .pressure(0, 200).temperature(200, 700).gravity(10, 200)
+ .biomes("advancedrocketry:hotdryrock;30,minecraft:desert;20,minecraft:mesa;10")
+ .terrain(TerrainOption.ofNative(0, 1))
+ .build());
+
+ // Hypothesised but plausible: no exposed continent worth the name, and a deep global sea.
+ l.add(PlanetTypePreset.builder("ocean").weight(7).allowsOxygen(true)
+ .pressure(60, 400).temperature(255, 380).gravity(50, 190)
+ .seaLevel(96)
+ .biomes("advancedrocketry:oceanspires;30,minecraft:deep_ocean;30,minecraft:ocean;20")
+ .terrain(TerrainOption.ofNative(0, 1))
+ .build());
+
+ // Life without oxygen — the crystal / stormland / alien-forest biomes, all written and nearly
+ // unused today. Deliberately narrow: a find, not a background.
+ l.add(PlanetTypePreset.builder("exotic").weight(5)
+ .pressure(40, 1600).temperature(200, 430).gravity(10, 220)
+ .biomes("advancedrocketry:crystalchasms;30,advancedrocketry:stormland;20,"
+ + "advancedrocketry:alien_forest;10")
+ .terrain(TerrainOption.ofNative(0, 1))
+ .build());
+
+ // Very rare, and rare on purpose: the conjunction is physics, the oxygen on top is biology.
+ l.add(PlanetTypePreset.builder("earthlike").weight(3).allowsOxygen(true)
+ .pressure(50, 220).temperature(255, 325).gravity(60, 145)
+ .biomes("minecraft:plains;30,minecraft:forest;25,minecraft:extreme_hills;15,"
+ + "minecraft:ocean;15,advancedrocketry:marsh;10")
+ .terrain(TerrainOption.ofNative(0, 1))
+ .build());
+
+ // ~10-20% of stars. A real destination — fuel skimming and moons — but never a landing.
+ // Its bands are deliberately the widest in the table: a giant is a giant, and nothing else in
+ // this list will ever admit one, so a gap here would leave a whole body class untyped.
+ l.add(PlanetTypePreset.builder("gasgiant").weight(14).gasGiant(true).tidallyLockable(false)
+ .pressure(0, 1600).temperature(0, 1500).gravity(1, 400)
+ .terrain(TerrainOption.ofNative(0, 1))
+ .build());
+
+ // Neptune and Uranus: the same, further out and colder.
+ l.add(PlanetTypePreset.builder("icegiant").weight(9).gasGiant(true).tidallyLockable(false)
+ .pressure(0, 1600).temperature(0, 250).gravity(1, 300)
+ .terrain(TerrainOption.ofNative(0, 1))
+ .build());
+
+ return Collections.unmodifiableList(l);
+ }
+
+ /**
+ * Production availability probe. Kept out of the field initialiser so that a unit test which never
+ * declares a foreign generator never loads a Minecraft registry class.
+ */
+ private static boolean worldTypeIsRegistered(String name) {
+ if (name == null || name.trim().isEmpty()) {
+ return false;
+ }
+ try {
+ // The SAME resolver TerrainResolution uses when it actually installs the generator — a
+ // filter that admitted a name the installer then rejects would be worse than no filter.
+ return net.minecraft.world.WorldType.parseWorldType(name.trim()) != null;
+ } catch (Throwable t) {
+ // No registry in this context (a headless derivation) — treat the generator as absent
+ // rather than pretending it is there and handing a realized world a name nothing answers.
+ return false;
+ }
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/SystemBody.java b/src/main/java/zmaster587/advancedRocketry/universe/SystemBody.java
index 93c97e831..08586963d 100644
--- a/src/main/java/zmaster587/advancedRocketry/universe/SystemBody.java
+++ b/src/main/java/zmaster587/advancedRocketry/universe/SystemBody.java
@@ -39,12 +39,16 @@ public final class SystemBody {
/** No content may sit outside its own cell — a cell is a whole neighbourhood — so an offset is bounded. */
private static final long MAX_IN_CELL = GalacticCoord.HALF_CELL - 1L;
+ /** Sentinel for {@link #orbitalDistance()}: this body has no orbit of its own (a star, a POI). */
+ public static final int ORBIT_UNKNOWN = 0;
+
private final GalacticCoord name;
private final CellFrame frame;
private final BodyEphemeris offsetLaw;
private final SystemBodyKind kind;
private final int dimId;
private final int starId;
+ private final int orbitalDistance;
/**
* A body at rest in a STATIC frame — the reading for a POI, a fixture, or anything derived
@@ -52,13 +56,24 @@ public final class SystemBody {
* the (constant) in-cell offset.
*/
public SystemBody(GalacticCoord address, SystemBodyKind kind, int dimId, int starId) {
+ this(address, kind, dimId, starId, ORBIT_UNKNOWN);
+ }
+
+ /** The same, carrying the body's orbital radius — see {@link #orbitalDistance()}. */
+ public SystemBody(GalacticCoord address, SystemBodyKind kind, int dimId, int starId,
+ int orbitalDistance) {
this(requireAddress(address).cellCentre(), CellFrame.staticAt(address),
BodyEphemeris.fixed(address.localX(), address.localY(), address.localZ()),
- kind, dimId, starId);
+ kind, dimId, starId, orbitalDistance);
}
public SystemBody(GalacticCoord name, CellFrame frame, BodyEphemeris offsetLaw,
SystemBodyKind kind, int dimId, int starId) {
+ this(name, frame, offsetLaw, kind, dimId, starId, ORBIT_UNKNOWN);
+ }
+
+ public SystemBody(GalacticCoord name, CellFrame frame, BodyEphemeris offsetLaw,
+ SystemBodyKind kind, int dimId, int starId, int orbitalDistance) {
if (name == null) {
throw new NullPointerException("name");
}
@@ -71,6 +86,7 @@ public SystemBody(GalacticCoord name, CellFrame frame, BodyEphemeris offsetLaw,
this.kind = kind;
this.dimId = dimId;
this.starId = starId;
+ this.orbitalDistance = orbitalDistance;
}
private static GalacticCoord requireAddress(GalacticCoord address) {
@@ -138,6 +154,31 @@ public int starId() {
return starId;
}
+ /**
+ * How far this body orbits its primary, in Advanced Rocketry distance units (100 = 1 AU), or
+ * {@link #ORBIT_UNKNOWN} for a body with no orbit of its own.
+ *
+ * It travels WITH the body rather than being recomputed from the body's cell, because a cell is
+ * coarse — a whole neighbourhood — while the orbit is what every physical property of the world is
+ * derived from. Recovering it from the address would make a planet's temperature a function of the
+ * placement arithmetic, so a tuning change to the layout would silently re-climate every world in
+ * the galaxy.
+ */
+ public int orbitalDistance() {
+ return orbitalDistance;
+ }
+
+ /**
+ * This body with a realized dimension attached. Used exactly once per body, when a descent turns it
+ * from a scanned dot into a world; everything else about it — its name, its frame, its orbit — is
+ * carried over untouched, because realization materializes what was already derived and changes
+ * nothing about where the body is.
+ */
+ public SystemBody withDimId(int newDimId) {
+ return newDimId == dimId ? this
+ : new SystemBody(name, frame, offsetLaw, kind, newDimId, starId, orbitalDistance);
+ }
+
/** {@code true} iff this body can be descended into as a walkable dimension. */
public boolean isDescendTarget() {
return kind.canDescend() && dimId != Constants.INVALID_PLANET;
@@ -161,7 +202,7 @@ public boolean definesFrame() {
public SystemBody withFrame(CellFrame newFrame) {
return newFrame == null || newFrame.equals(frame)
? this
- : new SystemBody(name, newFrame, offsetLaw, kind, dimId, starId);
+ : new SystemBody(name, newFrame, offsetLaw, kind, dimId, starId, orbitalDistance);
}
public void writeToNBT(NBTTagCompound nbt) {
@@ -171,6 +212,9 @@ public void writeToNBT(NBTTagCompound nbt) {
nbt.setString("kind", kind.name());
nbt.setInteger("dimId", dimId);
nbt.setInteger("starId", starId);
+ if (orbitalDistance != ORBIT_UNKNOWN) {
+ nbt.setInteger("orbitalDist", orbitalDistance);
+ }
}
public static SystemBody readFromNBT(NBTTagCompound nbt) {
@@ -184,7 +228,8 @@ public static SystemBody readFromNBT(NBTTagCompound nbt) {
return new SystemBody(name, CellFrame.readFromNBT(nbt, name), BodyEphemeris.readFromNBT(nbt),
kind,
nbt.hasKey("dimId") ? nbt.getInteger("dimId") : Constants.INVALID_PLANET,
- nbt.getInteger("starId"));
+ nbt.getInteger("starId"),
+ nbt.getInteger("orbitalDist"));
}
@Override
@@ -197,6 +242,7 @@ public boolean equals(Object o) {
}
SystemBody other = (SystemBody) o;
return dimId == other.dimId && starId == other.starId && kind == other.kind
+ && orbitalDistance == other.orbitalDistance
&& name.equals(other.name) && offsetLaw.equals(other.offsetLaw)
&& frame.equals(other.frame);
}
@@ -207,6 +253,7 @@ public int hashCode() {
result = 31 * result + kind.hashCode();
result = 31 * result + dimId;
result = 31 * result + starId;
+ result = 31 * result + orbitalDistance;
result = 31 * result + offsetLaw.hashCode();
return result;
}
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/SystemContent.java b/src/main/java/zmaster587/advancedRocketry/universe/SystemContent.java
index 485d93337..074e7a822 100644
--- a/src/main/java/zmaster587/advancedRocketry/universe/SystemContent.java
+++ b/src/main/java/zmaster587/advancedRocketry/universe/SystemContent.java
@@ -150,8 +150,12 @@ public static List bodiesOf(StellarBody star, GalacticCoord systemCo
BodyEphemeris planetLaw = orbitLawOf(planet, star);
GalacticCoord planetName = nameOf(planet, planetLaw, anchor, minSpacingCells, starId, names);
CellFrame planetFrame = CellFrame.of(anchorAbs, planetLaw);
+ // The orbit travels on the body for authored systems too, so the field means the same thing
+ // for the whole catalogue: how far this body is from its star. A body that knew its orbit
+ // only when it was procedural would be a field that lies for half the galaxy.
bodies.add(new SystemBody(planetName, planetFrame, BodyEphemeris.STATIC,
- kindOf(planet, SystemBodyKind.PLANET), planet.getId(), starId));
+ kindOf(planet, SystemBodyKind.PLANET), planet.getId(), starId,
+ planet.getOrbitalDist()));
for (int moonId : planet.getChildPlanets()) {
DimensionProperties moon = DimensionManager.getInstance().getDimensionProperties(moonId);
@@ -160,8 +164,12 @@ public static List bodiesOf(StellarBody star, GalacticCoord systemCo
}
// A moon shares its parent's NAME and its parent's FRAME, and keeps its own live offset
// inside it: a planet-and-its-moons is one destination that moves as one.
+ // A moon carries its PARENT's distance from the star — what warms a moon is where its
+ // planet is; how far it sits from the planet is in its ephemeris, which is what
+ // positions it. Same convention as the procedural side.
bodies.add(new SystemBody(planetName, planetFrame, moonLawOf(moon, planet),
- kindOf(moon, SystemBodyKind.MOON), moon.getId(), starId));
+ kindOf(moon, SystemBodyKind.MOON), moon.getId(), starId,
+ planet.getOrbitalDist()));
}
}
auditOneRealBodyPerCell(bodies, starId);
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/TerrainOption.java b/src/main/java/zmaster587/advancedRocketry/universe/TerrainOption.java
new file mode 100644
index 000000000..8c79ec9a1
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/universe/TerrainOption.java
@@ -0,0 +1,129 @@
+package zmaster587.advancedRocketry.universe;
+
+import zmaster587.advancedRocketry.dimension.TerrainSource;
+
+/**
+ * One weighted entry of a {@link PlanetTypePreset}'s terrain list: a way this kind of world may be
+ * generated, plus how often it is chosen relative to the type's other entries.
+ *
+ * A third-party world generator is a FIRST-CLASS terrain source here, not a fallback. A foreign
+ * generator brings authored terrain logic, so its variety does not degrade with repetition the way one
+ * procedural pen does — which is why a preset declares a LIST rather than a single generator.
+ *
+ * The three shapes mirror {@link TerrainSource}: {@link TerrainSource#NATIVE} carries a
+ * {@link #genType()} (Advanced Rocketry's own sub-flavour selector), {@link TerrainSource#MOD_WORLDTYPE}
+ * a {@link #worldType()} name resolved against the live {@code WorldType} registry, and
+ * {@link TerrainSource#TEMPLATE} a {@link #template()} folder name. {@link #options()} is the
+ * per-dimension generator-settings string handed to whichever generator is drawn; empty means
+ * "your defaults".
+ *
+ * Immutable and free of world state: a draw over these is part of a pure derivation.
+ */
+public final class TerrainOption {
+
+ private final TerrainSource source;
+ private final String worldType;
+ private final String template;
+ private final int genType;
+ private final String options;
+ private final int weight;
+
+ public TerrainOption(TerrainSource source, String worldType, String template, int genType,
+ String options, int weight) {
+ this.source = source == null ? TerrainSource.NATIVE : source;
+ this.worldType = worldType == null ? "" : worldType.trim();
+ this.template = template == null ? "" : template.trim();
+ this.genType = Math.max(0, genType);
+ this.options = options == null ? "" : options;
+ // A zero or negative weight would silently drop the entry from every draw while still LOOKING
+ // authored; floor it at 1 so "present in the XML" and "reachable" mean the same thing.
+ this.weight = Math.max(1, weight);
+ }
+
+ /** Advanced Rocketry's own generator, sub-flavour {@code genType}. */
+ public static TerrainOption ofNative(int genType, int weight) {
+ return new TerrainOption(TerrainSource.NATIVE, "", "", genType, "", weight);
+ }
+
+ /** A foreign {@code WorldType}, resolved by name, with an optional generator-settings string. */
+ public static TerrainOption ofWorldType(String worldTypeName, String options, int weight) {
+ return new TerrainOption(TerrainSource.MOD_WORLDTYPE, worldTypeName, "", 0, options, weight);
+ }
+
+ /** Pre-generated region files loaded verbatim from {@code config/advRocketry/templates//}. */
+ public static TerrainOption ofTemplate(String templateName, int weight) {
+ return new TerrainOption(TerrainSource.TEMPLATE, "", templateName, 0, "", weight);
+ }
+
+ public TerrainSource source() {
+ return source;
+ }
+
+ public String worldType() {
+ return worldType;
+ }
+
+ public String template() {
+ return template;
+ }
+
+ public int genType() {
+ return genType;
+ }
+
+ public String options() {
+ return options;
+ }
+
+ public int weight() {
+ return weight;
+ }
+
+ /**
+ * Whether this entry names a generator supplied by another mod — the only kind that can be MISSING
+ * from a given modset, and therefore the only kind the availability filter has anything to say
+ * about.
+ */
+ public boolean needsForeignWorldType() {
+ return source == TerrainSource.MOD_WORLDTYPE && !worldType.isEmpty();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof TerrainOption)) {
+ return false;
+ }
+ TerrainOption other = (TerrainOption) o;
+ return source == other.source && genType == other.genType && weight == other.weight
+ && worldType.equals(other.worldType) && template.equals(other.template)
+ && options.equals(other.options);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = source.hashCode();
+ result = 31 * result + worldType.hashCode();
+ result = 31 * result + template.hashCode();
+ result = 31 * result + genType;
+ result = 31 * result + options.hashCode();
+ return 31 * result + weight;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder("TerrainOption[").append(source);
+ if (!worldType.isEmpty()) {
+ sb.append(' ').append(worldType);
+ }
+ if (!template.isEmpty()) {
+ sb.append(' ').append(template);
+ }
+ if (source == TerrainSource.NATIVE) {
+ sb.append(" genType=").append(genType);
+ }
+ return sb.append(" w=").append(weight).append(']').toString();
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/UniverseRegistry.java b/src/main/java/zmaster587/advancedRocketry/universe/UniverseRegistry.java
index bca782771..6d7c256ec 100644
--- a/src/main/java/zmaster587/advancedRocketry/universe/UniverseRegistry.java
+++ b/src/main/java/zmaster587/advancedRocketry/universe/UniverseRegistry.java
@@ -628,6 +628,84 @@ public boolean pinSystem(GalacticCoord coord) {
return true;
}
+ /**
+ * The star of the system whose neighbourhood contains {@code coord} — pinned snapshot, catalogue
+ * entry, or the generator's fabrication, in that order.
+ *
+ * The pin comes FIRST and that ordering is the point: a touched procedural system's star is
+ * frozen in the save, so a later seed or config edit cannot warm it up under the planets that were
+ * derived from it. Realization needs this to materialize a body's physics, and the star it uses must
+ * be the one the scan already described.
+ */
+ public Optional starAt(GalacticCoord coord) {
+ Optional anchorOpt = anchorForCell(coord);
+ if (!anchorOpt.isPresent()) {
+ return Optional.empty();
+ }
+ GalacticCoord anchor = anchorOpt.get();
+ PinnedSystem pinned = pinnedSystems.get(anchor.cellKey());
+ if (pinned != null) {
+ return Optional.of(pinned.toStar());
+ }
+ Integer id = byCell.get(anchor.cellKey());
+ if (id != null) {
+ return Optional.ofNullable(starLookup.apply(id));
+ }
+ Optional sys = generator.systemAt(worldSeed, anchor);
+ return sys.isPresent() ? Optional.of(sys.get().star()) : Optional.empty();
+ }
+
+ /**
+ * Attach a realized dimension to the pinned body standing at {@code bodyCell}, and record that
+ * cell as the dimension's durable NAME. Returns whether a body was rewritten.
+ *
+ * Only a PINNED system can be rewritten, and that is not a limitation but the mechanism: a body
+ * is pinned the moment anything touches it, so by the time a descent asks for a dimension the
+ * snapshot it is being written into already exists. Rewriting a derived body would be writing into
+ * a list that is regenerated on the next query.
+ *
+ * Idempotent by construction — a body that already carries this dimension is left exactly as it
+ * is, so a second descent into the same cell reuses the world rather than minting another.
+ */
+ public boolean realizeBody(GalacticCoord bodyCell, int dimId) {
+ Optional anchorOpt = anchorForCell(bodyCell);
+ if (!anchorOpt.isPresent()) {
+ return false;
+ }
+ PinnedSystem pinned = pinnedSystems.get(anchorOpt.get().cellKey());
+ if (pinned == null) {
+ return false;
+ }
+ GalacticCoord cell = bodyCell.cellCentre();
+ for (int i = 0; i < pinned.bodies.size(); i++) {
+ SystemBody body = pinned.bodies.get(i);
+ if (!body.kind().canDescend() || !body.name().sameCell(cell)) {
+ continue;
+ }
+ if (body.dimId() == dimId) {
+ return true;
+ }
+ if (body.dimId() != Constants.INVALID_PLANET) {
+ continue; // another body of this cell (a moon) already holds a world of its own
+ }
+ pinned.bodies.set(i, body.withDimId(dimId));
+ namesByDim.put(dimId, new RecordedName(cell, pinned.starId));
+ markDirty();
+ return true;
+ }
+ return false;
+ }
+
+ /** The realized dimension of the descend-target body at {@code bodyCell}, if it has one. */
+ public OptionalInt realizedDimAt(GalacticCoord bodyCell) {
+ for (SystemBody body : bodiesAt(bodyCell)) {
+ if (body.kind().canDescend() && body.dimId() != Constants.INVALID_PLANET) {
+ return OptionalInt.of(body.dimId());
+ }
+ }
+ return OptionalInt.empty();
+ }
+
/** The POIs at a system's cell (a copy), excluding the derived star/planet/moon bodies. */
public List poisAt(GalacticCoord systemCoord) {
List list = poiOverrides.get(systemCoord.cellCentre().cellKey());
diff --git a/src/main/java/zmaster587/advancedRocketry/util/OreGenProperties.java b/src/main/java/zmaster587/advancedRocketry/util/OreGenProperties.java
index 459ff8675..97238f50f 100644
--- a/src/main/java/zmaster587/advancedRocketry/util/OreGenProperties.java
+++ b/src/main/java/zmaster587/advancedRocketry/util/OreGenProperties.java
@@ -55,6 +55,70 @@ public List getOreEntries() {
return oreEntries;
}
+ /**
+ * A COPY of this table with the metallic entries scaled by {@code factor} — the parent star's metal
+ * content applied to the palette its planet's climate earned.
+ *
+ * A copy and never a mutation: a table from {@link #getOresForPressure} is shared by every world
+ * in that climate cell, so scaling it in place would give one planet's star the ore of all of them.
+ * Non-metallic entries (coal, redstone, lapis, diamond, emerald, quartz — and anything the ore
+ * dictionary does not call an ore at all) pass through untouched: a metal-poor disk yields the same
+ * SORTS of rock with less metal in them, which is what the physics actually says.
+ *
+ * Both the clump size and the per-chunk chance are scaled, so the effect is on how much metal a
+ * world holds rather than on where it hides; each stays at least 1 so a scaling can thin a deposit
+ * but never delete it.
+ */
+ public OreGenProperties withMetalsScaled(double factor) {
+ OreGenProperties copy = new OreGenProperties();
+ for (OreEntry e : oreEntries) {
+ boolean metal = isMetallic(e.getBlockState());
+ double f = metal ? Math.max(0.05d, factor) : 1d;
+ copy.addEntry(e.getBlockState(), e.getMinHeight(), e.getMaxHeight(),
+ scale(e.getClumpSize(), f), scale(e.getChancePerChunk(), f));
+ }
+ return copy;
+ }
+
+ private static int scale(int value, double factor) {
+ return Math.max(1, (int) Math.round(value * factor));
+ }
+
+ /** Ore-dictionary names that begin with {@code ore} but are not metals. */
+ private static final java.util.Set NON_METAL_ORES = new java.util.HashSet<>(
+ java.util.Arrays.asList("orecoal", "oreredstone", "orelapis", "orediamond", "oreemerald",
+ "orequartz", "oresulfur", "oresaltpeter", "orenitre", "oreapatite", "orecertusquartz",
+ "orecharcoal", "oreamber", "oreobsidian"));
+
+ /**
+ * Whether a block is a METAL ore, as far as the ore dictionary can say. Unknown blocks answer
+ * {@code false} — under-scaling leaves a world with the ore its climate gave it, while over-scaling
+ * would quietly rewrite a pack's non-metal deposits.
+ */
+ static boolean isMetallic(IBlockState state) {
+ if (state == null || state.getBlock() == null) {
+ return false;
+ }
+ try {
+ net.minecraft.item.ItemStack stack = new net.minecraft.item.ItemStack(state.getBlock(), 1,
+ state.getBlock().getMetaFromState(state));
+ for (int id : net.minecraftforge.oredict.OreDictionary.getOreIDs(stack)) {
+ String name = net.minecraftforge.oredict.OreDictionary.getOreName(id);
+ if (name == null) {
+ continue;
+ }
+ String lower = name.toLowerCase(java.util.Locale.ROOT);
+ if (lower.startsWith("ore") && !NON_METAL_ORES.contains(lower)) {
+ return true;
+ }
+ }
+ } catch (Throwable t) {
+ // No ore dictionary in this context (a headless derivation, or a block with no item form).
+ return false;
+ }
+ return false;
+ }
+
public static class OreEntry {
int minHeight;
int maxHeight;
diff --git a/src/main/java/zmaster587/advancedRocketry/util/XMLPlanetLoader.java b/src/main/java/zmaster587/advancedRocketry/util/XMLPlanetLoader.java
index 588da3818..6f3255c1c 100644
--- a/src/main/java/zmaster587/advancedRocketry/util/XMLPlanetLoader.java
+++ b/src/main/java/zmaster587/advancedRocketry/util/XMLPlanetLoader.java
@@ -28,6 +28,9 @@
import zmaster587.advancedRocketry.universe.ClusteredGalaxyGenerator;
import zmaster587.advancedRocketry.universe.GalaxyGenConfig;
import zmaster587.advancedRocketry.universe.IGalaxyGenerator;
+import zmaster587.advancedRocketry.universe.PlanetTypePreset;
+import zmaster587.advancedRocketry.universe.PlanetTypes;
+import zmaster587.advancedRocketry.universe.TerrainOption;
import zmaster587.advancedRocketry.universe.UniverseRegistry;
import net.minecraft.server.MinecraftServer;
import net.minecraftforge.fml.common.FMLCommonHandler;
@@ -61,6 +64,24 @@ public class XMLPlanetLoader {
// between authored anchors; absent -> authored anchors only. All attrs are balance knobs with defaults.
private static final String ELEMENT_GALAXYGEN = "galaxyGen";
private static final String ELEMENT_STARTYPE = "starType";
+ // A planet TYPE preset: the named region of parameter space a world can land in, plus everything
+ // that follows from being that kind of world. Present -> replaces the whole stock table.
+ private static final String ELEMENT_PLANETTYPE = "planetType";
+ private static final String ELEMENT_TYPE_PRESSURE = "pressure";
+ private static final String ELEMENT_TYPE_TEMPERATURE = "temperature";
+ private static final String ELEMENT_TYPE_GRAVITY = "gravity";
+ private static final String ELEMENT_TYPE_TERRAIN = "terrain";
+ private static final String ELEMENT_TYPE_GEN = "gen";
+ private static final String ATTR_MIN = "min";
+ private static final String ATTR_MAX = "max";
+ private static final String ATTR_SOURCE = "source";
+ private static final String ATTR_WORLDTYPE = "worldType";
+ private static final String ATTR_TEMPLATE_PATH = "path";
+ private static final String ATTR_GENTYPE = "genType";
+ private static final String ATTR_OPTIONS = "options";
+ private static final String ATTR_GASGIANT = "gasGiant";
+ private static final String ATTR_ALLOWS_OXYGEN = "allowsOxygen";
+ private static final String ATTR_TIDALLY_LOCKABLE = "tidallyLockable";
private static final String ATTR_DENSITY = "density";
private static final String ATTR_MINSPACING = "minSpacing";
private static final String ATTR_CLUSTERSCALE = "clusterScale";
@@ -92,6 +113,10 @@ public class XMLPlanetLoader {
private static final String ELEMENT_FOGCOLOR = "fogColor";
private static final String ELEMENT_SKYCOLOR = "skyColor";
private static final String ELEMENT_GRAVITY = "gravitationalMultiplier";
+ private static final String ELEMENT_MASS = "mass";
+ private static final String ELEMENT_RADIUS = "radius";
+ private static final String ELEMENT_TIDALLY_LOCKED = "tidallyLocked";
+ private static final String ELEMENT_METALLICITY = "metallicity";
private static final String ELEMENT_DISTANCE = "orbitalDistance";
private static final String ELEMENT_BASEORBITTHETA = "orbitalTheta";
private static final String ELEMENT_PHI = "orbitalPhi";
@@ -234,6 +259,208 @@ private GalaxyGenConfig readGalaxyGen(Node node) {
return new GalaxyGenConfig(density, minSpacing, clusterScale, voidFraction, types);
}
+ /**
+ * Parse one {@code } element into a preset.
+ *
+ * {@code
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * advancedrocketry:moondark;10,minecraft:ice_flats;30
+ * ...
+ *
+ * }
+ *
+ * Ranges are in the game's own units: pressure in atmosphere-density units (100 = 1 atm),
+ * temperature in KELVIN, gravity in percent of Earth's. Every attribute has a default, so a
+ * {@code } with nothing else is a valid (if very greedy) preset.
+ */
+ private PlanetTypePreset readPlanetType(Node node) {
+ String name = attr(node, ATTR_NAME);
+ PlanetTypePreset.Builder b = PlanetTypePreset.builder(name == null ? "" : name)
+ .weight(attrInt(node, ATTR_WEIGHT, 10))
+ .gasGiant(attrBool(node, ATTR_GASGIANT, false))
+ .allowsOxygen(attrBool(node, ATTR_ALLOWS_OXYGEN, false))
+ .tidallyLockable(attrBool(node, ATTR_TIDALLY_LOCKABLE, true));
+
+ NodeList children = node.getChildNodes();
+ for (int i = 0; i < children.getLength(); i++) {
+ Node child = children.item(i);
+ String tag = child.getNodeName();
+ if (ELEMENT_TYPE_PRESSURE.equalsIgnoreCase(tag)) {
+ b.pressure(attrInt(child, ATTR_MIN, DimensionProperties.MIN_ATM_PRESSURE),
+ attrInt(child, ATTR_MAX, DimensionProperties.MAX_ATM_PRESSURE));
+ } else if (ELEMENT_TYPE_TEMPERATURE.equalsIgnoreCase(tag)) {
+ b.temperature(attrInt(child, ATTR_MIN, 0), attrInt(child, ATTR_MAX, 5000));
+ } else if (ELEMENT_TYPE_GRAVITY.equalsIgnoreCase(tag)) {
+ b.gravity(attrInt(child, ATTR_MIN, DimensionProperties.MIN_GRAVITY),
+ attrInt(child, ATTR_MAX, DimensionProperties.MAX_GRAVITY));
+ } else if (ELEMENT_TYPE_TERRAIN.equalsIgnoreCase(tag)) {
+ NodeList gens = child.getChildNodes();
+ for (int j = 0; j < gens.getLength(); j++) {
+ Node gen = gens.item(j);
+ if (ELEMENT_TYPE_GEN.equalsIgnoreCase(gen.getNodeName())) {
+ b.terrain(new TerrainOption(
+ TerrainSource.byName(attr(gen, ATTR_SOURCE)),
+ attr(gen, ATTR_WORLDTYPE),
+ attr(gen, ATTR_TEMPLATE_PATH),
+ attrInt(gen, ATTR_GENTYPE, 0),
+ attr(gen, ATTR_OPTIONS),
+ attrInt(gen, ATTR_WEIGHT, 1)));
+ }
+ }
+ } else if (ELEMENT_BIOMEIDS.equalsIgnoreCase(tag)) {
+ b.biomes(child.getTextContent());
+ } else if (ELEMENT_OREGEN.equalsIgnoreCase(tag)) {
+ b.ores(XMLOreLoader.loadOre(child));
+ } else if (ELEMENT_SEALEVEL.equalsIgnoreCase(tag)) {
+ b.seaLevel(parseIntOr(child.getTextContent(), PlanetTypePreset.SEA_LEVEL_UNSET));
+ } else if (ELEMENT_OCEANBLOCK.equalsIgnoreCase(tag)) {
+ b.oceanBlock(child.getTextContent());
+ }
+ }
+ return b.build();
+ }
+
+ /**
+ * Apply an authored biome palette — the {@code } format — to a planet.
+ *
+ * Public and shared because a planet TYPE declares its palette in exactly the same language a
+ * planet does, and a realized procedural world has to mean by it precisely what an authored world
+ * means. Two parsers for one format is two chances for a pack's entry to work in one place and be
+ * ignored in the other.
+ *
+ * Format: comma-separated entries of {@code biome} or {@code biome;weight}, where {@code biome}
+ * is a registry name (preferred) or a raw numeric id (legacy, and modset-dependent). A malformed
+ * entry is warned about and skipped; it never aborts the rest of the list.
+ */
+ public static void applyBiomeList(DimensionProperties properties, String authoredList) {
+ if (properties == null || authoredList == null || authoredList.trim().isEmpty()) {
+ return;
+ }
+ for (String s : authoredList.split(",")) {
+ if (s.trim().isEmpty()) {
+ continue;
+ }
+ int biomeWeight = 30;
+ String[] weightSplit = s.trim().split(";");
+
+ //Try to get a weight out of the semicolon separator
+ if (weightSplit.length > 1) {
+ try {
+ biomeWeight = Integer.parseInt(weightSplit[1].trim());
+ if (biomeWeight == 0) {
+ AdvancedRocketry.logger.warn("Weight cannot be 0! Setting weight to default");
+ biomeWeight = 30;
+ }
+ } catch (NumberFormatException e) {
+ biomeWeight = 30;
+ AdvancedRocketry.logger.warn(weightSplit[1] + " is not a valid biome weight");
+ }
+ }
+
+ //Check whether we have numeric IDs (bad!) or RL ids
+ ResourceLocation location = new ResourceLocation(weightSplit[0]);
+ if (Biome.REGISTRY.containsKey(location)) {
+ Biome biome = Biome.REGISTRY.getObject(location);
+ if (biome == null)
+ AdvancedRocketry.logger.warn("Error adding " + weightSplit[0]); //TODO: more detailed error msg
+ else
+ properties.addBiomeWeighted(biome, biomeWeight);
+ } else {
+ try {
+ int biome = Integer.parseInt(weightSplit[0]);
+
+ if (!properties.addBiome(biome))
+ AdvancedRocketry.logger.warn(weightSplit[0] + " is not a valid biome id"); //TODO: more detailed error msg
+ } catch (NumberFormatException e) {
+ AdvancedRocketry.logger.warn(weightSplit[0] + " is not a valid biome id or name"); //TODO: more detailed error msg
+ }
+ }
+ }
+ }
+
+ private static boolean attrBool(Node node, String name, boolean def) {
+ String v = attr(node, name);
+ if (v == null || v.trim().isEmpty()) {
+ return def;
+ }
+ return Boolean.parseBoolean(v.trim());
+ }
+
+ private static int parseIntOr(String text, int def) {
+ if (text == null || text.trim().isEmpty()) {
+ return def;
+ }
+ try {
+ return Integer.parseInt(text.trim());
+ } catch (NumberFormatException e) {
+ return def;
+ }
+ }
+
+ /** Emit a preset so a re-read round-trips the authored table. */
+ private static Element writePlanetType(Document doc, PlanetTypePreset preset) {
+ Element e = doc.createElement(ELEMENT_PLANETTYPE);
+ e.setAttribute(ATTR_NAME, preset.name());
+ e.setAttribute(ATTR_WEIGHT, Integer.toString(preset.weight()));
+ if (preset.gasGiant()) {
+ e.setAttribute(ATTR_GASGIANT, "true");
+ }
+ if (preset.allowsOxygen()) {
+ e.setAttribute(ATTR_ALLOWS_OXYGEN, "true");
+ }
+ if (!preset.tidallyLockable()) {
+ e.setAttribute(ATTR_TIDALLY_LOCKABLE, "false");
+ }
+ e.appendChild(range(doc, ELEMENT_TYPE_PRESSURE, preset.minPressure(), preset.maxPressure()));
+ e.appendChild(range(doc, ELEMENT_TYPE_TEMPERATURE, preset.minTemperature(), preset.maxTemperature()));
+ e.appendChild(range(doc, ELEMENT_TYPE_GRAVITY, preset.minGravity(), preset.maxGravity()));
+ Element terrain = doc.createElement(ELEMENT_TYPE_TERRAIN);
+ for (TerrainOption option : preset.terrain()) {
+ Element gen = doc.createElement(ELEMENT_TYPE_GEN);
+ gen.setAttribute(ATTR_SOURCE, option.source().name());
+ if (!option.worldType().isEmpty()) {
+ gen.setAttribute(ATTR_WORLDTYPE, option.worldType());
+ }
+ if (!option.template().isEmpty()) {
+ gen.setAttribute(ATTR_TEMPLATE_PATH, option.template());
+ }
+ if (option.source() == TerrainSource.NATIVE) {
+ gen.setAttribute(ATTR_GENTYPE, Integer.toString(option.genType()));
+ }
+ if (!option.options().isEmpty()) {
+ gen.setAttribute(ATTR_OPTIONS, option.options());
+ }
+ gen.setAttribute(ATTR_WEIGHT, Integer.toString(option.weight()));
+ terrain.appendChild(gen);
+ }
+ e.appendChild(terrain);
+ if (!preset.biomes().isEmpty()) {
+ e.appendChild(createTextNode(doc, ELEMENT_BIOMEIDS, preset.biomes()));
+ }
+ if (preset.seaLevel() != PlanetTypePreset.SEA_LEVEL_UNSET) {
+ e.appendChild(createTextNode(doc, ELEMENT_SEALEVEL, Integer.toString(preset.seaLevel())));
+ }
+ if (!preset.oceanBlock().isEmpty()) {
+ e.appendChild(createTextNode(doc, ELEMENT_OCEANBLOCK, preset.oceanBlock()));
+ }
+ return e;
+ }
+
+ private static Element range(Document doc, String tag, int min, int max) {
+ Element e = doc.createElement(tag);
+ e.setAttribute(ATTR_MIN, Integer.toString(min));
+ e.setAttribute(ATTR_MAX, Integer.toString(max));
+ return e;
+ }
+
private static Element writeGalaxyGen(Document doc, GalaxyGenConfig cfg) {
Element e = doc.createElement(ELEMENT_GALAXYGEN);
e.setAttribute(ATTR_DENSITY, Double.toString(cfg.density));
@@ -305,6 +532,12 @@ public static String writeXML(IGalaxy galaxy) {
IGalaxyGenerator activeGenerator = UniverseRegistry.getGenerator();
if (activeGenerator instanceof ClusteredGalaxyGenerator) {
galaxyElement.appendChild(writeGalaxyGen(doc, ((ClusteredGalaxyGenerator) activeGenerator).config()));
+ // The planet-type table travels with the generator, and only with it: an authored-anchors-only
+ // world has nothing that draws a type, so writing the presets there would put a section into
+ // the file that nothing reads.
+ for (PlanetTypePreset preset : PlanetTypes.presets()) {
+ galaxyElement.appendChild(writePlanetType(doc, preset));
+ }
}
TransformerFactory transformerFactory = TransformerFactory.newInstance();
@@ -395,6 +628,19 @@ private static Node writePlanet(Document doc, DimensionProperties properties) {
nodePlanet.appendChild(createTextNode(doc, ELEMENT_FOGCOLOR, properties.fogColor[0] + "," + properties.fogColor[1] + "," + properties.fogColor[2]));
nodePlanet.appendChild(createTextNode(doc, ELEMENT_SKYCOLOR, properties.skyColor[0] + "," + properties.skyColor[1] + "," + properties.skyColor[2]));
nodePlanet.appendChild(createTextNode(doc, ELEMENT_GRAVITY, (int) (properties.getGravitationalMultiplier() * 100f)));
+ // Bulk properties are written only when the planet HAS them, so a catalogue that never stated a
+ // mass round-trips to the same file it came from.
+ if (properties.hasBulkProperties()) {
+ nodePlanet.appendChild(createTextNode(doc, ELEMENT_MASS, Double.toString(properties.getMass())));
+ nodePlanet.appendChild(createTextNode(doc, ELEMENT_RADIUS, Double.toString(properties.getRadius())));
+ }
+ if (properties.isTidallyLocked()) {
+ nodePlanet.appendChild(createTextNode(doc, ELEMENT_TIDALLY_LOCKED, "true"));
+ }
+ if (properties.getMetallicity() != 1d) {
+ nodePlanet.appendChild(createTextNode(doc, ELEMENT_METALLICITY,
+ Double.toString(properties.getMetallicity())));
+ }
nodePlanet.appendChild(createTextNode(doc, ELEMENT_DISTANCE, properties.getOrbitalDist()));
// Written as fractional degrees, not truncated to whole ones: these two angles are the only
// authored inputs a body's durable CELL NAME is derived from, and one degree at a large
@@ -779,9 +1025,36 @@ else if (planetPropertyNode.getNodeName().equalsIgnoreCase(ELEMENT_ATMDENSITY))
try {
properties.gravitationalMultiplier = Math.min(Math.max(Integer.parseInt(planetPropertyNode.getTextContent()), DimensionProperties.MIN_GRAVITY), DimensionProperties.MAX_GRAVITY) / 100f;
+ // Stating a gravity makes it an OVERRIDE: a planet that also declares a mass and a
+ // radius keeps the gravity its author wrote, so adding bulk properties to an
+ // existing planet cannot change how it plays.
+ properties.setGravityAuthored(true);
} catch (NumberFormatException e) {
AdvancedRocketry.logger.warn("Invalid gravitationalMultiplier specified"); //TODO: more detailed error msg
}
+ } else if (planetPropertyNode.getNodeName().equalsIgnoreCase(ELEMENT_MASS)) {
+ try {
+ properties.setBulk(Double.parseDouble(planetPropertyNode.getTextContent()),
+ properties.getRadius());
+ } catch (NumberFormatException e) {
+ AdvancedRocketry.logger.warn("Invalid mass specified for dimension " + properties.getId());
+ }
+ } else if (planetPropertyNode.getNodeName().equalsIgnoreCase(ELEMENT_RADIUS)) {
+ try {
+ properties.setBulk(properties.getMass(),
+ Double.parseDouble(planetPropertyNode.getTextContent()));
+ } catch (NumberFormatException e) {
+ AdvancedRocketry.logger.warn("Invalid radius specified for dimension " + properties.getId());
+ }
+ } else if (planetPropertyNode.getNodeName().equalsIgnoreCase(ELEMENT_TIDALLY_LOCKED)) {
+ properties.setTidallyLocked(Boolean.parseBoolean(planetPropertyNode.getTextContent()));
+ } else if (planetPropertyNode.getNodeName().equalsIgnoreCase(ELEMENT_METALLICITY)) {
+ try {
+ properties.setMetallicity(Double.parseDouble(planetPropertyNode.getTextContent()));
+ } catch (NumberFormatException e) {
+ AdvancedRocketry.logger.warn("Invalid metallicity specified for dimension "
+ + properties.getId());
+ }
} else if (planetPropertyNode.getNodeName().equalsIgnoreCase(ELEMENT_DISTANCE)) {
try {
@@ -839,46 +1112,7 @@ else if (planetPropertyNode.getNodeName().equalsIgnoreCase(ELEMENT_TARGETSEALEVE
else if (planetPropertyNode.getNodeName().equalsIgnoreCase(ELEMENT_RIVER_OVERRIDE))
properties.hasRivers = Boolean.parseBoolean(planetPropertyNode.getTextContent());
else if (planetPropertyNode.getNodeName().equalsIgnoreCase(ELEMENT_BIOMEIDS)) {
-
- String[] biomeList = planetPropertyNode.getTextContent().split(",");
- for (String s : biomeList) {
-
- int biomeWeight = 30;
- String[] weightSplit = s.split(";");
-
- //Try to get a weight out of the semicolon separator
- if (weightSplit.length > 1) {
- try {
- biomeWeight = Integer.parseInt(weightSplit[1]);
- if (biomeWeight == 0) {
- AdvancedRocketry.logger.warn("Weight cannot be 0! Setting weight to default");
- biomeWeight = 30;
- }
- } catch (NumberFormatException e) {
- biomeWeight = 30;
- AdvancedRocketry.logger.warn(weightSplit[1] + " is not a valid biome weight");
- }
- }
-
- //Check whether we have numeric IDs (bad!) or RL ids
- ResourceLocation location = new ResourceLocation(weightSplit[0]);
- if (Biome.REGISTRY.containsKey(location)) {
- Biome biome = Biome.REGISTRY.getObject(location);
- if (biome == null)
- AdvancedRocketry.logger.warn("Error adding " + weightSplit[0]); //TODO: more detailed error msg
- else
- properties.addBiomeWeighted(biome, biomeWeight);
- } else {
- try {
- int biome = Integer.parseInt(weightSplit[0]);
-
- if (!properties.addBiome(biome))
- AdvancedRocketry.logger.warn(weightSplit[0] + " is not a valid biome id"); //TODO: more detailed error msg
- } catch (NumberFormatException e) {
- AdvancedRocketry.logger.warn(weightSplit[0] + " is not a valid biome id or name"); //TODO: more detailed error msg
- }
- }
- }
+ applyBiomeList(properties, planetPropertyNode.getTextContent());
} else if (planetPropertyNode.getNodeName().equalsIgnoreCase(ELEMENT_CRATER_BIOMEIDS)) {
String[] biomeList = planetPropertyNode.getTextContent().split(",");
@@ -1322,6 +1556,11 @@ public DimensionPropertyCoupling readAllPlanets() {
masterNode = masterNode.getNextSibling();
continue;
}
+ if (masterNode.getNodeName().equalsIgnoreCase(ELEMENT_PLANETTYPE)) {
+ coupling.planetTypes.add(readPlanetType(masterNode));
+ masterNode = masterNode.getNextSibling();
+ continue;
+ }
if (!masterNode.getNodeName().equals("star")) {
masterNode = masterNode.getNextSibling();
continue;
@@ -1398,6 +1637,8 @@ public static class DimensionPropertyCoupling {
public Map anchorCoords = new HashMap<>();
// Procedural-galaxy generation config from an optional element; null = authored-only.
public GalaxyGenConfig galaxyGenConfig = null;
+ // Authored presets. Empty -> the stock table stands.
+ public List planetTypes = new ArrayList<>();
}
}
diff --git a/src/main/java/zmaster587/advancedRocketry/world/provider/WorldProviderPlanet.java b/src/main/java/zmaster587/advancedRocketry/world/provider/WorldProviderPlanet.java
index 290af794d..e917e6528 100644
--- a/src/main/java/zmaster587/advancedRocketry/world/provider/WorldProviderPlanet.java
+++ b/src/main/java/zmaster587/advancedRocketry/world/provider/WorldProviderPlanet.java
@@ -515,9 +515,24 @@ public double getHorizon() {
return 63;
}
+ /**
+ * Where a tidally-locked world's sun sits, permanently. Zero is noon in vanilla's angle convention
+ * ({@code (time % period) / period - 0.25} is zero at midday), so a locked world stands under a sun
+ * that never sets.
+ *
+ * One sky serves a whole dimension, so this expresses the half of tidal locking a per-dimension
+ * value CAN express — that there is no day/night cycle at all. The permanently-dark hemisphere and
+ * the temperate terminator strip between them are a property of WHERE you stand, which a single
+ * celestial angle has no way to say; they belong to the terrain and biome layer.
+ */
+ private static final float TIDALLY_LOCKED_CELESTIAL_ANGLE = 0f;
+
@Override
public float calculateCelestialAngle(long p_76563_1_, float p_76563_3_) {
int rotationalPeriod;
+ if (getDimensionProperties(new BlockPos(0, 0, 0)).isTidallyLocked()) {
+ return TIDALLY_LOCKED_CELESTIAL_ANGLE;
+ }
rotationalPeriod = getRotationalPeriod(new BlockPos(0, 0, 0));
diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/ProceduralPlanetRealizationE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/ProceduralPlanetRealizationE2ETest.java
new file mode 100644
index 000000000..a97ab29ce
--- /dev/null
+++ b/src/test/java/zmaster587/advancedRocketry/test/server/ProceduralPlanetRealizationE2ETest.java
@@ -0,0 +1,173 @@
+package zmaster587.advancedRocketry.test.server;
+
+import com.github.stannismod.forge.testing.junit.AbstractHeadlessServerTest;
+
+import org.junit.After;
+import org.junit.Test;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * A procedural planet becomes somewhere you can stand, and it is the planet the scan described.
+ *
+ * Before this batch the generator filled the galaxy with bodies carrying {@code INVALID_PLANET}, so
+ * {@code isDescendTarget()} was false for every one of them and a system full of planets had nowhere to
+ * land. This drives the real realization path on a real server and measures three things that are easy
+ * to claim and easy to get wrong:
+ *
+ *
+ * - The scan and the landing agree. Mass, atmosphere, temperature, gravity and water are
+ * promised to a telescope from across the system, so the world that is minted has to MATERIALIZE
+ * those numbers rather than roll fresh ones. The test compares the realized dimension against the
+ * derivation's own answer, read before anything was minted — never against a literal it wrote
+ * itself, which would pass just as well if both sides were wrong together.
+ * - Realization is idempotent. The trigger is a per-tick proximity check, so a second ask
+ * must reuse the world rather than mint another.
+ * - The world is real. It loads, it has ground, and the body now advertises itself as a
+ * descent target — the flag every downstream consumer reads.
+ *
+ *
+ * Per-method harness on purpose: this installs a procedural generator, which is a JVM-global, and a
+ * shared server would carry it into every class that ran after it.
+ */
+public class ProceduralPlanetRealizationE2ETest extends AbstractHeadlessServerTest {
+
+ /**
+ * A tiny super-cell spacing. A system's anchor is seated in the middle band of its super-cell, so at
+ * the production spacing of 512 the nearest system is hundreds of cells away and a bounded probe
+ * sweep finds an empty universe. The spacing is a balance knob, and nothing here asserts one.
+ */
+ private static final String GEN_INSTALL = "artest space gen-install 0.9 4 8 0.0 987654321";
+ private static final int SWEEP_RADIUS = 8;
+
+ private String exec(String cmd) throws Exception {
+ return String.join("\n", client().execute(cmd));
+ }
+
+ @After
+ public void restoreGenerator() throws Exception {
+ try {
+ exec("artest space gen-reset");
+ } catch (Exception ignored) {
+ }
+ }
+
+ @Test
+ public void aProceduralBodyBecomesTheWorldTheScanDescribed() throws Exception {
+ String installed = exec(GEN_INSTALL);
+ assertTrue("the procedural generator must install: " + installed,
+ installed.contains("\"ok\":true"));
+
+ String found = exec("artest space find-procedural " + SWEEP_RADIUS);
+ assertTrue("a dense procedural galaxy must offer a landable body: " + found,
+ found.contains("\"ok\":true"));
+ String cell = jsonInt(found, "sx") + " " + jsonInt(found, "sy") + " " + jsonInt(found, "sz");
+ assertTrue("the body must carry the orbit its physics is derived from: " + found,
+ jsonInt(found, "orbitalDist") > 0);
+
+ // CONTROL. Nothing in that cell is a descent target yet — which is the defect this whole path
+ // exists to fix, and without measuring it first "descendTarget is true afterwards" would be a
+ // statement about a flag that might always have been true.
+ String before = exec("artest space cell-info " + cell);
+ assertTrue("cell-info must answer: " + before, before.contains("\"ok\":true"));
+ assertFalse("no procedural body may be a descent target before it is realized: " + before,
+ before.contains("\"descendTarget\":true"));
+
+ // What the telescope would say, taken BEFORE anything is minted.
+ String scan = exec("artest space derived " + cell);
+ assertTrue("the derivation must answer for an unrealized body: " + scan,
+ scan.contains("\"ok\":true"));
+
+ String realized = exec("artest space realize " + cell);
+ assertTrue("realization must mint a world: " + realized, realized.contains("\"ok\":true"));
+ int dim = jsonInt(realized, "dim");
+ assertTrue("a realized dimension id must be real: " + realized, dim > 1);
+
+ // The whole contract, field by field. Terrain is deliberately absent from this list: its tier
+ // is APPROACH, not TELESCOPE, so the design lets it settle later — but it is compared anyway
+ // because the derivation is the single origin of every one of these.
+ assertEquals("orbital distance must be materialized, not re-rolled: scan " + scan
+ + " vs world " + realized, jsonInt(scan, "orbitalDist"), jsonInt(realized, "orbitalDist"));
+ assertEquals("gravity must match the scan: " + scan + " vs " + realized,
+ jsonInt(scan, "gravity"), jsonInt(realized, "gravity"));
+ assertEquals("atmospheric pressure must match the scan: " + scan + " vs " + realized,
+ jsonInt(scan, "pressure"), jsonInt(realized, "pressure"));
+ assertEquals("temperature must match the scan: " + scan + " vs " + realized,
+ jsonInt(scan, "temperature"), jsonInt(realized, "temperature"));
+ assertEquals("a breathable atmosphere must match the scan: " + scan + " vs " + realized,
+ jsonBool(scan, "oxygen"), jsonBool(realized, "oxygen"));
+ assertEquals("tidal locking must match the scan: " + scan + " vs " + realized,
+ jsonBool(scan, "locked"), jsonBool(realized, "locked"));
+ assertEquals("mass must match the scan: " + scan + " vs " + realized,
+ jsonDouble(scan, "mass"), jsonDouble(realized, "mass"), 1e-6d);
+ assertEquals("radius must match the scan: " + scan + " vs " + realized,
+ jsonDouble(scan, "radius"), jsonDouble(realized, "radius"), 1e-6d);
+ assertEquals("the star's metallicity must reach the world: " + scan + " vs " + realized,
+ jsonDouble(scan, "metallicity"), jsonDouble(realized, "metallicity"), 1e-6d);
+ assertEquals("the terrain source drawn for the type must be the one fixed on the world: "
+ + scan + " vs " + realized, jsonString(scan, "terrainSource"),
+ jsonString(realized, "terrainSource"));
+
+ // Gravity is DERIVED from the bulk properties, so the world must not merely carry a number that
+ // happens to match — the relation has to hold on the world itself.
+ double mass = jsonDouble(realized, "mass");
+ double radius = jsonDouble(realized, "radius");
+ assertTrue("a realized world must carry real bulk properties: " + realized,
+ mass > 0d && radius > 0d);
+ double expected = Math.max(0.05d, Math.min(4d, mass / (radius * radius)));
+ assertEquals("surface gravity must be M/R^2: " + realized,
+ expected * 100d, jsonInt(realized, "gravity"), 1.5d);
+
+ assertTrue("the body must now advertise itself as a descent target: " + realized,
+ realized.contains("\"descendTarget\":true"));
+ assertTrue("a procedural system keeps its synthetic negative star id: " + realized,
+ jsonInt(realized, "starId") < 0);
+
+ // Idempotency: the trigger is a per-tick proximity check, so asking again is the normal case.
+ String again = exec("artest space realize " + cell);
+ assertTrue("a second descent must succeed: " + again, again.contains("\"ok\":true"));
+ assertEquals("a second descent must REUSE the world, not mint another: " + again,
+ dim, jsonInt(again, "dim"));
+
+ // And the world is a world: it loads, and it has ground rather than a column of air.
+ String loaded = exec("artest dim time " + dim);
+ assertFalse("the realized dimension must load: " + loaded, loaded.contains("\"error\""));
+ String sample = exec("artest worldgen sample " + dim + " 0 0");
+ assertFalse("the realized world must generate terrain: " + sample, sample.contains("\"error\""));
+ assertNotEquals("a realized planet must have ground under its sky: " + sample,
+ "minecraft:air", jsonString(sample, "topBlock"));
+ }
+
+ // ─── tiny JSON readers (the probe surface is flat JSON on purpose) ─────────
+
+ private static int jsonInt(String json, String key) {
+ Matcher m = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*(-?\\d+)").matcher(json);
+ assertTrue("missing int '" + key + "' in " + json, m.find());
+ return Integer.parseInt(m.group(1));
+ }
+
+ private static double jsonDouble(String json, String key) {
+ Matcher m = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*(-?[\\d.eE+-]+)")
+ .matcher(json);
+ assertTrue("missing number '" + key + "' in " + json, m.find());
+ return Double.parseDouble(m.group(1));
+ }
+
+ private static boolean jsonBool(String json, String key) {
+ Matcher m = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*(true|false)").matcher(json);
+ assertTrue("missing boolean '" + key + "' in " + json, m.find());
+ return Boolean.parseBoolean(m.group(1));
+ }
+
+ private static String jsonString(String json, String key) {
+ Matcher m = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*\"([^\"]*)\"").matcher(json);
+ assertTrue("missing string '" + key + "' in " + json, m.find());
+ return m.group(1);
+ }
+}
diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/ClusteredGalaxyGeneratorTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/ClusteredGalaxyGeneratorTest.java
index 7fe11b02d..4dab7b93e 100644
--- a/src/test/java/zmaster587/advancedRocketry/test/unit/ClusteredGalaxyGeneratorTest.java
+++ b/src/test/java/zmaster587/advancedRocketry/test/unit/ClusteredGalaxyGeneratorTest.java
@@ -339,9 +339,15 @@ public void proceduralBodiesGetTheirOwnCellsInsideTheSuperCell() {
}
@Test
- public void tinySpacingDegeneratesConsistentlyIntoTheAnchorCell() {
- // minSpacing=1: the super-cell IS one cell, so every body clamps into the anchor cell — degenerate
- // but consistent (attribution still exact, nothing escapes the box).
+ public void tinySpacingDegeneratesIntoALoneStar() {
+ // minSpacing=1: the super-cell IS one cell, and the star already holds it. A second real body
+ // would have to share that cell, which at most one real body per cell forbids — so the system
+ // degenerates to its star alone. Degenerate but CONSISTENT: attribution stays exact, nothing
+ // escapes the box, and no cell ends up with two destinations in it.
+ //
+ // (Before the retinue gained a distinctness rule this read "every body clamps into the anchor
+ // cell", which was the same arrangement described from the other side — and describing it that
+ // way made the invariant violation sound like the intended behaviour.)
ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(
new GalaxyGenConfig(0.9d, 1, 8, 0.0d, null));
boolean checkedAny = false;
@@ -352,9 +358,10 @@ public void tinySpacingDegeneratesConsistentlyIntoTheAnchorCell() {
continue;
}
checkedAny = true;
- for (SystemBody body : gen.bodiesFor(SEED, c)) {
- assertTrue("with s=1 every body stays in the anchor cell", body.name().sameCell(c));
- }
+ List bodies = gen.bodiesFor(SEED, c);
+ assertEquals("a one-cell neighbourhood can host exactly one real body", 1, bodies.size());
+ assertEquals("and that body is the star", SystemBodyKind.STAR, bodies.get(0).kind());
+ assertTrue("which holds the anchor cell", bodies.get(0).name().sameCell(c));
}
assertTrue(checkedAny);
}
diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetDerivationTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetDerivationTest.java
new file mode 100644
index 000000000..491a93369
--- /dev/null
+++ b/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetDerivationTest.java
@@ -0,0 +1,508 @@
+package zmaster587.advancedRocketry.test.unit;
+
+import org.junit.After;
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import zmaster587.advancedRocketry.api.dimension.solar.StellarBody;
+import zmaster587.advancedRocketry.dimension.TerrainSource;
+import zmaster587.advancedRocketry.space.GalacticCoord;
+import zmaster587.advancedRocketry.universe.BodyProfile;
+import zmaster587.advancedRocketry.universe.PlanetDerivation;
+import zmaster587.advancedRocketry.universe.PlanetTypePreset;
+import zmaster587.advancedRocketry.universe.PlanetTypes;
+import zmaster587.advancedRocketry.universe.SystemBodyKind;
+import zmaster587.advancedRocketry.universe.TerrainOption;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Contract tests for the procedural planet derivation. Pure JUnit; no Minecraft bootstrap.
+ *
+ * What is pinned here is what the design PROMISES, never the numbers that happen to deliver it: that
+ * the same {@code (seed, cell)} answers the same world twice, that a world always satisfies the type it
+ * was given, that zoning follows temperature rather than a table, that gravity is derived from mass and
+ * radius, and that a terrain generator no installed mod provides is dropped BEFORE the draw rather than
+ * after. Every balance constant is exercised as an input and none is asserted as an expected value.
+ */
+public class PlanetDerivationTest {
+
+ private static final long SEED = 0xBEEF1234L;
+
+ @After
+ public void restoreGlobals() {
+ // Both are process-wide seams; a test that installs one must not leak it into the next class.
+ PlanetTypes.resetToStock();
+ PlanetTypes.setWorldTypeAvailability(null);
+ }
+
+ private static GalacticCoord cell(long sx, long sy, long sz) {
+ return GalacticCoord.ofSectorLocal(sx, sy, sz, 0L, 0L, 0L);
+ }
+
+ /** A star of the given archetype. Temperature is in Advanced Rocketry units: 100 = Sol. */
+ private static StellarBody star(int temperature, float size) {
+ StellarBody s = new StellarBody();
+ s.setTemperature(temperature);
+ s.setSize(size);
+ s.setId(-1);
+ s.setName("test");
+ return s;
+ }
+
+ private static StellarBody sol() {
+ return star(100, 1.0f);
+ }
+
+ /** Every body of one system, as the generator would lay it out. */
+ private static List system(long seed, GalacticCoord anchor, StellarBody s, int count) {
+ List out = new ArrayList<>();
+ for (int i = 0; i < count; i++) {
+ int orbit = PlanetDerivation.orbitalDistanceOf(seed, anchor, i, count, s);
+ // One cell per body, as the placement guarantees; the exact cell is the generator's business,
+ // so a distinct synthetic one is enough to key the per-body draws.
+ out.add(PlanetDerivation.derive(seed, anchor, cell(anchor.sectorX() + i + 1, 0, 0), 0, s,
+ false, orbit));
+ }
+ return out;
+ }
+
+ // ─── Determinism ───────────────────────────────────────────────────────────
+
+ @Test
+ public void theSameCellAlwaysDerivesTheSameWorld() {
+ StellarBody s = sol();
+ for (long x = -12; x <= 12; x++) {
+ GalacticCoord anchor = cell(x, 3, -1);
+ for (int i = 0; i < 6; i++) {
+ int orbit = PlanetDerivation.orbitalDistanceOf(SEED, anchor, i, 6, s);
+ BodyProfile a = PlanetDerivation.derive(SEED, anchor, cell(x, 3, i), 0, s, false, orbit);
+ BodyProfile b = PlanetDerivation.derive(SEED, anchor, cell(x, 3, i), 0, s, false, orbit);
+ assertEquals("type must be stable", a.typeName(), b.typeName());
+ assertEquals("terrain must be stable", a.terrain(), b.terrain());
+ assertEquals("mass must be stable", a.massEarths(), b.massEarths(), 0d);
+ assertEquals("radius must be stable", a.radiusEarths(), b.radiusEarths(), 0d);
+ assertEquals("pressure must be stable", a.pressure(), b.pressure());
+ assertEquals("temperature must be stable", a.temperatureKelvin(), b.temperatureKelvin());
+ assertEquals("oxygen must be stable", a.hasOxygen(), b.hasOxygen());
+ assertEquals("locking must be stable", a.tidallyLocked(), b.tidallyLocked());
+ }
+ }
+ }
+
+ @Test
+ public void aBodysWorldIsKeyedOnItsCellNotOnItsPositionInTheList() {
+ // The property that makes a profile survive a pin: a body keeps its world when the system's body
+ // COUNT changes under it, because the draw is keyed on the durable cell name and not on an index.
+ StellarBody s = sol();
+ GalacticCoord anchor = cell(4, 0, 0);
+ GalacticCoord body = cell(9, 1, 2);
+ BodyProfile inFive = PlanetDerivation.derive(SEED, anchor, body, 0, s, false, 140);
+ BodyProfile inTwelve = PlanetDerivation.derive(SEED, anchor, body, 0, s, false, 140);
+ assertEquals(inFive.typeName(), inTwelve.typeName());
+ assertEquals(inFive.massEarths(), inTwelve.massEarths(), 0d);
+ }
+
+ @Test
+ public void aMoonIsNotACopyOfThePlanetWhoseCellItShares() {
+ // A moon lives in its parent's cell by construction, so without the variant it would draw the
+ // parent's exact physics — the same mass, the same air, the same world twice.
+ StellarBody s = sol();
+ GalacticCoord anchor = cell(0, 0, 0);
+ GalacticCoord shared = cell(5, 0, 0);
+ BodyProfile planet = PlanetDerivation.derive(SEED, anchor, shared, 0, s, false, 100);
+ BodyProfile moon = PlanetDerivation.derive(SEED, anchor, shared, 1, s, true, 100);
+ assertFalse("a moon must not inherit its parent's exact bulk",
+ planet.massEarths() == moon.massEarths()
+ && planet.radiusEarths() == moon.radiusEarths());
+ assertEquals(SystemBodyKind.MOON, moon.kind());
+ assertTrue("a moon is never a giant", moon.radiusEarths() < 1.5d);
+ }
+
+ @Test
+ public void metallicityBelongsToTheStarAndIsSharedByEveryBodyOfItsSystem() {
+ GalacticCoord anchor = cell(7, -2, 5);
+ double first = PlanetDerivation.metallicityOf(SEED, anchor);
+ assertEquals(first, PlanetDerivation.metallicityOf(SEED, anchor), 0d);
+ assertTrue("metallicity must be a positive multiplier", first > 0d);
+ StellarBody s = sol();
+ for (BodyProfile p : system(SEED, anchor, s, 6)) {
+ assertEquals("every body of a system shares its star's metallicity", first, p.metallicity(), 0d);
+ }
+ // Different systems must not all be metal-average, or the axis does nothing.
+ Set seen = new HashSet<>();
+ for (long x = -30; x <= 30; x++) {
+ seen.add(PlanetDerivation.metallicityOf(SEED, cell(x, 0, 0)));
+ }
+ assertTrue("metallicity must genuinely vary between stars", seen.size() > 10);
+ }
+
+ // ─── The type a world gets ─────────────────────────────────────────────────
+
+ @Test
+ public void everyDerivedWorldSatisfiesTheTypeItWasGiven() {
+ // The admission ranges are the whole meaning of a type: a world outside its own preset's box
+ // would be a world whose scan describes something else.
+ int checked = 0;
+ for (long x = -20; x <= 20; x++) {
+ StellarBody s = starFor(x);
+ GalacticCoord anchor = cell(x, 0, 0);
+ for (BodyProfile p : system(SEED, anchor, s, 8)) {
+ assertNotNull("no world may be left without a type", p.preset());
+ assertTrue(p + " does not satisfy its own preset " + p.preset(),
+ p.preset().admits(p.pressure(), p.temperatureKelvin(), p.gravityPercent(),
+ p.kind() == SystemBodyKind.GAS_GIANT));
+ checked++;
+ }
+ }
+ assertTrue(checked > 300);
+ }
+
+ @Test
+ public void theStockTableLeavesNoWorldUnclassified() {
+ // A gap in the preset coverage is an authoring bug, and this is the only place it is visible:
+ // an unclassified world still lands and still renders, so nothing else would ever notice.
+ List uncovered = new ArrayList<>();
+ int total = 0;
+ for (long x = -25; x <= 25; x++) {
+ for (long z = -4; z <= 4; z++) {
+ StellarBody s = starFor(x + z);
+ GalacticCoord anchor = cell(x, 0, z);
+ for (BodyProfile p : system(SEED, anchor, s, 8)) {
+ total++;
+ if (PlanetTypes.UNCLASSIFIED.equals(p.typeName()) && uncovered.size() < 15) {
+ uncovered.add("p=" + p.pressure() + " T=" + p.temperatureKelvin() + " g="
+ + p.gravityPercent() + (p.kind() == SystemBodyKind.GAS_GIANT
+ ? " GIANT" : ""));
+ }
+ }
+ }
+ }
+ assertTrue("sample must be large", total > 2000);
+ // The message NAMES the gap: a bare count would say a hole exists without saying where, and the
+ // whole value of this test is that it hands the author the range to widen.
+ assertTrue("the stock presets must cover every world the derivation can produce; uncovered "
+ + "samples: " + uncovered, uncovered.isEmpty());
+ }
+
+ @Test
+ public void aWideRangeOfWorldsIsProduced() {
+ // The point of deriving a type rather than drawing one is variety that FOLLOWS the physics; a
+ // table that collapses onto one or two names would satisfy every other test here.
+ Set names = new HashSet<>();
+ for (long x = -25; x <= 25; x++) {
+ StellarBody s = starFor(x);
+ for (BodyProfile p : system(SEED, cell(x, 1, 1), s, 8)) {
+ names.add(p.typeName());
+ }
+ }
+ assertTrue("the derivation must produce many kinds of world, saw " + names,
+ names.size() >= 6);
+ }
+
+ // ─── Zoning emerges from the physics ───────────────────────────────────────
+
+ @Test
+ public void aFartherOrbitIsAlwaysColder() {
+ StellarBody s = sol();
+ int previous = Integer.MAX_VALUE;
+ for (int d = 10; d <= 4000; d += 10) {
+ int t = PlanetDerivation.bareTemperature(s, d);
+ assertTrue("temperature must never rise with distance (" + d + ")", t <= previous);
+ previous = t;
+ }
+ }
+
+ @Test
+ public void giantsFormInTheColdAndNeverInTheHeat() {
+ // The zoning claim, stated in physics rather than in the implementation's threshold: a world
+ // warm enough for liquid water on its surface did not accrete a gas envelope.
+ int giantsCold = 0;
+ int checkedHot = 0;
+ for (long x = -30; x <= 30; x++) {
+ StellarBody s = starFor(x);
+ GalacticCoord anchor = cell(x, 2, 0);
+ for (BodyProfile p : system(SEED, anchor, s, 8)) {
+ boolean giant = p.kind() == SystemBodyKind.GAS_GIANT;
+ int bare = PlanetDerivation.bareTemperature(s, p.orbitalDistance());
+ if (bare >= 273) {
+ checkedHot++;
+ assertFalse("a giant must not form above the freezing point of water: " + p, giant);
+ } else if (giant) {
+ giantsCold++;
+ }
+ }
+ }
+ assertTrue("the hot zone must actually be sampled", checkedHot > 100);
+ assertTrue("giants must actually form in the cold", giantsCold > 5);
+ }
+
+ @Test
+ public void aColdSystemsWarmZoneSitsCloserInThanAHotOnes() {
+ // The reference distance is what makes "the warm zone" mean the same thing around every star.
+ int coolDwarf = PlanetDerivation.referenceDistance(star(40, 0.6f));
+ int sunlike = PlanetDerivation.referenceDistance(sol());
+ int blueGiant = PlanetDerivation.referenceDistance(star(220, 2.6f));
+ assertTrue("a cool dwarf's warm zone must be inside a sunlike star's", coolDwarf < sunlike);
+ assertTrue("a hot star's warm zone must be outside a sunlike star's", blueGiant > sunlike);
+ }
+
+ // ─── Gravity is DERIVED, and mass/radius are primary ───────────────────────
+
+ @Test
+ public void gravityFollowsMassOverRadiusSquared() {
+ StellarBody s = sol();
+ for (long x = -20; x <= 20; x++) {
+ for (BodyProfile p : system(SEED, cell(x, 5, 5), s, 8)) {
+ double expected = p.massEarths() / (p.radiusEarths() * p.radiusEarths());
+ double clamped = Math.max(0.05d, Math.min(4d, expected));
+ assertEquals("gravity must be M/R^2 (clamped), not an independent draw",
+ clamped * 100d, p.gravityPercent(), 1.0d);
+ }
+ }
+ }
+
+ @Test
+ public void doublingMassDoublesGravityAndDoublingRadiusQuartersIt() {
+ assertEquals(2d * zmaster587.advancedRocketry.dimension.DimensionProperties.derivedGravity(1d, 1d),
+ zmaster587.advancedRocketry.dimension.DimensionProperties.derivedGravity(2d, 1d), 1e-9d);
+ assertEquals(zmaster587.advancedRocketry.dimension.DimensionProperties.derivedGravity(1d, 1d) / 4d,
+ zmaster587.advancedRocketry.dimension.DimensionProperties.derivedGravity(1d, 2d), 1e-9d);
+ }
+
+ // ─── Atmosphere retention ──────────────────────────────────────────────────
+
+ @Test
+ public void aHeavierWorldHoldsMoreAirThanALighterOneInTheSameOrbit() {
+ // Retention is the physical claim behind the pressure draw; the scatter must not be big enough
+ // to reverse it across a large sample, or "heavy worlds have thick air" is not a rule at all.
+ StellarBody s = sol();
+ double lightAverage = 0d;
+ double heavyAverage = 0d;
+ int light = 0;
+ int heavy = 0;
+ for (long x = -40; x <= 40; x++) {
+ for (BodyProfile p : system(SEED, cell(x, 9, 9), s, 8)) {
+ if (p.kind() == SystemBodyKind.GAS_GIANT) {
+ continue;
+ }
+ if (p.massEarths() < 0.3d) {
+ lightAverage += p.pressure();
+ light++;
+ } else if (p.massEarths() > 3d) {
+ heavyAverage += p.pressure();
+ heavy++;
+ }
+ }
+ }
+ assertTrue("both weight classes must be sampled", light > 20 && heavy > 20);
+ assertTrue("a heavy world must hold more air on average (" + (lightAverage / light) + " vs "
+ + (heavyAverage / heavy) + ")",
+ heavyAverage / heavy > lightAverage / light);
+ }
+
+ // ─── Oxygen is biology, on top of an already-suitable world ────────────────
+
+ @Test
+ public void oxygenOnlyAppearsOnTypesThatPermitItAndStaysRare() {
+ int oxygen = 0;
+ int total = 0;
+ for (long x = -40; x <= 40; x++) {
+ StellarBody s = starFor(x);
+ for (BodyProfile p : system(SEED, cell(x, 7, 0), s, 8)) {
+ total++;
+ if (p.hasOxygen()) {
+ oxygen++;
+ assertTrue("oxygen on a type that forbids it: " + p, p.preset().allowsOxygen());
+ }
+ }
+ }
+ assertTrue("sample must be large", total > 500);
+ assertTrue("a breathable world must stay rare, saw " + oxygen + "/" + total,
+ oxygen * 20 < total);
+ }
+
+ // ─── Tidal locking ─────────────────────────────────────────────────────────
+
+ @Test
+ public void aCloseOrbitIsLockedAndADistantOneIsNot() {
+ StellarBody s = sol();
+ assertTrue("a very close orbit must be locked", PlanetDerivation.tidallyLockedAt(s, 1));
+ assertFalse("a distant orbit must not be locked", PlanetDerivation.tidallyLockedAt(s, 100_000));
+ }
+
+ @Test
+ public void aCoolDwarfsWarmZoneLiesInsideItsLockingRadius() {
+ // The astronomical point of D4b, stated as the relation it rests on: around the commonest kind
+ // of star, the orbits that are warm enough to live in are also the ones that are locked — while
+ // around a sunlike star they are not.
+ StellarBody dwarf = star(40, 0.6f);
+ StellarBody sun = sol();
+ assertTrue("a red dwarf's warm zone must be tidally locked",
+ PlanetDerivation.tidallyLockedAt(dwarf, PlanetDerivation.referenceDistance(dwarf)));
+ assertFalse("a sunlike star's warm zone must not be",
+ PlanetDerivation.tidallyLockedAt(sun, PlanetDerivation.referenceDistance(sun)));
+ }
+
+ @Test
+ public void aGiantIsNeverReportedAsTidallyLocked() {
+ for (long x = -30; x <= 30; x++) {
+ StellarBody s = starFor(x);
+ for (BodyProfile p : system(SEED, cell(x, 11, 0), s, 8)) {
+ if (p.kind() == SystemBodyKind.GAS_GIANT) {
+ assertFalse("nobody stands on a giant, so locking it means nothing: " + p,
+ p.tidallyLocked());
+ }
+ }
+ }
+ }
+
+ // ─── Orbits ────────────────────────────────────────────────────────────────
+
+ @Test
+ public void orbitsAreOrderedAndSpreadLogarithmically() {
+ StellarBody s = sol();
+ GalacticCoord anchor = cell(2, 2, 2);
+ int count = 9;
+ int previous = 0;
+ List orbits = new ArrayList<>();
+ for (int i = 0; i < count; i++) {
+ int d = PlanetDerivation.orbitalDistanceOf(SEED, anchor, i, count, s);
+ assertTrue("body " + i + " must orbit outside body " + (i - 1) + " (" + previous + " -> "
+ + d + ")", d > previous);
+ previous = d;
+ orbits.add(d);
+ }
+ // Geometric spacing: the outer gaps must dwarf the inner ones, which uniform spacing never does.
+ int innerGap = orbits.get(1) - orbits.get(0);
+ int outerGap = orbits.get(count - 1) - orbits.get(count - 2);
+ assertTrue("spacing must widen outward (" + innerGap + " vs " + outerGap + ")",
+ outerGap > innerGap * 3);
+ }
+
+ @Test
+ public void theCellPlacementFractionAgreesWithTheOrbitItCameFrom() {
+ // The placement maps an orbit onto a cell radius through this fraction, so a body that is third
+ // from its star is third out from the anchor cell. If the two ever disagreed, the sky would show
+ // a system laid out differently from the one the physics describes.
+ StellarBody s = sol();
+ double previous = -1d;
+ for (int d = 1; d <= 20_000; d += 37) {
+ double f = PlanetDerivation.orbitFraction(d, s);
+ assertTrue("fraction must stay in [0,1]", f >= 0d && f <= 1d);
+ assertTrue("fraction must not decrease as the orbit grows", f >= previous);
+ previous = f;
+ }
+ }
+
+ // ─── D6: the availability filter runs BEFORE the draw ──────────────────────
+
+ @Test
+ public void anUnavailableWorldTypeIsDroppedAndItsWeightRedistributed() {
+ PlanetTypePreset preset = PlanetTypePreset.builder("t")
+ .terrain(TerrainOption.ofWorldType("MISSING", "", 97))
+ .terrain(TerrainOption.ofNative(3, 2))
+ .terrain(TerrainOption.ofTemplate("ruins", 1))
+ .build();
+ PlanetTypes.setWorldTypeAvailability(name -> false);
+
+ Map drawn = new HashMap<>();
+ for (int i = 0; i < 4000; i++) {
+ TerrainOption option = PlanetTypes.drawTerrain(preset, i * 0x9E3779B97F4A7C15L);
+ String key = option.source() + ":" + option.genType() + option.template();
+ drawn.merge(key, 1, Integer::sum);
+ }
+ assertFalse("a world type no mod provides must never be drawn",
+ drawn.containsKey(TerrainSource.MOD_WORLDTYPE + ":0"));
+ // The survivors keep their RATIO to each other (2:1). Converting the missing entry's share into
+ // the native fallback instead would swamp the template at roughly 99:1.
+ int nativeDraws = drawn.getOrDefault(TerrainSource.NATIVE + ":3", 0);
+ int templateDraws = drawn.getOrDefault(TerrainSource.TEMPLATE + ":0ruins", 0);
+ assertTrue("both survivors must be drawn", nativeDraws > 0 && templateDraws > 0);
+ double ratio = nativeDraws / (double) templateDraws;
+ assertTrue("weights must renormalize among the survivors, not collapse into the fallback "
+ + "(saw " + nativeDraws + ":" + templateDraws + ")",
+ ratio > 1.5d && ratio < 2.5d);
+ }
+
+ @Test
+ public void anAvailableWorldTypeIsDrawnNormally() {
+ PlanetTypePreset preset = PlanetTypePreset.builder("t")
+ .terrain(TerrainOption.ofWorldType("PRESENT", "opts", 99))
+ .terrain(TerrainOption.ofNative(0, 1))
+ .build();
+ PlanetTypes.setWorldTypeAvailability(name -> "PRESENT".equals(name));
+ int foreign = 0;
+ for (int i = 0; i < 500; i++) {
+ if (PlanetTypes.drawTerrain(preset, i * 0x9E3779B97F4A7C15L).source()
+ == TerrainSource.MOD_WORLDTYPE) {
+ foreign++;
+ }
+ }
+ assertTrue("an installed generator must dominate at weight 99:1, saw " + foreign + "/500",
+ foreign > 400);
+ }
+
+ @Test
+ public void aPresetWhoseEveryGeneratorIsMissingStillProducesATerrain() {
+ PlanetTypePreset preset = PlanetTypePreset.builder("t")
+ .terrain(TerrainOption.ofWorldType("A", "", 1))
+ .terrain(TerrainOption.ofWorldType("B", "", 1))
+ .build();
+ PlanetTypes.setWorldTypeAvailability(name -> false);
+ TerrainOption option = PlanetTypes.drawTerrain(preset, 12345L);
+ assertEquals("a world must still generate when its type's mods are all absent",
+ TerrainSource.NATIVE, option.source());
+ }
+
+ // ─── Type overlap is a weighted draw, not first match ──────────────────────
+
+ @Test
+ public void overlappingPresetsShareTheirProbabilityByWeight() {
+ List table = new ArrayList<>();
+ table.add(PlanetTypePreset.builder("common").weight(90)
+ .pressure(0, 1000).temperature(0, 1000).gravity(0, 400).build());
+ table.add(PlanetTypePreset.builder("rare").weight(10)
+ .pressure(0, 1000).temperature(0, 1000).gravity(0, 400).build());
+ PlanetTypes.setPresets(table);
+
+ Map counts = new HashMap<>();
+ for (int i = 0; i < 5000; i++) {
+ PlanetTypePreset p = PlanetTypes.drawType(100, 280, 100, false,
+ i * 0x9E3779B97F4A7C15L);
+ counts.merge(p.name(), 1, Integer::sum);
+ }
+ assertTrue("both overlapping presets must be reachable — first match would never draw the "
+ + "second: " + counts,
+ counts.getOrDefault("rare", 0) > 100);
+ assertTrue("the heavier preset must dominate: " + counts,
+ counts.getOrDefault("common", 0) > counts.getOrDefault("rare", 0) * 3);
+ }
+
+ @Test
+ public void aWorldNoPresetAdmitsIsReportedRatherThanSubstituted() {
+ List table = new ArrayList<>();
+ table.add(PlanetTypePreset.builder("narrow").weight(1)
+ .pressure(0, 10).temperature(0, 10).gravity(0, 10).build());
+ PlanetTypes.setPresets(table);
+ assertEquals("silently substituting a preset would hide the coverage gap for ever",
+ null, PlanetTypes.drawType(900, 900, 300, false, 1L));
+ }
+
+ /** A star archetype that varies across the sweep, so no test measures one kind of system only. */
+ private static StellarBody starFor(long x) {
+ int[] temps = {40, 70, 100, 150, 220};
+ float[] sizes = {0.6f, 0.9f, 1.1f, 1.4f, 2.2f};
+ int i = (int) Math.floorMod(x, temps.length);
+ return star(temps[i], sizes[i]);
+ }
+}
diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetRealizationTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetRealizationTest.java
new file mode 100644
index 000000000..fdec9910c
--- /dev/null
+++ b/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetRealizationTest.java
@@ -0,0 +1,248 @@
+package zmaster587.advancedRocketry.test.unit;
+
+import org.junit.After;
+import org.junit.Test;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.OptionalInt;
+
+import zmaster587.advancedRocketry.api.Constants;
+import zmaster587.advancedRocketry.api.dimension.solar.StellarBody;
+import zmaster587.advancedRocketry.space.GalacticCoord;
+import zmaster587.advancedRocketry.universe.ClusteredGalaxyGenerator;
+import zmaster587.advancedRocketry.universe.GalaxyGenConfig;
+import zmaster587.advancedRocketry.universe.SystemBody;
+import zmaster587.advancedRocketry.universe.SystemBodyKind;
+import zmaster587.advancedRocketry.universe.UniverseRegistry;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Contract tests for the registry half of realization — the half that decides whether a body has a
+ * world, and therefore the half that has to be idempotent.
+ *
+ * Minting the dimension itself needs a live server and is pinned by the server e2e. What is pinned
+ * HERE is the property that makes minting safe to drive from a per-tick proximity check: asking twice
+ * gives the same answer, and a body that already has a world is never handed a second one. If that ever
+ * stopped holding, a pilot hovering at the descent boundary would allocate a dimension per tick.
+ */
+public class PlanetRealizationTest {
+
+ private static final long SEED = 0x5EED5EEDL;
+
+ @After
+ public void resetSeams() {
+ UniverseRegistry.setGenerator(null);
+ UniverseRegistry.setStarLookup(null);
+ }
+
+ /**
+ * A dense, void-free galaxy so a small sweep is guaranteed to find systems. The spacing is
+ * deliberately tiny: a system's anchor is seated in the MIDDLE BAND of its super-cell, so at the
+ * production spacing of 512 the nearest anchor is hundreds of cells from the origin and a
+ * unit-test-sized sweep finds an empty universe.
+ */
+ private static UniverseRegistry registryWithProceduralGalaxy() {
+ UniverseRegistry reg = new UniverseRegistry();
+ UniverseRegistry.setGenerator(new ClusteredGalaxyGenerator(
+ new GalaxyGenConfig(0.9d, 4, 8, 0.0d, null)));
+ reg.bindWorldSeed(SEED);
+ return reg;
+ }
+
+ /** The first cell in a small sweep holding a body a ship could land on but that has no world. */
+ private static GalacticCoord findLandableCell(UniverseRegistry reg) {
+ for (long x = -8; x <= 8; x++) {
+ for (long y = -8; y <= 8; y++) {
+ for (long z = -8; z <= 8; z++) {
+ GalacticCoord cell = GalacticCoord.ofSectorLocal(x, y, z, 0L, 0L, 0L);
+ for (SystemBody b : reg.bodiesAt(cell)) {
+ if (b.kind().canDescend() && b.dimId() == Constants.INVALID_PLANET) {
+ return cell;
+ }
+ }
+ }
+ }
+ }
+ return null;
+ }
+
+ @Test
+ public void theProceduralGalaxyOffersLandableBodiesThatHaveNoWorldYet() {
+ // The precondition of everything below, and the defect the whole batch exists to fix: the
+ // generator places bodies a ship could stand on, and not one of them is a descent target.
+ UniverseRegistry reg = registryWithProceduralGalaxy();
+ GalacticCoord cell = findLandableCell(reg);
+ assertNotNull("a dense procedural galaxy must contain landable bodies", cell);
+ for (SystemBody b : reg.bodiesAt(cell)) {
+ if (b.kind().canDescend()) {
+ assertFalse("an unrealized body must not advertise itself as a descent target",
+ b.isDescendTarget());
+ }
+ }
+ }
+
+ @Test
+ public void realizingABodyMakesItADescentTargetAndRecordsItsCellName() {
+ UniverseRegistry reg = registryWithProceduralGalaxy();
+ GalacticCoord cell = findLandableCell(reg);
+ assertNotNull(cell);
+
+ assertTrue("touching a procedural system must pin it before anything is written into it",
+ reg.pinSystem(cell));
+ assertTrue("the pinned body must accept a dimension", reg.realizeBody(cell, 4242));
+
+ OptionalInt realized = reg.realizedDimAt(cell);
+ assertTrue("the cell must now report a realized world", realized.isPresent());
+ assertEquals(4242, realized.getAsInt());
+
+ boolean sawTarget = false;
+ for (SystemBody b : reg.bodiesAt(cell)) {
+ if (b.dimId() == 4242) {
+ assertTrue("a realized body must be a descent target", b.isDescendTarget());
+ sawTarget = true;
+ }
+ }
+ assertTrue(sawTarget);
+
+ assertEquals("the body's cell must be recorded as that dimension's durable name",
+ Optional.of(cell.cellCentre()), reg.recordedName(4242));
+ }
+
+ @Test
+ public void asecondDescentIntoTheSameCellReusesTheWorld() {
+ // The idempotency contract. The trigger is a per-tick proximity check, so "ask again" is the
+ // normal case, not an edge one — a pilot who hovers at the boundary must not mint a dimension
+ // per tick.
+ UniverseRegistry reg = registryWithProceduralGalaxy();
+ GalacticCoord cell = findLandableCell(reg);
+ assertNotNull(cell);
+ reg.pinSystem(cell);
+ assertTrue(reg.realizeBody(cell, 777));
+
+ assertEquals("asking again must answer the SAME world", 777,
+ reg.realizedDimAt(cell).getAsInt());
+ assertTrue("re-realizing with the same id is a no-op, not a failure",
+ reg.realizeBody(cell, 777));
+ assertEquals(777, reg.realizedDimAt(cell).getAsInt());
+ }
+
+ @Test
+ public void aBodyThatAlreadyHasAWorldRefusesASecondOne() {
+ UniverseRegistry reg = registryWithProceduralGalaxy();
+ GalacticCoord cell = findLandableCell(reg);
+ assertNotNull(cell);
+ reg.pinSystem(cell);
+ assertTrue(reg.realizeBody(cell, 100));
+
+ assertFalse("a body must never be re-pointed at a different world", reg.realizeBody(cell, 200));
+ assertEquals("and it must still hold the first one", 100, reg.realizedDimAt(cell).getAsInt());
+ }
+
+ @Test
+ public void anUnpinnedSystemCannotBeRealizedIntoAtAll() {
+ // Not a limitation but the mechanism: a derived body list is regenerated on the next query, so
+ // writing a dimension into one would be writing into a value that is about to be thrown away.
+ UniverseRegistry reg = registryWithProceduralGalaxy();
+ GalacticCoord cell = findLandableCell(reg);
+ assertNotNull(cell);
+ assertFalse("an unpinned system must refuse the rewrite rather than lose it silently",
+ reg.realizeBody(cell, 55));
+ assertFalse(reg.realizedDimAt(cell).isPresent());
+ }
+
+ @Test
+ public void aPinnedSystemsStarSurvivesAChangeOfGenerator() {
+ // Realization derives a body's physics from its STAR, so the star a landing uses has to be the
+ // one the scan described — even after a config edit that would have fabricated a different one.
+ UniverseRegistry reg = registryWithProceduralGalaxy();
+ GalacticCoord cell = findLandableCell(reg);
+ assertNotNull(cell);
+ reg.pinSystem(cell);
+
+ Optional before = reg.starAt(cell);
+ assertTrue("a pinned system must have a star", before.isPresent());
+
+ // A pack edit: a different spacing, a different density, a whole different galaxy.
+ UniverseRegistry.setGenerator(new ClusteredGalaxyGenerator(
+ new GalaxyGenConfig(0.2d, 32, 4, 0.5d, null)));
+
+ Optional after = reg.starAt(cell);
+ assertTrue(after.isPresent());
+ assertEquals("a pinned star's identity must not move", before.get().getId(),
+ after.get().getId());
+ assertEquals("nor its temperature", before.get().getTemperature(), after.get().getTemperature());
+ assertEquals("nor its size", before.get().getSize(), after.get().getSize(), 0f);
+ }
+
+ @Test
+ public void aRealizedBodyKeepsItsCellItsOrbitAndItsKind() {
+ // Realization materializes what was derived; it must not MOVE the body. An address a player
+ // wrote down before landing has to keep denoting the world they landed on.
+ UniverseRegistry reg = registryWithProceduralGalaxy();
+ GalacticCoord cell = findLandableCell(reg);
+ assertNotNull(cell);
+
+ SystemBody before = null;
+ for (SystemBody b : reg.bodiesAt(cell)) {
+ if (b.kind().canDescend()) {
+ before = b;
+ break;
+ }
+ }
+ assertNotNull(before);
+ reg.pinSystem(cell);
+ assertTrue(reg.realizeBody(cell, 999));
+
+ SystemBody after = null;
+ for (SystemBody b : reg.bodiesAt(cell)) {
+ if (b.dimId() == 999) {
+ after = b;
+ break;
+ }
+ }
+ assertNotNull(after);
+ assertEquals("the cell name must not move", before.name(), after.name());
+ assertEquals("the orbit must not move", before.orbitalDistance(), after.orbitalDistance());
+ assertEquals("the kind must not change", before.kind(), after.kind());
+ assertEquals("the owning system must not change", before.starId(), after.starId());
+ assertNotEquals("but it must now have a world", before.dimId(), after.dimId());
+ }
+
+ @Test
+ public void aProceduralBodyCarriesTheOrbitItsPhysicsWasDerivedFrom() {
+ // The orbit travels ON the body so a pinned system's worlds stay derivable after any change to
+ // the placement arithmetic. A body with no orbit would have no climate.
+ UniverseRegistry reg = registryWithProceduralGalaxy();
+ GalacticCoord cell = findLandableCell(reg);
+ assertNotNull(cell);
+ List here = reg.bodiesAt(cell);
+ boolean checked = false;
+ for (SystemBody b : here) {
+ if (b.kind() == SystemBodyKind.STAR) {
+ continue;
+ }
+ assertTrue("a procedural body must carry a real orbital distance, got "
+ + b.orbitalDistance(), b.orbitalDistance() > 0);
+ checked = true;
+ }
+ assertTrue(checked);
+ }
+
+ @Test
+ public void theOrbitSurvivesAnNbtRoundTrip() {
+ SystemBody body = new SystemBody(GalacticCoord.ofSectorLocal(3, 4, 5, 0, 0, 0),
+ SystemBodyKind.PLANET, 12, -7, 1234);
+ net.minecraft.nbt.NBTTagCompound nbt = new net.minecraft.nbt.NBTTagCompound();
+ body.writeToNBT(nbt);
+ SystemBody back = SystemBody.readFromNBT(nbt);
+ assertEquals("a pinned body's orbit must survive the save, or its world is not re-derivable",
+ 1234, back.orbitalDistance());
+ assertEquals(body, back);
+ }
+}
diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/SystemRetinueTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/SystemRetinueTest.java
new file mode 100644
index 000000000..7064dfb2b
--- /dev/null
+++ b/src/test/java/zmaster587/advancedRocketry/test/unit/SystemRetinueTest.java
@@ -0,0 +1,340 @@
+package zmaster587.advancedRocketry.test.unit;
+
+import org.junit.After;
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+import zmaster587.advancedRocketry.space.GalacticCoord;
+import zmaster587.advancedRocketry.universe.ClusteredGalaxyGenerator;
+import zmaster587.advancedRocketry.universe.GalaxyGenConfig;
+import zmaster587.advancedRocketry.universe.PlanetTypes;
+import zmaster587.advancedRocketry.universe.SystemBody;
+import zmaster587.advancedRocketry.universe.SystemBodyKind;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Contract tests for a system's RETINUE — how many bodies it has, where they sit, and what it always
+ * contains.
+ *
+ * The shape is what is pinned, never the constants that produce it: a long-tailed body count rather
+ * than a fixed ceiling, an outer belt on every system without exception, moons living inside their
+ * parent's cell, and — the one that is not cosmetic — no two real bodies sharing a cell. A cell
+ * is the unit a jump is aimed at and the unit a ship arrives into, so two real bodies in one are two
+ * destinations a player can neither tell apart nor choose between.
+ */
+public class SystemRetinueTest {
+
+ private static final long SEED = 0xA57E401DL;
+
+ @After
+ public void restoreGlobals() {
+ PlanetTypes.resetToStock();
+ PlanetTypes.setWorldTypeAvailability(null);
+ }
+
+ private static GalacticCoord cell(long sx, long sy, long sz) {
+ return GalacticCoord.ofSectorLocal(sx, sy, sz, 0L, 0L, 0L);
+ }
+
+ /**
+ * A galaxy dense enough to sample and roomy enough to lay a system out in. The spacing has to leave
+ * a real neighbourhood: a body's cell radius is bounded by {@code 3s/8}, so at {@code s=4} a system
+ * has a single ring of cells to put a dozen bodies in.
+ */
+ private static ClusteredGalaxyGenerator gen(int minSpacing) {
+ return new ClusteredGalaxyGenerator(new GalaxyGenConfig(0.9d, minSpacing, 8, 0.0d, null));
+ }
+
+ /** Every occupied system anchor in a sweep of super-cells. */
+ private static List anchors(ClusteredGalaxyGenerator g, long seed, int minSpacing,
+ int supercells) {
+ Set seen = new HashSet<>();
+ List out = new ArrayList<>();
+ for (long sx = -supercells; sx <= supercells; sx++) {
+ for (long sy = -supercells; sy <= supercells; sy++) {
+ for (long sz = -supercells; sz <= supercells; sz++) {
+ Optional a = g.anchorAt(seed,
+ cell(sx * minSpacing, sy * minSpacing, sz * minSpacing));
+ if (a.isPresent() && seen.add(a.get().cellKey())) {
+ out.add(a.get());
+ }
+ }
+ }
+ }
+ return out;
+ }
+
+ // ─── The invariant the audit exists to protect ─────────────────────────────
+
+ @Test
+ public void noTwoRealBodiesOfOneSystemShareACell() {
+ // Measured the way SystemContent.auditOneRealBodyPerCell measures it — moons exempt, because a
+ // moon lives in its parent's cell by construction — so the generator and the audit cannot
+ // disagree silently about what the invariant says.
+ int minSpacing = 64;
+ ClusteredGalaxyGenerator g = gen(minSpacing);
+ int checked = 0;
+ for (GalacticCoord anchor : anchors(g, SEED, minSpacing, 3)) {
+ Map perCell = new HashMap<>();
+ for (SystemBody b : g.bodiesFor(SEED, anchor)) {
+ if (b.kind() == SystemBodyKind.MOON) {
+ continue;
+ }
+ perCell.merge(b.name().cellKey(), 1, Integer::sum);
+ }
+ for (Map.Entry e : perCell.entrySet()) {
+ assertEquals("system " + anchor.cellKey() + " put " + e.getValue()
+ + " real bodies in cell " + e.getKey(), 1, (int) e.getValue());
+ }
+ checked++;
+ }
+ assertTrue("the sweep must actually find systems", checked > 5);
+ }
+
+ @Test
+ public void theInvariantHoldsEvenWhenTheNeighbourhoodIsCrampedForRoom() {
+ // The collision risk grows with the square of the body count, so the tightest spacing that still
+ // has more than one cell is where it bites. A cramped system is allowed to hold FEWER bodies;
+ // it is not allowed to hold two in one cell.
+ int minSpacing = 8;
+ ClusteredGalaxyGenerator g = gen(minSpacing);
+ int checked = 0;
+ for (GalacticCoord anchor : anchors(g, SEED, minSpacing, 3)) {
+ Set cells = new HashSet<>();
+ for (SystemBody b : g.bodiesFor(SEED, anchor)) {
+ if (b.kind() == SystemBodyKind.MOON) {
+ continue;
+ }
+ assertTrue("cell " + b.name().cellKey() + " of system " + anchor.cellKey()
+ + " holds a second real body", cells.add(b.name().cellKey()));
+ }
+ checked++;
+ }
+ assertTrue(checked > 5);
+ }
+
+ // ─── E1: a long-tailed body count ──────────────────────────────────────────
+
+ @Test
+ public void systemSizeIsLongTailedRatherThanCapped() {
+ List counts = new ArrayList<>();
+ for (long x = -400; x <= 400; x++) {
+ counts.add(ClusteredGalaxyGenerator.retinueSize(SEED, cell(x, 0, 0)));
+ }
+ Collections.sort(counts);
+ int median = counts.get(counts.size() / 2);
+ int biggest = counts.get(counts.size() - 1);
+ int smallest = counts.get(0);
+
+ assertTrue("an ordinary system must be a handful of bodies, saw a median of " + median,
+ median >= 4 && median <= 8);
+ assertTrue("a rare system must be genuinely large — a find, not just a bit bigger; biggest "
+ + "seen was " + biggest, biggest >= 15);
+ assertTrue("and no system may be empty", smallest >= 1);
+ // The tail must be a TAIL: large systems rare, not a second mode.
+ int large = 0;
+ for (int c : counts) {
+ if (c >= 12) {
+ large++;
+ }
+ }
+ assertTrue("large systems must stay rare, saw " + large + "/" + counts.size(),
+ large * 10 < counts.size());
+ assertTrue("but they must exist at all", large > 0);
+ }
+
+ @Test
+ public void theRetinueSizeIsDeterministic() {
+ for (long x = -50; x <= 50; x++) {
+ GalacticCoord c = cell(x, 7, -3);
+ assertEquals(ClusteredGalaxyGenerator.retinueSize(SEED, c),
+ ClusteredGalaxyGenerator.retinueSize(SEED, c));
+ }
+ }
+
+ // ─── E3: every system has an outer belt ────────────────────────────────────
+
+ @Test
+ public void everySystemEndsInABelt() {
+ // Load-bearing beyond this task: drifting out of jump range is only survivable because every
+ // system has something to mine without landing. "Usually" would be a soft-lock.
+ int minSpacing = 64;
+ ClusteredGalaxyGenerator g = gen(minSpacing);
+ int checked = 0;
+ for (GalacticCoord anchor : anchors(g, SEED, minSpacing, 3)) {
+ List bodies = g.bodiesFor(SEED, anchor);
+ int belts = 0;
+ int outermostMajor = 0;
+ int outermostBelt = 0;
+ for (SystemBody b : bodies) {
+ if (b.kind() == SystemBodyKind.ASTEROID_BELT) {
+ belts++;
+ outermostBelt = Math.max(outermostBelt, b.orbitalDistance());
+ } else if (b.kind() != SystemBodyKind.STAR && b.kind() != SystemBodyKind.MOON) {
+ outermostMajor = Math.max(outermostMajor, b.orbitalDistance());
+ }
+ }
+ assertTrue("system " + anchor.cellKey() + " has no belt at all", belts >= 1);
+ assertTrue("the outermost body of a system must be a belt (major " + outermostMajor
+ + ", belt " + outermostBelt + ")", outermostBelt > outermostMajor);
+ checked++;
+ }
+ assertTrue(checked > 5);
+ }
+
+ @Test
+ public void anInnerBeltAppearsOnlyWhereAGiantClearedOne() {
+ // A belt is material a giant's resonances stopped from accreting, so a second belt inside the
+ // system implies a giant. The converse is not asserted: a giant near the edge has no room for a
+ // gap inside it, and a cramped neighbourhood may have no free cell to put one in.
+ int minSpacing = 64;
+ ClusteredGalaxyGenerator g = gen(minSpacing);
+ int systemsWithInnerBelt = 0;
+ for (GalacticCoord anchor : anchors(g, SEED, minSpacing, 3)) {
+ List bodies = g.bodiesFor(SEED, anchor);
+ boolean hasGiant = false;
+ int belts = 0;
+ for (SystemBody b : bodies) {
+ if (b.kind() == SystemBodyKind.GAS_GIANT) {
+ hasGiant = true;
+ } else if (b.kind() == SystemBodyKind.ASTEROID_BELT) {
+ belts++;
+ }
+ }
+ if (belts > 1) {
+ systemsWithInnerBelt++;
+ assertTrue("system " + anchor.cellKey() + " has an inner belt with no giant to have "
+ + "cleared it", hasGiant);
+ }
+ }
+ assertTrue("the sweep must contain systems with giants and inner belts", systemsWithInnerBelt > 0);
+ }
+
+ // ─── E2: moons ─────────────────────────────────────────────────────────────
+
+ @Test
+ public void moonsExistAndLiveInsideTheirParentsCell() {
+ // Without moons the whole outer system is look-only: nothing out there is landable, because the
+ // bodies big enough to be out there are the ones with no surface.
+ int minSpacing = 64;
+ ClusteredGalaxyGenerator g = gen(minSpacing);
+ int moons = 0;
+ int checked = 0;
+ for (GalacticCoord anchor : anchors(g, SEED, minSpacing, 3)) {
+ List bodies = g.bodiesFor(SEED, anchor);
+ Set majorCells = new HashSet<>();
+ for (SystemBody b : bodies) {
+ if (b.kind() != SystemBodyKind.MOON) {
+ majorCells.add(b.name().cellKey());
+ }
+ }
+ for (SystemBody b : bodies) {
+ if (b.kind() != SystemBodyKind.MOON) {
+ continue;
+ }
+ moons++;
+ assertTrue("a moon must share a major body's cell — a planet and its moons are ONE "
+ + "destination", majorCells.contains(b.name().cellKey()));
+ assertTrue("a moon must be landable", b.kind().canDescend());
+ }
+ checked++;
+ }
+ assertTrue(checked > 5);
+ assertTrue("a sweep of systems must produce moons", moons > 3);
+ }
+
+ @Test
+ public void aMoonIsSomewhereElseInsideItsCellThanItsParent() {
+ // A moon that never moved inside the cell would be at the cell centre, i.e. exactly where the
+ // planet is — one address, two bodies, and a descent that cannot say which it came for.
+ int minSpacing = 64;
+ ClusteredGalaxyGenerator g = gen(minSpacing);
+ boolean checkedAny = false;
+ for (GalacticCoord anchor : anchors(g, SEED, minSpacing, 3)) {
+ for (SystemBody b : g.bodiesFor(SEED, anchor)) {
+ if (b.kind() != SystemBodyKind.MOON) {
+ continue;
+ }
+ checkedAny = true;
+ assertFalse("a moon must stand off its cell's centre",
+ b.inCellOffsetAt(0L).isZero() && b.inCellOffsetAt(6000L).isZero());
+ assertTrue("a moon must carry the orbit its climate is derived from — its PARENT's "
+ + "distance from the star", b.orbitalDistance() > 0);
+ }
+ }
+ assertTrue(checkedAny);
+ }
+
+ // ─── E6: the layout follows the orbits ─────────────────────────────────────
+
+ @Test
+ public void aSystemsCellLayoutFollowsItsOrbits() {
+ // The cell radius is derived from the orbit, so a body further from its star is further from the
+ // anchor cell. If the two ever came apart, the map would show a system laid out differently from
+ // the one the physics describes.
+ int minSpacing = 128;
+ ClusteredGalaxyGenerator g = gen(minSpacing);
+ int checked = 0;
+ for (GalacticCoord anchor : anchors(g, SEED, minSpacing, 2)) {
+ SystemBody inner = null;
+ SystemBody outer = null;
+ for (SystemBody b : g.bodiesFor(SEED, anchor)) {
+ if (b.kind() == SystemBodyKind.STAR || b.kind() == SystemBodyKind.MOON) {
+ continue;
+ }
+ if (inner == null || b.orbitalDistance() < inner.orbitalDistance()) {
+ inner = b;
+ }
+ if (outer == null || b.orbitalDistance() > outer.orbitalDistance()) {
+ outer = b;
+ }
+ }
+ if (inner == null || outer == null || inner == outer) {
+ continue;
+ }
+ assertTrue("the outermost body must sit further from the anchor cell than the innermost "
+ + "(inner " + inner.orbitalDistance() + " at "
+ + cellDistance(anchor, inner) + ", outer " + outer.orbitalDistance()
+ + " at " + cellDistance(anchor, outer) + ")",
+ cellDistance(anchor, outer) >= cellDistance(anchor, inner));
+ checked++;
+ }
+ assertTrue(checked > 3);
+ }
+
+ // ─── determinism of the whole retinue ──────────────────────────────────────
+
+ @Test
+ public void theWholeRetinueIsDeterministic() {
+ int minSpacing = 64;
+ ClusteredGalaxyGenerator g = gen(minSpacing);
+ int checked = 0;
+ for (GalacticCoord anchor : anchors(g, SEED, minSpacing, 2)) {
+ assertEquals("a system must regenerate identically", g.bodiesFor(SEED, anchor),
+ g.bodiesFor(SEED, anchor));
+ // And a member cell must answer for the whole system, not just for itself.
+ List viaAnchor = g.bodiesFor(SEED, anchor);
+ assertEquals(viaAnchor, g.bodiesFor(SEED, viaAnchor.get(viaAnchor.size() - 1).name()));
+ checked++;
+ }
+ assertTrue(checked > 3);
+ }
+
+ private static long cellDistance(GalacticCoord anchor, SystemBody body) {
+ long dx = body.name().sectorX() - anchor.sectorX();
+ long dy = body.name().sectorY() - anchor.sectorY();
+ long dz = body.name().sectorZ() - anchor.sectorZ();
+ return dx * dx + dy * dy + dz * dz;
+ }
+}
From bbd40ed4c79f8ee39a599b4956cc1598fe4ae66f Mon Sep 17 00:00:00 2001
From: StannisMod
Date: Tue, 11 Aug 2026 22:14:13 +0300
Subject: [PATCH 04/42] test: measure far-coordinate playability on a real
player
- add /artest player far-tp, delivering like a dimension change
- walk, stand and collide measured clean from 0 to 24M
- pin the reserved-shipyard teleport veto with predicted outcomes
- keep the server far-coordinate integrity spike as a guard
---
.../command/test/TestProbeCommand.java | 56 +++
.../SpikeFarCoordinatePlayabilityTest.java | 453 ++++++++++++++++++
.../SpikeFarCoordinateIntegrityTest.java | 153 ++++++
3 files changed, 662 insertions(+)
create mode 100644 src/test/java/zmaster587/advancedRocketry/test/client/SpikeFarCoordinatePlayabilityTest.java
create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/SpikeFarCoordinateIntegrityTest.java
diff --git a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java
index 0f785acbe..baf61f4ff 100644
--- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java
+++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java
@@ -15959,6 +15959,62 @@ private void handlePlayer(MinecraftServer server, ICommandSender sender, String[
+ ",\"posZ\":" + player.posZ + "}");
return;
}
+ if ("far-tp".equals(sub) && args.length >= 4) {
+ // /artest player far-tp
+ //
+ // Delivers a CONNECTED player to an arbitrary coordinate, including one
+ // millions of blocks out, without the anti-cheat having any say in it.
+ // NetHandlerPlayServer's speed check ("moved too quickly!") measures the
+ // client's next movement packet against the position captured at the top of
+ // the tick, and it is skipped entirely while invulnerableDimensionChange is
+ // armed — the flag vanilla itself sets on every dimension change and clears
+ // when the client acknowledges the teleport, adopting the destination as the
+ // last good position. Arming the same flag and then calling the same
+ // setPlayerLocation a dimension transfer calls makes this the production
+ // delivery minus the change of dimension.
+ //
+ // What a caller DOES have to avoid: Valkyrien Skies vetoes any teleport into
+ // its reserved shipyard region, silently — the command reports success and
+ // the player does not move. That region is the half-open quadrant
+ // chunkX >= CHUNK_X_START - MAX_CHUNK_RADIUS && chunkZ >= -MAX_CHUNK_RADIUS
+ // (see ShipChunkAllocator), so with the shipped constants any destination
+ // with X >= 5,094,416 and Z >= -25,584 is refused. Compare the reported posX
+ // with what you asked for rather than trusting "ok":true.
+ //
+ // Deliberately does NOT generate terrain: the caller arranges the
+ // destination (forceload + fill) so that an arrival into thin air is a
+ // finding, not a silently patched one.
+ if (player.connection == null) {
+ send(sender, "{\"error\":\"far-tp needs a connected player (no connection on \""
+ + escapeJson(player.getName()) + "\")\"}");
+ return;
+ }
+ double tx;
+ double ty;
+ double tz;
+ try {
+ tx = Double.parseDouble(args[1]);
+ ty = Double.parseDouble(args[2]);
+ tz = Double.parseDouble(args[3]);
+ } catch (NumberFormatException e) {
+ send(sender, "{\"error\":\"usage: /artest player far-tp \"}");
+ return;
+ }
+ double fromX = player.posX;
+ player.motionX = 0;
+ player.motionY = 0;
+ player.motionZ = 0;
+ player.fallDistance = 0;
+ player.invulnerableDimensionChange = true;
+ player.connection.setPlayerLocation(tx, ty, tz, player.rotationYaw, player.rotationPitch);
+ server.getPlayerList().serverUpdateMovingPlayer(player);
+ send(sender, "{\"ok\":true,\"player\":\"" + escapeJson(player.getName()) + "\""
+ + ",\"fromX\":" + fromX
+ + ",\"posX\":" + player.posX
+ + ",\"posY\":" + player.posY
+ + ",\"posZ\":" + player.posZ + "}");
+ return;
+ }
if ("held-air".equals(sub)) {
// Probe the air-buffer NBT on the player's chest-armor slot
// (the canonical AR space-suit slot — ItemSpaceChest wraps
diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/SpikeFarCoordinatePlayabilityTest.java b/src/test/java/zmaster587/advancedRocketry/test/client/SpikeFarCoordinatePlayabilityTest.java
new file mode 100644
index 000000000..150306cdd
--- /dev/null
+++ b/src/test/java/zmaster587/advancedRocketry/test/client/SpikeFarCoordinatePlayabilityTest.java
@@ -0,0 +1,453 @@
+package zmaster587.advancedRocketry.test.client;
+
+import com.github.stannismod.forge.testing.junit.AbstractClientE2ETest;
+import com.google.gson.JsonObject;
+
+import org.junit.Test;
+import org.lwjgl.input.Keyboard;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+import static org.junit.Assert.assertTrue;
+
+/**
+ * SPIKE — can a player actually LIVE millions of blocks from the origin, or only exist there?
+ *
+ * Chunk generation, block storage and entity doubles were already measured clean out to 28M with
+ * the subject SPAWNED at the coordinate. None of them says anything about the thing that decides how
+ * big a body may be drawn: whether a player walks, stands and collides normally out there.
+ *
+ * Why the first attempt could not reach 8M, and why the reason was not vanilla
+ * A connected player could not be delivered past ~4M, and vanilla's speed check
+ * ({@code NetHandlerPlayServer} "moved too quickly!") was blamed. It is not the cause. The physics
+ * mod installs a cancellable {@code @Inject} at the HEAD of
+ * {@code NetHandlerPlayServer.setPlayerLocation} that CANCELS any teleport whose destination it
+ * considers its own reserved "shipyard" region, and that region is the half-open quadrant
+ * {@code chunkX >= 318401 && chunkZ >= -1599} — i.e. every position with
+ * X ≥ 5,094,416 and Z ≥ -25,584. Teleports into it are dropped silently: the command reports
+ * success, the mixin cancels, and the player never moves. That is exactly the reported symptom, and
+ * it is a mod-imposed wall five million blocks out, not a vanilla precision limit.
+ *
+ * Two consequences drive this class. {@link #whereExactlyDoesADeliveryStopWorking()} pins the
+ * boundary against numbers PREDICTED from that predicate, so the mechanism is proven rather than
+ * inferred from "2M worked and 8M did not". And the playability ladder runs at
+ * {@code Z = }{@value #ARENA_Z}, below the quadrant's Z edge, where the predicate is false at every
+ * X — so the original question can be answered out to 28M without touching the physics mod.
+ *
+ * Acceptance, stated before the run
+ * Every rung is compared against the {@code x=0} rung measured in the same run, in the same arena,
+ * with the same key held for the same number of ticks:
+ *
+ * - Walking distance over {@value #WALK_TICKS} ticks of held {@code W} must be within
+ * ±10% of the origin's, and at least {@value #MIN_WALK_BLOCKS} blocks absolute.
+ * - Collision stand-off from the wall walked into must be within
+ * {@value #STANDOFF_TOLERANCE} blocks of the origin's — the sharpest instrument here,
+ * being a sub-block quantity resolved from absolute coordinates.
+ * - Standing: {@code posY} within {@value #Y_TOLERANCE} of the floor top throughout.
+ * - No rubber-band: server and client agree on {@code posX} to within
+ * {@value #SYNC_TOLERANCE} blocks at rest.
+ *
+ *
+ * Designed to come back NO. If 28M behaves like the origin on all four, the ±2M bound has
+ * nothing left holding it up. If it does not, the rung where it stops is the answer.
+ */
+public class SpikeFarCoordinatePlayabilityTest extends AbstractClientE2ETest {
+
+ /**
+ * Measured indistinguishable from the origin: 2M, 8M, 16M, 16,777,216 = 2²⁴, 20M, 24M —
+ * so the suspicion that 2²⁴ is the wall is refuted, and the vanilla wiki's first documented
+ * horizontal symptom (sound positioning) does not touch walking, standing or collision.
+ * 28M is the only rung that ever failed, and on both sides at once (client displacement 0.0000,
+ * not just the server's), which rules out the server dragging him back.
+ *
+ * 28M is deliberately NOT in the ladder. It is the one coordinate that ever failed, and
+ * it failed for a reason none of arrangement, run position, server-side revert or 2²⁴ explains —
+ * a ladder of {@code 0, 28M, 24M, 28M, 0} was run for exactly that and both 28M rungs failed
+ * while the 24M between them and the trailing origin passed. The finding is recorded where a
+ * finding belongs; keeping a permanently red rung here would only make this class dead weight in
+ * every client gate. The ladder below is the range the design actually uses — half-cell 16M, with
+ * 20M and 24M as margin — so this class now guards "a player lives normally at the coordinates
+ * our cells use", which is a different and durable claim from the one it was written for.
+ */
+ private static final int[] X_LADDER = {0, 8_000_000, 16_000_000, 20_000_000, 24_000_000};
+
+ /**
+ * The arena's Z. The physics mod's reserved quadrant starts at {@code chunkZ >= -1599}
+ * (Z ≥ -25,584); everything here sits well below it, so its teleport veto never fires and the
+ * only thing under test is the coordinate's own magnitude.
+ */
+ private static final int ARENA_Z = -100_000;
+
+ private static final int OVERWORLD = 0;
+ /** Well above sea level: 2M and 16M are both ocean, and a delivery into water measures the water. */
+ private static final int FLOOR_Y = 140;
+ private static final int STAND_Y = FLOOR_Y + 1;
+
+ /** The corridor runs +X from the player; the wall's near face is this many blocks ahead. */
+ private static final int WALL_OFFSET = 16;
+ private static final int WALK_TICKS = 40;
+ private static final int RAM_TICKS = 160;
+
+ private static final double MIN_WALK_BLOCKS = 5.0d;
+ private static final double WALK_RATIO_TOLERANCE = 0.10d;
+ private static final double STANDOFF_TOLERANCE = 0.05d;
+ private static final double Y_TOLERANCE = 0.05d;
+ private static final double SYNC_TOLERANCE = 0.5d;
+ private static final double ARRIVAL_TOLERANCE = 1.0d;
+ /** How many (deliver, settle) rounds a rung gets before it is called undeliverable. */
+ private static final int DELIVERY_ATTEMPTS = 4;
+
+ private String exec(String cmd) throws Exception {
+ return String.join("\n", serverClient().execute(cmd));
+ }
+
+ /**
+ * Pins the delivery wall against numbers predicted from the physics mod's own predicate, so the
+ * mechanism is proven rather than inferred. {@code isChunkInShipyard(cx, cz)} is
+ * {@code cx >= CHUNK_X_START - MAX_CHUNK_RADIUS && cz >= CHUNK_Z_START - MAX_CHUNK_RADIUS}
+ * = {@code cx >= 318401 && cz >= -1599}, so the four cases below are decided before the run:
+ * one block under the X edge moves, one block over it does not, and the same X moves again once
+ * Z drops below the quadrant. A miss on ANY of the four falsifies the explanation.
+ */
+ @Test
+ public void whereExactlyDoesADeliveryStopWorking() throws Exception {
+ bot().waitForWorld();
+ exec("gamerule sendCommandFeedback false");
+ exec("gamerule logAdminCommands false");
+
+ List report = new ArrayList<>();
+ List wrong = new ArrayList<>();
+ // {x, z, expectedToMove}
+ double[][] cases = {
+ {5_094_400.5d, 0.5d, 1d}, // chunkX 318400 — one chunk under the edge
+ {5_094_416.5d, 0.5d, 0d}, // chunkX 318401 — the first reserved chunk
+ {28_000_000.5d, 0.5d, 0d}, // deep inside the quadrant
+ {28_000_000.5d, ARENA_Z + 0.5d, 1d}, // same X, Z below the quadrant's edge
+ };
+ for (double[] c : cases) {
+ boolean expectMove = c[2] != 0d;
+ String reply = exec("artest player far-tp " + fmt(c[0]) + " 200 " + fmt(c[1]));
+ double from = field(reply, "fromX");
+ double to = field(reply, "posX");
+ boolean moved = Math.abs(to - c[0]) < ARRIVAL_TOLERANCE;
+ boolean unchanged = Math.abs(to - from) < 1e-6d;
+ report.add("target=(" + fmt(c[0]) + "," + fmt(c[1]) + ")"
+ + " chunk=(" + (((long) Math.floor(c[0])) >> 4) + "," + (((long) Math.floor(c[1])) >> 4) + ")"
+ + " predicted=" + (expectMove ? "MOVES" : "CANCELLED")
+ + " observed=" + (moved ? "MOVED" : unchanged ? "CANCELLED" : "ELSEWHERE(" + to + ")"));
+ if (moved != expectMove) {
+ wrong.add(report.get(report.size() - 1));
+ }
+ // Park him back near the origin so the next case starts from a known place.
+ exec("artest player far-tp 0.5 200 0.5");
+ exec("artest server wait " + OVERWORLD + " 20");
+ }
+
+ StringBuilder out = new StringBuilder("[SPIKE far-coordinate delivery boundary]\n");
+ for (String line : report) {
+ out.append(" ").append(line).append('\n');
+ }
+ System.out.println(out);
+ writeReport("far-coordinate-delivery-boundary.txt", out.toString());
+ assertTrue("the reserved-quadrant explanation predicts these four outcomes; it missed:\n" + out,
+ wrong.isEmpty());
+ }
+
+ @Test
+ public void canAPlayerWalkStandAndCollideFarFromTheOrigin() throws Exception {
+ bot().waitForWorld();
+ exec("gamerule sendCommandFeedback false");
+ exec("gamerule logAdminCommands false");
+ exec("gamerule doMobSpawning false");
+ exec("gamerule doDaylightCycle false");
+ exec("gamerule doWeatherCycle false");
+ exec("weather clear");
+ bot().setRenderDistance(4);
+
+ List report = new ArrayList<>();
+ List inconclusive = new ArrayList<>();
+ Rung control = null;
+
+ for (int x : X_LADDER) {
+ buildArena(x);
+ String arenaFault = inspectArena(x);
+ if (arenaFault != null) {
+ buildArena(x); // one retry: a fill can lose a race with chunk loading
+ arenaFault = inspectArena(x);
+ }
+ if (arenaFault != null) {
+ inconclusive.add("x=" + x + " the arena did not build - " + arenaFault
+ + " (arrangement, not the coordinate)");
+ continue;
+ }
+
+ String delivery = deliverAndStand(x);
+ if (delivery != null) {
+ inconclusive.add("x=" + x + " " + delivery);
+ continue;
+ }
+
+ double startServerX = serverX();
+ double startClientX = clientX();
+ double startY = serverY();
+
+ bot().setLook(-90f, 0f); // yaw -90 = east = +X, straight down the corridor
+ bot().waitTicks(5);
+
+ bot().holdKey(Keyboard.KEY_W);
+ bot().waitTicks(WALK_TICKS);
+ double walkedServerX = serverX();
+ // Read the CLIENT's own displacement beside the server's, at the one moment it can still
+ // discriminate. A rung where the player barely moves has two completely different causes
+ // — the client never walked, or it walked and the server dragged it back — and by the
+ // time everything is at rest they agree either way, so "sync" at rest cannot tell them
+ // apart. This sample can.
+ double walkedClientX = clientX();
+ double midY = serverY();
+ // Keep the key held: the collision is measured with exactly the input that produced the
+ // distance above.
+ bot().waitTicks(RAM_TICKS);
+ bot().releaseKey(Keyboard.KEY_W);
+ bot().waitTicks(20);
+
+ double finalServerX = serverX();
+ double finalClientX = clientX();
+ double finalY = serverY();
+
+ double walked = walkedServerX - startServerX;
+ // The wall's near face is at x+WALL_OFFSET; the player's box is 0.6 wide, so a clean
+ // collision leaves his centre 0.3 short of it.
+ double standoff = (x + WALL_OFFSET) - finalServerX;
+
+ Rung rung = new Rung(x, walked, walkedClientX - startClientX, standoff, startY, midY,
+ finalY, Math.abs(finalServerX - finalClientX),
+ Math.abs(startServerX - startClientX));
+ if (x == 0 && control == null) {
+ control = rung; // the FIRST origin rung; a trailing one is judged against it
+ }
+ report.add(rung.line(control));
+ }
+
+ StringBuilder out = new StringBuilder("[SPIKE far-coordinate playability] walkTicks=" + WALK_TICKS
+ + " ramTicks=" + RAM_TICKS + " wallOffset=" + WALL_OFFSET + " arenaZ=" + ARENA_Z + "\n");
+ for (String line : report) {
+ out.append(" ").append(line).append('\n');
+ }
+ for (String line : inconclusive) {
+ out.append(" INCONCLUSIVE ").append(line).append('\n');
+ }
+ System.out.println(out);
+ writeReport("far-coordinate-playability.txt", out.toString());
+
+ assertTrue("no rung produced a usable measurement:\n" + out, !report.isEmpty());
+ assertTrue("the x=0 control rung must be measurable - without it no far rung means anything:\n"
+ + out, control != null);
+
+ List verdicts = new ArrayList<>();
+ for (String line : report) {
+ if (line.contains("VERDICT=FAIL")) {
+ verdicts.add(line);
+ }
+ }
+ assertTrue("a far coordinate did not behave like the origin:\n" + out, verdicts.isEmpty());
+ }
+
+ // ─── arrangement ────────────────────────────────────────────────────────────
+
+ /**
+ * A sealed stone corridor running +X from {@code x}, with a wall across it at
+ * {@code x + WALL_OFFSET}, built BEFORE the player is delivered.
+ */
+ private void buildArena(int x) throws Exception {
+ int fromChunk = (x - 8) >> 4;
+ int toChunk = (x + WALL_OFFSET + 8) >> 4;
+ int fromChunkZ = (ARENA_Z - 8) >> 4;
+ int toChunkZ = (ARENA_Z + 8) >> 4;
+ for (int cx = fromChunk; cx <= toChunk; cx++) {
+ for (int cz = fromChunkZ; cz <= toChunkZ; cz++) {
+ exec("artest chunk forceload " + OVERWORLD + " " + cx + " " + cz);
+ }
+ }
+ exec("artest server wait " + OVERWORLD + " 60");
+
+ int x1 = x - 4;
+ int x2 = x + WALL_OFFSET + 4;
+ exec("artest fill " + OVERWORLD + " " + x1 + " " + FLOOR_Y + " " + (ARENA_Z - 6) + " "
+ + x2 + " " + (FLOOR_Y + 6) + " " + (ARENA_Z + 6) + " minecraft:stone");
+ // Hollow out everything up to (but not including) the wall plane at x+WALL_OFFSET.
+ exec("artest fill " + OVERWORLD + " " + (x1 + 1) + " " + STAND_Y + " " + (ARENA_Z - 5) + " "
+ + (x + WALL_OFFSET - 1) + " " + (FLOOR_Y + 5) + " " + (ARENA_Z + 5) + " minecraft:air");
+ exec("artest server wait " + OVERWORLD + " 20");
+ }
+
+ /**
+ * Reads the arena back and reports the first thing that is not what it should be.
+ *
+ * The first version of this control only checked that the FLOOR and the WALL are stone, and it
+ * passed at every rung — including the one where the player then stood motionless through 200
+ * ticks of held {@code W}. It could not fail on the thing that actually matters: whether the
+ * corridor he has to walk down is air. A player delivered into solid stone stands at
+ * exactly the right Y and cannot move a millimetre, which reads precisely like "movement is
+ * broken at this coordinate". So the walkable line is now sampled along its whole length.
+ *
+ * @return {@code null} if the arena is sound, else what was wrong and what was actually read
+ */
+ private String inspectArena(int x) throws Exception {
+ for (int dx : new int[] {0, 1, 2, 5, 10, WALL_OFFSET - 2}) {
+ String at = exec("artest block at " + OVERWORLD + " " + (x + dx) + " " + STAND_Y + " "
+ + ARENA_Z);
+ if (!at.contains("minecraft:air")) {
+ return "the corridor is not air at x+" + dx + " (" + oneLine(at) + ")";
+ }
+ }
+ for (int dx : new int[] {0, 8, WALL_OFFSET - 1}) {
+ String at = exec("artest block at " + OVERWORLD + " " + (x + dx) + " " + FLOOR_Y + " "
+ + ARENA_Z);
+ if (!at.contains("stone")) {
+ return "the floor is not stone at x+" + dx + " (" + oneLine(at) + ")";
+ }
+ }
+ String wall = exec("artest block at " + OVERWORLD + " " + (x + WALL_OFFSET) + " " + STAND_Y
+ + " " + ARENA_Z);
+ if (!wall.contains("stone")) {
+ return "the wall is not stone (" + oneLine(wall) + ")";
+ }
+ return null;
+ }
+
+ /**
+ * Delivers the player into the arena and does not return until he is STANDING in it.
+ *
+ * One delivery is not enough and the first run proved it: the chunks are force-loaded on the
+ * server but the CLIENT has not received them yet, so client-side physics see air, he falls
+ * through the floor, and the server accepts his movement packets. Delivering again once the
+ * chunks have arrived is what makes him stay. The loop converges rather than guessing a settle
+ * time, and reports which of the two conditions it never met.
+ *
+ * @return {@code null} once he is standing, or a reason string for the INCONCLUSIVE list
+ */
+ private String deliverAndStand(int x) throws Exception {
+ double lastX = Double.NaN;
+ double lastY = Double.NaN;
+ String lastReply = "";
+ for (int attempt = 1; attempt <= DELIVERY_ATTEMPTS; attempt++) {
+ lastReply = exec("artest player far-tp " + fmt(x + 0.5d) + " " + STAND_Y + " "
+ + fmt(ARENA_Z + 0.5d));
+ exec("artest server wait " + OVERWORLD + " 40");
+ bot().waitTicks(30);
+ lastX = serverX();
+ lastY = serverY();
+ if (Math.abs(lastX - (x + 0.5d)) < ARRIVAL_TOLERANCE
+ && Math.abs(lastY - STAND_Y) < Y_TOLERANCE) {
+ return null;
+ }
+ }
+ boolean arrived = Math.abs(lastX - (x + 0.5d)) < ARRIVAL_TOLERANCE;
+ return (arrived
+ ? "he arrived but would not stand (posY=" + fmt(lastY) + ", floor top " + STAND_Y
+ + ") - he is falling through a floor the client has not received"
+ : "the player never arrived (server posX=" + lastX + ", wanted " + (x + 0.5d) + ")")
+ + " after " + DELIVERY_ATTEMPTS + " deliveries - arrangement, not the coordinate."
+ + " lastReply=" + oneLine(lastReply);
+ }
+
+ // ─── instruments ────────────────────────────────────────────────────────────
+
+ private double serverX() throws Exception {
+ return field(exec("artest player health"), "posX");
+ }
+
+ private double serverY() throws Exception {
+ return field(exec("artest player health"), "posY");
+ }
+
+ private double clientX() throws Exception {
+ JsonObject state = bot().reportState();
+ return state.has("playerX") ? state.get("playerX").getAsDouble() : Double.NaN;
+ }
+
+ private static double field(String json, String key) {
+ java.util.regex.Matcher m = java.util.regex.Pattern
+ .compile("\"" + key + "\"\\s*:\\s*([-0-9.eE]+)").matcher(json);
+ return m.find() ? Double.parseDouble(m.group(1)) : Double.NaN;
+ }
+
+ /** One rung's four numbers plus the verdict they earn against the origin control. */
+ private static final class Rung {
+ final int x;
+ final double walked;
+ final double clientWalked;
+ final double standoff;
+ final double startY;
+ final double midY;
+ final double finalY;
+ final double syncAtRest;
+ final double syncAtStart;
+
+ Rung(int x, double walked, double clientWalked, double standoff, double startY, double midY,
+ double finalY, double syncAtRest, double syncAtStart) {
+ this.x = x;
+ this.walked = walked;
+ this.clientWalked = clientWalked;
+ this.standoff = standoff;
+ this.startY = startY;
+ this.midY = midY;
+ this.finalY = finalY;
+ this.syncAtRest = syncAtRest;
+ this.syncAtStart = syncAtStart;
+ }
+
+ String line(Rung control) {
+ List failures = new ArrayList<>();
+ if (walked < MIN_WALK_BLOCKS) {
+ failures.add("walked<" + MIN_WALK_BLOCKS);
+ }
+ if (Math.abs(startY - STAND_Y) > Y_TOLERANCE
+ || Math.abs(midY - STAND_Y) > Y_TOLERANCE
+ || Math.abs(finalY - STAND_Y) > Y_TOLERANCE) {
+ failures.add("leftTheFloor");
+ }
+ if (syncAtRest > SYNC_TOLERANCE) {
+ failures.add("serverClientDisagree");
+ }
+ if (control != null && control != this) {
+ double ratio = control.walked == 0 ? Double.NaN : walked / control.walked;
+ if (!(Math.abs(ratio - 1d) <= WALK_RATIO_TOLERANCE)) {
+ failures.add("walkRatio=" + fmt(ratio));
+ }
+ if (!(Math.abs(standoff - control.standoff) <= STANDOFF_TOLERANCE)) {
+ failures.add("standoffDelta=" + fmt(standoff - control.standoff));
+ }
+ }
+ return "x=" + x
+ + " walked=" + fmt(walked) + "(client " + fmt(clientWalked) + ")"
+ + " standoff=" + fmt(standoff)
+ + " y=" + fmt(startY) + "/" + fmt(midY) + "/" + fmt(finalY)
+ + " sync=" + fmt(syncAtStart) + "->" + fmt(syncAtRest)
+ + " VERDICT=" + (failures.isEmpty() ? "OK" : "FAIL" + failures);
+ }
+ }
+
+ private static void writeReport(String name, String text) {
+ try {
+ Path dir = Paths.get("build", "spike-reports").toAbsolutePath();
+ Files.createDirectories(dir);
+ Files.write(dir.resolve(name), text.getBytes("UTF-8"));
+ } catch (Exception e) {
+ System.out.println("[SPIKE] could not write the report file: " + e);
+ }
+ }
+
+ private static String oneLine(String s) {
+ return s.replace((char) 10, ' ').replace((char) 13, ' ').trim();
+ }
+
+ private static String fmt(double v) {
+ return String.format(Locale.ROOT, "%.4f", v);
+ }
+}
diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/SpikeFarCoordinateIntegrityTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/SpikeFarCoordinateIntegrityTest.java
new file mode 100644
index 000000000..d1e548df6
--- /dev/null
+++ b/src/test/java/zmaster587/advancedRocketry/test/server/SpikeFarCoordinateIntegrityTest.java
@@ -0,0 +1,153 @@
+package zmaster587.advancedRocketry.test.server;
+
+import com.github.stannismod.forge.testing.junit.AbstractHeadlessServerTest;
+
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.Assert.assertTrue;
+
+/**
+ * SPIKE — does anything actually break past ±2M blocks, the bound the space cell was sized for?
+ *
+ * `space-model.md` says a 4M cell exists because "entity doubles / chunks / lighting degrade past
+ * ~±2M blocks in 1.12.2". Three mechanisms in one sentence, and the cell size — hence how big a planet
+ * may be drawn — rests on all three. This measures the two a server can see: chunk generation
+ * and block storage. The third (render jitter) is client-side and is measured separately.
+ *
+ * This spike is designed to come back NO. The acceptance number is stated here, before the
+ * run: at each sampled X, terrain must generate (a non-air top block at a plausible height) and a
+ * placed block must read back as itself. A coordinate where either fails is a real ceiling; a
+ * coordinate where both hold tells us the ceiling is not here.
+ *
+ * Throwaway by intent: it exists to answer one question once. If it is kept, it becomes a
+ * regression test for "the world still works at the coordinates our cells use", which is a different
+ * claim from the one it was written for.
+ */
+public class SpikeFarCoordinateIntegrityTest extends AbstractHeadlessServerTest {
+
+ /**
+ * The ladder. 2M is today's half-cell; 8M and 16M are the growth steps a bigger cell would need;
+ * 28M is just inside the vanilla world border (29 999 984), i.e. the last coordinate that can
+ * exist at all.
+ */
+ private static final int[] X_LADDER = {2_000_000, 8_000_000, 16_000_000, 28_000_000};
+
+ private static final int OVERWORLD = 0;
+ private static final int PLACE_Y = 100;
+
+ private String exec(String cmd) throws Exception {
+ return String.join("\n", client().execute(cmd));
+ }
+
+ @Test
+ public void chunksAndBlockStorageStillWorkFarFromTheOrigin() throws Exception {
+ List report = new ArrayList<>();
+ List broken = new ArrayList<>();
+
+ for (int x : X_LADDER) {
+ int chunkX = x >> 4;
+ exec("artest chunk forceload " + OVERWORLD + " " + chunkX + " 0");
+ // Generation at a fresh, distant chunk is not instant; give the server real ticks rather
+ // than reading an empty chunk and calling it a ceiling.
+ exec("artest server wait " + OVERWORLD + " 40");
+
+ String sample = exec("artest worldgen sample " + OVERWORLD + " " + chunkX + " 0");
+ String placed = exec("artest place " + OVERWORLD + " " + x + " " + PLACE_Y + " 0 "
+ + "minecraft:diamond_block");
+ String readBack = exec("artest block at " + OVERWORLD + " " + x + " " + PLACE_Y + " 0");
+
+ boolean terrainOk = !sample.contains("\"error\"") && !sample.contains("minecraft:air");
+ boolean storageOk = readBack.contains("diamond_block");
+ report.add("x=" + x + " terrain=" + (terrainOk ? "ok" : "FAIL") + " storage="
+ + (storageOk ? "ok" : "FAIL") + " sample=" + oneLine(sample)
+ + " placed=" + oneLine(placed) + " readBack=" + oneLine(readBack));
+ if (!terrainOk || !storageOk) {
+ broken.add(Integer.toString(x));
+ }
+ }
+
+ // The whole point is the REPORT, so it is emitted either way — a spike that only speaks when
+ // it fails cannot tell you where the ceiling ISN'T.
+ System.out.println("[SPIKE far-coordinate integrity]");
+ for (String line : report) {
+ System.out.println(" " + line);
+ }
+
+ assertTrue("terrain or block storage failed at: " + broken + "\n" + String.join("\n", report),
+ broken.isEmpty());
+ }
+
+ /**
+ * Do ENTITY DOUBLES hold a sub-block X far from the origin?
+ *
+ * This is the third of `space-model.md`'s three claimed mechanisms, and the only delivery that
+ * can reach the far coordinates at all: the subject is SPAWNED there rather than moved there.
+ * Every earlier attempt teleported a connected player and was rubber-banded by
+ * {@code NetHandlerPlayServer} ("moved too quickly!"), so it measured the anti-cheat and not the
+ * coordinate.
+ *
+ * Two stands 0.05 apart are spawned at each coordinate. If both read back exactly, entity
+ * doubles do not degrade there — which is what the arithmetic says they should not: a double's
+ * ULP at 2.8e7 is about 5e-9 of a block.
+ */
+ @Test
+ public void doEntityDoublesHoldASubBlockXFarFromTheOrigin() throws Exception {
+ List report = new ArrayList<>();
+ List broken = new ArrayList<>();
+ for (int x : X_LADDER) {
+ exec("artest chunk forceload " + OVERWORLD + " " + (x >> 4) + " 0");
+ exec("artest server wait " + OVERWORLD + " 40");
+
+ String near = spawnAndRead(x + 0.5500d);
+ String far = spawnAndRead(x + 0.6000d);
+ // Compare NUMERICALLY. Java prints a double above 1e7 in E-notation, so a textual check
+ // reported 16000000.55 as a miss when the value was exact — the check was wrong, not the
+ // coordinate, and a spike whose verdict is its own formatting is worse than no spike.
+ boolean nearOk = Math.abs(asDouble(near) - (x + 0.55d)) < 1e-6d;
+ boolean farOk = Math.abs(asDouble(far) - (x + 0.60d)) < 1e-6d;
+ boolean distinct = !near.equals(far);
+ report.add("x=" + x + " asked " + x + ".55 got " + near
+ + " | asked " + x + ".60 got " + far
+ + " | exact=" + (nearOk && farOk) + " distinct=" + distinct);
+ if (!nearOk || !farOk || !distinct) {
+ broken.add(Integer.toString(x));
+ }
+ }
+ System.out.println("[SPIKE far-coordinate entity doubles]");
+ for (String line : report) {
+ System.out.println(" " + line);
+ }
+ assertTrue("a sub-block X was lost at: " + broken + " " + String.join(" | ", report),
+ broken.isEmpty());
+ }
+
+ private static double asDouble(String s) {
+ try {
+ return Double.parseDouble(s);
+ } catch (RuntimeException e) {
+ return Double.NaN;
+ }
+ }
+
+ /** Spawn an armour stand at an exact X and return the position the server reports for it. */
+ private String spawnAndRead(double x) throws Exception {
+ String spawned = exec("artest vs drop-stand " + OVERWORLD + " "
+ + String.format(java.util.Locale.ROOT, "%.4f", x) + " 150 0.5");
+ java.util.regex.Matcher idm = java.util.regex.Pattern
+ .compile("\"entityId\"\\s*:\\s*(-?\\d+)").matcher(spawned);
+ if (!idm.find()) {
+ return "NO-SPAWN:" + oneLine(spawned);
+ }
+ String info = exec("artest entity info " + OVERWORLD + " " + idm.group(1));
+ java.util.regex.Matcher xm = java.util.regex.Pattern
+ .compile("\"posX\"\\s*:\\s*([-0-9.eE]+)").matcher(info);
+ return xm.find() ? xm.group(1) : ("UNREADABLE:" + oneLine(info));
+ }
+
+ private static String oneLine(String s) {
+ return s.replace('\n', ' ').replace('\r', ' ').trim();
+ }
+}
From f4a38351894481a3c07aeffceafdae0a447f5ec2 Mon Sep 17 00:00:00 2001
From: StannisMod
Date: Wed, 12 Aug 2026 08:39:27 +0300
Subject: [PATCH 05/42] fix: a moon's period follows mass, and every star
lights the world
- derive moon periods from the parent's mass, not its surface gravity
- a Jupiter's moons ran 11.2x too slowly on both placement paths
- sum stellar flux over the primary and its companions
- a black hole's companion no longer cancels its accretion dimming
- pin both as contract tests; single-star numbers are unchanged
---
.../render/planet/RenderAsteroidSky.java | 2 +-
.../render/planet/RenderPlanetarySky.java | 2 +-
.../dimension/DimensionProperties.java | 16 ++-
.../universe/ClusteredGalaxyGenerator.java | 10 +-
.../universe/SystemContent.java | 2 +-
.../util/AstronomicalBodyHelper.java | 102 +++++++++++-------
.../test/integration/SystemContentTest.java | 58 ++++++++++
.../test/unit/AstronomicalBodyHelperTest.java | 44 +++++++-
8 files changed, 193 insertions(+), 43 deletions(-)
diff --git a/src/main/java/zmaster587/advancedRocketry/client/render/planet/RenderAsteroidSky.java b/src/main/java/zmaster587/advancedRocketry/client/render/planet/RenderAsteroidSky.java
index ba7c44ef2..8d062ea8a 100644
--- a/src/main/java/zmaster587/advancedRocketry/client/render/planet/RenderAsteroidSky.java
+++ b/src/main/java/zmaster587/advancedRocketry/client/render/planet/RenderAsteroidSky.java
@@ -553,7 +553,7 @@ public void render(float partialTicks, WorldClient world, Minecraft mc) {
GL11.glPushMatrix();
float planetPositionTheta = AstronomicalBodyHelper.getParentPlanetThetaFromMoon(
- properties.rotationalPeriod, properties.orbitalDist, parentProperties.gravitationalMultiplier,
+ properties.rotationalPeriod, properties.orbitalDist, (float) parentProperties.getOrbitalMass(),
myTheta, properties.baseOrbitTheta);
GL11.glRotatef((float) myPhi, 0f, 0f, 1f);
diff --git a/src/main/java/zmaster587/advancedRocketry/client/render/planet/RenderPlanetarySky.java b/src/main/java/zmaster587/advancedRocketry/client/render/planet/RenderPlanetarySky.java
index 62864c48f..47d28b170 100644
--- a/src/main/java/zmaster587/advancedRocketry/client/render/planet/RenderPlanetarySky.java
+++ b/src/main/java/zmaster587/advancedRocketry/client/render/planet/RenderPlanetarySky.java
@@ -865,7 +865,7 @@ public void render(float partialTicks, WorldClient world, Minecraft mc) {
//Do a whole lotta math to figure out where the parent planet is supposed to be
//That 0.3054325f is there because we need to do adjustments for some ^$%^$% reason and it's consistently off by 17.5 degrees
- float planetPositionTheta = AstronomicalBodyHelper.getParentPlanetThetaFromMoon(properties.rotationalPeriod, properties.orbitalDist, parentProperties.gravitationalMultiplier, myTheta, properties.baseOrbitTheta);
+ float planetPositionTheta = AstronomicalBodyHelper.getParentPlanetThetaFromMoon(properties.rotationalPeriod, properties.orbitalDist, (float) parentProperties.getOrbitalMass(), myTheta, properties.baseOrbitTheta);
GL11.glRotatef((float) myPhi, 0f, 0f, 1f);
GL11.glRotatef(planetPositionTheta, 1f, 0f, 0f);
diff --git a/src/main/java/zmaster587/advancedRocketry/dimension/DimensionProperties.java b/src/main/java/zmaster587/advancedRocketry/dimension/DimensionProperties.java
index 0547fd8f2..4a5f87208 100644
--- a/src/main/java/zmaster587/advancedRocketry/dimension/DimensionProperties.java
+++ b/src/main/java/zmaster587/advancedRocketry/dimension/DimensionProperties.java
@@ -572,6 +572,20 @@ public boolean hasBulkProperties() {
return mass > BULK_UNSET && radius > BULK_UNSET;
}
+ /**
+ * The mass, in Earth masses, to use in a two-body orbital law about this body — what a moon's
+ * period is derived from.
+ *
+ * Falls back to surface gravity when nothing has stated a mass, and that is not a fudge:
+ * {@code g = M/R²}, so gravity and mass are the same number at one Earth radius, and a body with
+ * no stated bulk is precisely a body nobody has given a radius. What it replaces IS the fudge —
+ * every caller used to pass gravity unconditionally, which is exact for Earth and off by
+ * {@code sqrt(M/g)} for everything else.
+ */
+ public double getOrbitalMass() {
+ return mass > BULK_UNSET ? mass : gravitationalMultiplier;
+ }
+
/**
* State this body's mass and radius, deriving surface gravity from them unless a gravity was
* explicitly authored.
@@ -2440,7 +2454,7 @@ public double orbitThetaAt(long worldTick) {
double theta = 0d;
if (isMoon() && getParentProperties() != null) {
theta = AstronomicalBodyHelper.getMoonOrbitalThetaAt(orbitalDist,
- getParentProperties().gravitationalMultiplier, worldTick);
+ (float) getParentProperties().getOrbitalMass(), worldTick);
} else {
StellarBody host = getStar();
if (host != null) {
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/ClusteredGalaxyGenerator.java b/src/main/java/zmaster587/advancedRocketry/universe/ClusteredGalaxyGenerator.java
index 2631cc968..c93eb768d 100644
--- a/src/main/java/zmaster587/advancedRocketry/universe/ClusteredGalaxyGenerator.java
+++ b/src/main/java/zmaster587/advancedRocketry/universe/ClusteredGalaxyGenerator.java
@@ -332,13 +332,19 @@ private void addMoons(List bodies, long seed, GalacticCoord anchor,
if (moons > max) {
moons = max;
}
- double parentGravity = Math.max(0.05d, parentProfile.gravityPercent() / 100d);
+ // A moon's period comes from its parent's MASS. Passing gravity here is exact only at one
+ // Earth radius and made a giant's moons crawl — Jupiter is 318 Earth masses but 2.53 g, a
+ // factor of sqrt(318/2.53) = 11.2 in the period. A profile with no mass falls back to gravity,
+ // which is the same number for the one-Earth-radius body an unstated bulk describes.
+ double parentMass = parentProfile.massEarths() > 0d
+ ? parentProfile.massEarths()
+ : Math.max(0.05d, parentProfile.gravityPercent() / 100d);
for (int j = 1; j <= moons; j++) {
int moonOrbit = MOON_MIN_ORBIT + (int) (CellHash.norm(
CellHash.ofBody(seed, parent, j, SALT_MOONRAD)) * MOON_ORBIT_SPAN);
double theta = CellHash.norm(CellHash.ofBody(seed, parent, j, SALT_MOONANG)) * 2d * Math.PI;
double periodTicks = AstronomicalBodyHelper.TICKS_PER_DAY
- * AstronomicalBodyHelper.getMoonOrbitalPeriod(moonOrbit, (float) parentGravity);
+ * AstronomicalBodyHelper.getMoonOrbitalPeriod(moonOrbit, (float) parentMass);
BodyEphemeris law = BodyEphemeris.orbit(moonOrbit, theta, 0d, false, periodTicks,
SystemContent.MOON_UNIT_BLOCKS);
bodies.add(new SystemBody(parent, CellFrame.staticAt(parent), law, SystemBodyKind.MOON,
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/SystemContent.java b/src/main/java/zmaster587/advancedRocketry/universe/SystemContent.java
index 074e7a822..7736cb920 100644
--- a/src/main/java/zmaster587/advancedRocketry/universe/SystemContent.java
+++ b/src/main/java/zmaster587/advancedRocketry/universe/SystemContent.java
@@ -191,7 +191,7 @@ private static BodyEphemeris orbitLawOf(DimensionProperties planet, StellarBody
/** A moon's orbital law about its PARENT — its offset inside the shared cell, live at every tick. */
private static BodyEphemeris moonLawOf(DimensionProperties moon, DimensionProperties parent) {
double periodTicks = TICKS_PER_DAY * AstronomicalBodyHelper.getMoonOrbitalPeriod(
- moon.getOrbitalDist(), parent.gravitationalMultiplier);
+ moon.getOrbitalDist(), (float) parent.getOrbitalMass());
return BodyEphemeris.orbit(moon.getOrbitalDist(), moon.baseOrbitTheta, moon.orbitalPhi,
moon.isRetrograde, periodTicks, MOON_UNIT_BLOCKS);
}
diff --git a/src/main/java/zmaster587/advancedRocketry/util/AstronomicalBodyHelper.java b/src/main/java/zmaster587/advancedRocketry/util/AstronomicalBodyHelper.java
index a572af5a6..b1647d8f3 100644
--- a/src/main/java/zmaster587/advancedRocketry/util/AstronomicalBodyHelper.java
+++ b/src/main/java/zmaster587/advancedRocketry/util/AstronomicalBodyHelper.java
@@ -68,13 +68,20 @@ public static double getOrbitalPeriod(int orbitalDistance, float solarSize) {
/**
* Returns the orbital period for a body at a given distance around its parent planet
*
+ * The second argument is a MASS, in Earth masses, and callers used to pass surface gravity.
+ * The two agree only at one Earth radius — {@code g = M/R²} — so the substitution was exact for
+ * Earth and wrong by {@code sqrt(M/g)} everywhere else, which for Jupiter (M=318, g=2.53) made its
+ * moons orbit 11.2 times too slowly. Pass the body's mass; where nothing has stated one, its
+ * gravity IS the right stand-in, because a body with no stated bulk is a body assumed to be one
+ * Earth radius across.
+ *
* @param orbitalDistance the distance from the parent body
- * @param planetaryMass the mass of the planet in question
+ * @param planetaryMass the mass of the planet in question, in Earth masses
* @return the orbital period in MC Days (24000 ticks)
*/
public static double getMoonOrbitalPeriod(float orbitalDistance, float planetaryMass) {
//One (lunar) MC month is 8 MC days, so the moon orbits in 8
- //The same a the function for planets, but since gravity is directly correlated with mass uses the gravity of the plant for mass
+ //The same as the function for planets, with the parent's mass in place of the star's size
return DAYS_PER_LUNAR_MONTH
* Math.pow(Math.pow((orbitalDistance / (double) DISTANCE_UNITS_PER_AU), 3) / planetaryMass, 0.5d);
}
@@ -111,12 +118,12 @@ public static double getOrbitalThetaAt(int orbitalDistance, float solarSize, lon
/**
* Returns the orbital theta for a body at a given distance around its parent planet, at this current moment
*
- * @param orbitalDistance the distance from the parent body
- * @param parentGravitationalMultiplier the size of the parent planet in question
+ * @param orbitalDistance the distance from the parent body
+ * @param parentMassEarths the mass of the parent planet, in Earth masses
* @return the current angle around the planet in radians
*/
- public static double getMoonOrbitalTheta(int orbitalDistance, float parentGravitationalMultiplier) {
- return getMoonOrbitalThetaAt(orbitalDistance, parentGravitationalMultiplier,
+ public static double getMoonOrbitalTheta(int orbitalDistance, float parentMassEarths) {
+ return getMoonOrbitalThetaAt(orbitalDistance, parentMassEarths,
AdvancedRocketry.proxy.getWorldTimeUniversal(0));
}
@@ -126,11 +133,11 @@ public static double getMoonOrbitalTheta(int orbitalDistance, float parentGravit
*
* @return the angle around the parent planet in RADIANS
*/
- public static double getMoonOrbitalThetaAt(int orbitalDistance, float parentGravitationalMultiplier,
+ public static double getMoonOrbitalThetaAt(int orbitalDistance, float parentMassEarths,
long worldTick) {
//Because the function is still in AU and solar mass, some correctional factors to convert to those units
double periodTicks = (double) TICKS_PER_DAY
- * getMoonOrbitalPeriod(orbitalDistance, parentGravitationalMultiplier);
+ * getMoonOrbitalPeriod(orbitalDistance, parentMassEarths);
if (!(periodTicks > 0d) || Double.isInfinite(periodTicks)) {
return 0d;
}
@@ -140,19 +147,19 @@ public static double getMoonOrbitalThetaAt(int orbitalDistance, float parentGrav
/**
* Returns the visual orbital theta for a body at a given distance around its parent planet, at this current moment, as a value from 0 - 360
*
- * @param rotationalPeriod the rotational period of the moon we are rendering from
- * @param orbitalDistance the distance from the parent body
- * @param parentGravitationalMultiplier the distance from the parent body
- * @param currentOrbitalTheta the orbital theta of the moon we are rendering from
- * @param baseOrbitalTheta the base orbital theta of the planet in question
+ * @param rotationalPeriod the rotational period of the moon we are rendering from
+ * @param orbitalDistance the distance from the parent body
+ * @param parentMassEarths the mass of the parent planet, in Earth masses
+ * @param currentOrbitalTheta the orbital theta of the moon we are rendering from
+ * @param baseOrbitalTheta the base orbital theta of the planet in question
* @return the current angle around the planet normalized 0 - 360, for GL calls
*/
- public static float getParentPlanetThetaFromMoon(int rotationalPeriod, int orbitalDistance, float parentGravitationalMultiplier, double currentOrbitalTheta, double baseOrbitalTheta) {
+ public static float getParentPlanetThetaFromMoon(int rotationalPeriod, int orbitalDistance, float parentMassEarths, double currentOrbitalTheta, double baseOrbitalTheta) {
//Convert from radians to degrees for easier math
float degreeOrbitalTheta = (float) (currentOrbitalTheta * 180 / Math.PI);
//Computer the number of rotations per revolution and use that for how fast the planet would seem to orbit from the moon
//Planet will not move at all if it is tidally locked
- float planetPositionTheta = (((float) (AstronomicalBodyHelper.getMoonOrbitalPeriod(orbitalDistance, parentGravitationalMultiplier) * TICKS_PER_DAY) / rotationalPeriod) - 1) * degreeOrbitalTheta;
+ float planetPositionTheta = (((float) (AstronomicalBodyHelper.getMoonOrbitalPeriod(orbitalDistance, parentMassEarths) * TICKS_PER_DAY) / rotationalPeriod) - 1) * degreeOrbitalTheta;
//Add the base orbital theta so the planet is in the correct place
return (planetPositionTheta + (float) (baseOrbitalTheta * 180 / Math.PI)) % 360;
}
@@ -191,31 +198,34 @@ public static double getStellarBrightness(StellarBody star, int orbitalDistance)
if (star == null || orbitalDistance <= 0) {
return MIN_BRIGHTNESS;
}
- //Normal stars are 1.0 times this value, black holes with accretion discs emit less and so modify it
- float lightMultiplier = 1.0f;
- //Make all values ratios of Earth normal to get ratio compared to Earth
- float normalizedStarTemperature = star.getTemperature() / (float) TEMPERATURE_UNITS_PER_SOL;
float planetaryOrbitalRadius = orbitalDistance / (float) DISTANCE_UNITS_PER_AU;
- //Check to see if the star is a black hole
- boolean blackHole = star.isBlackHole();
- Iterable subs = star.getSubStars();
- if (subs != null) {
- for (StellarBody star2 : subs) {
- if (star2 != null && !star2.isBlackHole()) {
- blackHole = false;
- break;
+ // EVERY star that shines on this world contributes, and what ADDS is the FLUX each one
+ // delivers here — not their luminosities. Radiant power from mutually incoherent sources
+ // superposes linearly, so E = sum of L_i / d_i², with each star's own distance under its own
+ // luminosity. Summing luminosities first and dividing once is the same number only while all
+ // the stars are equidistant from the planet.
+ //
+ // Today they are, by construction rather than by physics: a companion's separation is stored
+ // as an ANGLE in the sky (StellarBody.getStarSeparation), so there is no distance to give it,
+ // and every companion is fed the primary's. That is exact for the close binaries the model can
+ // actually describe, and it is why this sums flux terms rather than luminosities — when a
+ // companion gains a real orbital radius, only the argument below changes.
+ //
+ // This replaces a walk over the companions whose only effect was to clear a boolean: any
+ // ordinary companion turned the accretion-disc dimming OFF, after which the brightness came
+ // from the BLACK HOLE's own size and temperature at full strength, and the companion itself
+ // never contributed a photon.
+ //Returns ratio compared to a planet at 1 AU for Sol, because the other values in AR are normalized,
+ //and this works fairly well for hooking into with other mod's solar panels & such
+ double brightness = fluxOf(star, planetaryOrbitalRadius);
+ Iterable companions = star.getSubStars();
+ if (companions != null) {
+ for (StellarBody companion : companions) {
+ if (companion != null) {
+ brightness += fluxOf(companion, planetaryOrbitalRadius);
}
}
}
- //There's no real easy way to get the light emitted by an accretion disc, so this substitutes
- if (blackHole)
- lightMultiplier *= 0.25;
- //Returns ratio compared to a planet at 1 AU for Sol, because the other values in AR are normalized,
- //and this works fairly well for hooking into with other mod's solar panels & such
- double brightness =
- lightMultiplier *
- ((Math.pow(star.getSize(), 2) * Math.pow(normalizedStarTemperature, 4)) /
- Math.pow(planetaryOrbitalRadius, 2));
// Guarantee: never return 0, NaN, or Infinity
if (!Double.isFinite(brightness) || brightness < MIN_BRIGHTNESS) {
@@ -224,6 +234,26 @@ public static double getStellarBrightness(StellarBody star, int orbitalDistance)
return brightness;
}
+ /**
+ * The flux one star delivers at {@code orbitalRadiusAu}, relative to Sol at 1 AU:
+ * {@code size² · (T/Sol)⁴ / r²} — Stefan-Boltzmann over the inverse square, both in solar units.
+ * Quartered for a black hole, because there is no easy way to model what an accretion disc emits.
+ *
+ * 0.25 is a power of two, so applying it to the numerator rather than to the finished quotient
+ * is exact: a system of one star returns bit-identical numbers to the version that multiplied at
+ * the end.
+ */
+ private static double fluxOf(StellarBody star, float orbitalRadiusAu) {
+ //Make all values ratios of Earth normal to get ratio compared to Earth
+ float normalizedStarTemperature = star.getTemperature() / (float) TEMPERATURE_UNITS_PER_SOL;
+ double luminosity = Math.pow(star.getSize(), 2) * Math.pow(normalizedStarTemperature, 4);
+ //There's no real easy way to get the light emitted by an accretion disc, so this substitutes
+ if (star.isBlackHole()) {
+ luminosity *= 0.25d;
+ }
+ return luminosity / Math.pow(orbitalRadiusAu, 2);
+ }
+
/**
* Returns the human-eye-perceivable brightness of this insolation multiplier
*
diff --git a/src/test/java/zmaster587/advancedRocketry/test/integration/SystemContentTest.java b/src/test/java/zmaster587/advancedRocketry/test/integration/SystemContentTest.java
index c012eec9b..d39acbcd3 100644
--- a/src/test/java/zmaster587/advancedRocketry/test/integration/SystemContentTest.java
+++ b/src/test/java/zmaster587/advancedRocketry/test/integration/SystemContentTest.java
@@ -10,6 +10,7 @@
import zmaster587.advancedRocketry.api.dimension.solar.StellarBody;
import zmaster587.advancedRocketry.dimension.DimensionManager;
import zmaster587.advancedRocketry.dimension.DimensionProperties;
+import zmaster587.advancedRocketry.space.BlockDelta;
import zmaster587.advancedRocketry.space.GalacticCoord;
import zmaster587.advancedRocketry.test.MinecraftBootstrap;
import zmaster587.advancedRocketry.util.AstronomicalBodyHelper;
@@ -385,6 +386,63 @@ public void aMoonsOffsetInsideItsParentsCellIsLiveWhileItsNameIsNot() {
planetBody.inCellOffsetAt(quarterPeriod).isZero());
}
+ /**
+ * A moon's period is set by its parent's MASS, not by the gravity you would feel standing on it.
+ *
+ * The two are the same number only at one Earth radius — {@code g = M/R²} — and every orbital
+ * law here used to be handed gravity. Exact for Earth; for a Jupiter (318 Earth masses, 2.53 g)
+ * wrong by {@code sqrt(318/2.53)}, so a giant's moons crawled round it 11 times too slowly. The
+ * fixture below is that Jupiter, and the two readings are 11× apart, so a run cannot satisfy this
+ * test by accident.
+ */
+ @Test
+ public void aMoonsPeriodFollowsItsParentsMassNotItsSurfaceGravity() {
+ StellarBody star = new StellarBody();
+ star.setId(4251);
+ star.setSize(1f);
+ DimensionProperties parent = planet(780, 200, 0.5);
+ parent.setBulk(318d, 11.2d); // a Jupiter: gravity falls out as M/R² = 2.53
+ DimensionProperties moon = planet(781, 127, 0.9);
+ DimensionManager.getInstance().setDimProperties(780, parent);
+ DimensionManager.getInstance().setDimProperties(781, moon);
+ parent.setStar(star);
+ moon.setParentPlanet(parent);
+
+ assertEquals("the fixture must be a giant, or the two readings coincide and prove nothing",
+ 2.535d, parent.gravitationalMultiplier, 0.01d);
+
+ long massPeriodTicks = (long) (24000d
+ * AstronomicalBodyHelper.getMoonOrbitalPeriod(127f, (float) parent.getOrbitalMass()));
+ long gravityPeriodTicks = (long) (24000d
+ * AstronomicalBodyHelper.getMoonOrbitalPeriod(127f, parent.gravitationalMultiplier));
+ assertTrue("mass and gravity must give periods far enough apart to tell apart: "
+ + massPeriodTicks + " vs " + gravityPeriodTicks,
+ gravityPeriodTicks > massPeriodTicks * 5);
+
+ SystemBody moonBody = bodyOf(SystemContent.bodiesOf(star, GalacticCoord.ORIGIN), 781);
+ assertNotNull(moonBody);
+
+ BlockDelta start = moonBody.inCellOffsetAt(0L);
+ BlockDelta afterOnePeriod = moonBody.inCellOffsetAt(massPeriodTicks);
+ BlockDelta afterHalf = moonBody.inCellOffsetAt(massPeriodTicks / 2L);
+
+ // The orbit is 127 units at MOON_UNIT_BLOCKS, so its radius is 25 400 blocks: half a turn puts
+ // the moon ~50 800 blocks from where it started, and one full turn puts it back.
+ double halfTurn = separation(start, afterHalf);
+ double fullTurn = separation(start, afterOnePeriod);
+ assertTrue("half a mass-derived period must carry the moon to the far side (was " + halfTurn + ")",
+ halfTurn > 40_000d);
+ assertTrue("one mass-derived period must bring it back (was " + fullTurn + ")",
+ fullTurn < 500d);
+ }
+
+ private static double separation(BlockDelta a, BlockDelta b) {
+ double dx = a.dx() - b.dx();
+ double dy = a.dy() - b.dy();
+ double dz = a.dz() - b.dz();
+ return Math.sqrt(dx * dx + dy * dy + dz * dz);
+ }
+
/**
* A body on the NEGATIVE side of its star belongs to that star's system, exactly like one on the
* positive side.
diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/AstronomicalBodyHelperTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/AstronomicalBodyHelperTest.java
index f0207781e..12d66b31a 100644
--- a/src/test/java/zmaster587/advancedRocketry/test/unit/AstronomicalBodyHelperTest.java
+++ b/src/test/java/zmaster587/advancedRocketry/test/unit/AstronomicalBodyHelperTest.java
@@ -86,10 +86,52 @@ public void blackHoleStarReducesBrightness() {
blackHole.setBlackHole(true);
double dimmed = AstronomicalBodyHelper.getStellarBrightness(blackHole, 100);
- // Implementation multiplies by 0.25 when the primary (and all sub-stars) are black holes.
+ // A black hole emits a quarter of what its size and temperature would otherwise give.
assertEquals(normal * 0.25, dimmed, 1e-9);
}
+ /**
+ * Every star in a system lights the worlds in it. Before this was true, the companion list was
+ * walked only to decide a boolean and no companion ever contributed a photon.
+ */
+ @Test
+ public void everyStarInASystemContributesItsOwnLight() {
+ double alone = AstronomicalBodyHelper.getStellarBrightness(sunLikeStar(), 100);
+
+ StellarBody binary = sunLikeStar();
+ binary.addSubStar(sunLikeStar());
+
+ assertEquals("two identical stars light a world twice as brightly as one does",
+ 2 * alone, AstronomicalBodyHelper.getStellarBrightness(binary, 100), 1e-9);
+ }
+
+ /**
+ * A companion does not repeal the primary's nature.
+ *
+ * The case this pins used to invert: any ordinary companion cleared the black-hole flag, after
+ * which the luminosity was taken from the BLACK HOLE's own size and temperature at FULL strength —
+ * so a black hole with a companion came out brighter than a bare one and lit by the wrong body,
+ * while the companion contributed nothing.
+ */
+ @Test
+ public void aCompanionDoesNotTurnABlackHoleBackIntoAStar() {
+ double sunAlone = AstronomicalBodyHelper.getStellarBrightness(sunLikeStar(), 100);
+
+ StellarBody bareHole = sunLikeStar();
+ bareHole.setBlackHole(true);
+ double holeAlone = AstronomicalBodyHelper.getStellarBrightness(bareHole, 100);
+
+ StellarBody holeWithCompanion = sunLikeStar();
+ holeWithCompanion.setBlackHole(true);
+ holeWithCompanion.addSubStar(sunLikeStar());
+ double together = AstronomicalBodyHelper.getStellarBrightness(holeWithCompanion, 100);
+
+ assertEquals("a black hole and its companion each light the world on their own terms",
+ holeAlone + sunAlone, together, 1e-9);
+ assertTrue("the hole stays dimmed: the pair is never as bright as two ordinary stars",
+ together < 2 * sunAlone);
+ }
+
@Test
public void planetaryLightLevelMultiplierBaselineIsOne() {
assertEquals(1.0, AstronomicalBodyHelper.getPlanetaryLightLevelMultiplier(1.0), 1e-9);
From 368461e57f88c7d42705611673353aa276ff3294 Mon Sep 17 00:00:00 2001
From: StannisMod
Date: Wed, 12 Aug 2026 09:21:28 +0300
Subject: [PATCH 06/42] fix: a year follows stellar mass, a day is drawn,
albedo comes from type
- key orbital periods on the star's mass, deriving M from R when unstated
- rewrite equilibrium temperature over the summed flux, unchanged per star
- give every planet type an albedo; ice reflects, lava and ocean do not
- delete the (1/g)^3 rotation law from both the realizer and the generator
- pin each as a contract test, including one gravity cannot satisfy
---
.../api/dimension/solar/StellarBody.java | 32 +++++++++
.../dimension/DimensionManager.java | 8 ++-
.../dimension/DimensionProperties.java | 26 ++++++-
.../universe/BodyProfile.java | 9 ++-
.../universe/PlanetDerivation.java | 35 +++++++++-
.../universe/PlanetRealizer.java | 13 ++--
.../universe/PlanetTypePreset.java | 20 ++++++
.../universe/PlanetTypes.java | 22 +++---
.../universe/SystemContent.java | 2 +-
.../util/AstronomicalBodyHelper.java | 67 ++++++++++++++-----
.../test/unit/AstronomicalBodyHelperTest.java | 55 ++++++++++++++-
.../test/unit/PlanetDerivationTest.java | 44 ++++++++++++
12 files changed, 290 insertions(+), 43 deletions(-)
diff --git a/src/main/java/zmaster587/advancedRocketry/api/dimension/solar/StellarBody.java b/src/main/java/zmaster587/advancedRocketry/api/dimension/solar/StellarBody.java
index 5a7e4bceb..87c86e24f 100644
--- a/src/main/java/zmaster587/advancedRocketry/api/dimension/solar/StellarBody.java
+++ b/src/main/java/zmaster587/advancedRocketry/api/dimension/solar/StellarBody.java
@@ -15,12 +15,18 @@
public class StellarBody {
+ /** Sentinel for {@link #mass}: nobody has stated one, so it follows from the radius. */
+ public static final float MASS_UNSET = 0f;
+ /** {@code M ≈ R^1.25} — the inverse of the main-sequence {@code R ≈ M^0.8}. Exact for Sol. */
+ private static final double MAIN_SEQUENCE_MASS_EXPONENT = 1.25d;
+
public List subStars;
int numPlanets;
int discoveredPlanets;
float[] color;
int id;
float size;
+ private float mass = MASS_UNSET;
String name;
short posX, posZ;
float starSeperation;
@@ -80,6 +86,27 @@ public void setSize(float size) {
this.size = size;
}
+ /**
+ * This star's mass in SOLAR MASSES — what an orbital law about it needs.
+ *
+ * Where nothing has stated one it is derived from the radius through the main-sequence relation
+ * {@code R ≈ M^0.8}, i.e. {@code M ≈ R^1.25}, which is exact for Sol and the honest reading of a
+ * star described only by its size. Mass and radius are NOT interchangeable anywhere else: Kepler's
+ * third law is {@code P ∝ a^1.5 / sqrt(M)}, and feeding it a radius made a 2 R☉ star's planets
+ * orbit 1.83× too fast and a 0.3 R☉ red dwarf's 2.87× too slowly.
+ */
+ public float getMass() {
+ if (mass > MASS_UNSET) {
+ return mass;
+ }
+ return (float) Math.pow(Math.max(0.01f, size), MAIN_SEQUENCE_MASS_EXPONENT);
+ }
+
+ /** State this star's mass in solar masses; {@link #MASS_UNSET} hands it back to the radius. */
+ public void setMass(float solarMasses) {
+ this.mass = Math.max(MASS_UNSET, solarMasses);
+ }
+
public int getPosX() {
return posX;
}
@@ -233,6 +260,9 @@ public void writeToNBT(NBTTagCompound nbt) {
nbt.setShort("posX", posX);
nbt.setShort("posZ", posZ);
nbt.setFloat("size", size);
+ if (mass > MASS_UNSET) {
+ nbt.setFloat("mass", mass);
+ }
nbt.setFloat("seperation", starSeperation);
nbt.setBoolean("isBlackHole", isBlackHole);
nbt.setFloat("diskAngle", diskAngle);
@@ -261,6 +291,8 @@ public void readFromNBT(NBTTagCompound nbt) {
if (nbt.hasKey("size"))
size = nbt.getFloat("size");
+ mass = nbt.hasKey("mass") ? nbt.getFloat("mass") : MASS_UNSET;
+
if (nbt.hasKey("seperation"))
starSeperation = nbt.getFloat("seperation");
diff --git a/src/main/java/zmaster587/advancedRocketry/dimension/DimensionManager.java b/src/main/java/zmaster587/advancedRocketry/dimension/DimensionManager.java
index 8ffc6471c..602321e14 100644
--- a/src/main/java/zmaster587/advancedRocketry/dimension/DimensionManager.java
+++ b/src/main/java/zmaster587/advancedRocketry/dimension/DimensionManager.java
@@ -337,8 +337,12 @@ public DimensionProperties generateRandom(int starId, String name, int baseAtmos
properties.ringColor[2] = properties.skyColor[2];
}
- properties.rotationalPeriod = (int) (Math.pow((1 / properties.gravitationalMultiplier), 3)
- * DimensionProperties.DEFAULT_ROTATIONAL_PERIOD);
+ // A day is DRAWN, log-uniform between a quarter and four times the default. It used to be
+ // (1/g)^3 * DEFAULT — a fabricated law that made spin a function of surface gravity, which has
+ // no bearing on it, so a half-gravity world got a day eight times longer than Earth's.
+ double spinFactor = 0.25d * Math.pow(16d, random.nextDouble());
+ properties.rotationalPeriod = (int) Math.max(1L, Math.round(spinFactor
+ * DimensionProperties.DEFAULT_ROTATIONAL_PERIOD));
properties.addBiomes(properties.getViableBiomes(true));
properties.initDefaultAttributes();
diff --git a/src/main/java/zmaster587/advancedRocketry/dimension/DimensionProperties.java b/src/main/java/zmaster587/advancedRocketry/dimension/DimensionProperties.java
index 4a5f87208..10ac98fb0 100644
--- a/src/main/java/zmaster587/advancedRocketry/dimension/DimensionProperties.java
+++ b/src/main/java/zmaster587/advancedRocketry/dimension/DimensionProperties.java
@@ -214,6 +214,12 @@ private static float clampFeatureFrequencyMultiplier(float multiplier) {
private double mass = BULK_UNSET;
/** This body's radius in Earth radii, or {@link #BULK_UNSET}. See {@link #mass}. */
private double radius = BULK_UNSET;
+ /**
+ * The fraction of incident light this world's surface reflects, 0..1 — stated by its TYPE and
+ * used to derive its temperature. Defaults to Earth's, so a world whose type says nothing keeps
+ * exactly the temperature it had when 0.3 was hard-coded into the formula.
+ */
+ private double albedo = AstronomicalBodyHelper.EARTH_ALBEDO;
/**
* Whether {@link #gravitationalMultiplier} was STATED rather than derived. The single bit that keeps
* "authored planets are unchanged" true: it is set by the XML element, by the public setter and by
@@ -549,6 +555,7 @@ public void resetProperties() {
laserDrillOres = new ArrayList<>();
mass = BULK_UNSET;
radius = BULK_UNSET;
+ albedo = AstronomicalBodyHelper.EARTH_ALBEDO;
gravityAuthored = false;
tidallyLocked = false;
metallicity = 1d;
@@ -568,6 +575,16 @@ public double getRadius() {
return radius;
}
+ /** The fraction of incident light this world reflects, 0..1. */
+ public double getAlbedo() {
+ return albedo;
+ }
+
+ /** State this world's albedo; clamped to 0..1. */
+ public void setAlbedo(double a) {
+ this.albedo = Math.min(Math.max(a, 0d), 1d);
+ }
+
public boolean hasBulkProperties() {
return mass > BULK_UNSET && radius > BULK_UNSET;
}
@@ -1853,6 +1870,7 @@ else if (nbt.hasKey("biomes", NBT.TAG_INT_ARRAY)) {
// saved before planets had a mass reloads with exactly the gravity it already had.
mass = nbt.hasKey("mass") ? nbt.getDouble("mass") : BULK_UNSET;
radius = nbt.hasKey("radius") ? nbt.getDouble("radius") : BULK_UNSET;
+ albedo = nbt.hasKey("albedo") ? nbt.getDouble("albedo") : AstronomicalBodyHelper.EARTH_ALBEDO;
gravityAuthored = nbt.getBoolean("gravityAuthored");
tidallyLocked = nbt.getBoolean("tidallyLocked");
metallicity = nbt.hasKey("metallicity") ? nbt.getDouble("metallicity") : 1d;
@@ -2245,6 +2263,9 @@ public void writeToNBT(NBTTagCompound nbt) {
if (radius > BULK_UNSET) {
nbt.setDouble("radius", radius);
}
+ if (albedo != AstronomicalBodyHelper.EARTH_ALBEDO) {
+ nbt.setDouble("albedo", albedo);
+ }
if (gravityAuthored) {
nbt.setBoolean("gravityAuthored", true);
}
@@ -2347,7 +2368,8 @@ public void writeToNBT(NBTTagCompound nbt) {
*/
@Override
public int getAverageTemp() {
- averageTemperature = AstronomicalBodyHelper.getAverageTemperature(this.getStar(), this.getSolarOrbitalDistance(), this.getAtmosphereDensity());
+ averageTemperature = AstronomicalBodyHelper.getAverageTemperature(this.getStar(),
+ this.getSolarOrbitalDistance(), this.getAtmosphereDensity(), this.albedo);
/*
int temp = averageTemperature;
@@ -2458,7 +2480,7 @@ public double orbitThetaAt(long worldTick) {
} else {
StellarBody host = getStar();
if (host != null) {
- theta = AstronomicalBodyHelper.getOrbitalThetaAt(orbitalDist, host.getSize(), worldTick);
+ theta = AstronomicalBodyHelper.getOrbitalThetaAt(orbitalDist, host.getMass(), worldTick);
}
}
return (theta + baseOrbitTheta) * (isRetrograde ? -1 : 1);
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/BodyProfile.java b/src/main/java/zmaster587/advancedRocketry/universe/BodyProfile.java
index a731630d3..306d6daa4 100644
--- a/src/main/java/zmaster587/advancedRocketry/universe/BodyProfile.java
+++ b/src/main/java/zmaster587/advancedRocketry/universe/BodyProfile.java
@@ -33,11 +33,12 @@ public final class BodyProfile {
private final boolean hasRings;
private final double metallicity;
private final TerrainOption terrain;
+ private final int rotationalPeriodTicks;
public BodyProfile(SystemBodyKind kind, String typeName, PlanetTypePreset preset, int orbitalDistance,
double massEarths, double radiusEarths, int gravityPercent, int pressure,
int temperatureKelvin, boolean hasOxygen, boolean tidallyLocked, boolean hasRings,
- double metallicity, TerrainOption terrain) {
+ double metallicity, TerrainOption terrain, int rotationalPeriodTicks) {
this.kind = kind;
this.typeName = typeName;
this.preset = preset;
@@ -52,6 +53,7 @@ public BodyProfile(SystemBodyKind kind, String typeName, PlanetTypePreset preset
this.hasRings = hasRings;
this.metallicity = metallicity;
this.terrain = terrain;
+ this.rotationalPeriodTicks = Math.max(1, rotationalPeriodTicks);
}
/** What this body is as an addressable object — planet, giant, moon or belt. */
@@ -75,6 +77,11 @@ public int orbitalDistance() {
}
/** Mass in Earth masses — PRIMARY, not derived from gravity. */
+ /** How long this body takes to turn once, in ticks; drawn from the seed, not derived from gravity. */
+ public int rotationalPeriodTicks() {
+ return rotationalPeriodTicks;
+ }
+
public double massEarths() {
return massEarths;
}
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/PlanetDerivation.java b/src/main/java/zmaster587/advancedRocketry/universe/PlanetDerivation.java
index a0a97654b..cc9e6d245 100644
--- a/src/main/java/zmaster587/advancedRocketry/universe/PlanetDerivation.java
+++ b/src/main/java/zmaster587/advancedRocketry/universe/PlanetDerivation.java
@@ -53,6 +53,14 @@ public final class PlanetDerivation {
private static final long SALT_TERRAIN = 0x28L;
private static final long SALT_OXYGEN = 0x29L;
private static final long SALT_RINGS = 0x2AL;
+ private static final long SALT_SPIN = 0x2BL;
+
+ /** A rocky world's day, as a multiple of the default, log-uniform between these. */
+ private static final double SPIN_ROCKY_MIN = 0.25d;
+ private static final double SPIN_ROCKY_MAX = 4.0d;
+ /** Giants spin fast — a real correlation, unlike the gravity law this replaces. */
+ private static final double SPIN_GIANT_MIN = 0.20d;
+ private static final double SPIN_GIANT_MAX = 0.60d;
/**
* The temperature, in Kelvin, that defines a star's REFERENCE distance — Earth's equilibrium
@@ -289,11 +297,36 @@ public static BodyProfile derive(long seed, GalacticCoord anchor, GalacticCoord
&& CellHash.norm(CellHash.ofBody(seed, key, variant, SALT_RINGS))
< (giant ? RING_CHANCE_GIANT : RING_CHANCE_ROCKY);
+ int spin = rotationalPeriodOf(seed, key, variant, giant);
+
SystemBodyKind kind = giant ? SystemBodyKind.GAS_GIANT
: (moon ? SystemBodyKind.MOON : SystemBodyKind.PLANET);
return new BodyProfile(kind, preset == null ? PlanetTypes.UNCLASSIFIED : preset.name(), preset,
orbitalDistance, mass, radius, gravityPercent, pressure, temperature, oxygen, locked,
- rings, metallicity, terrain);
+ rings, metallicity, terrain, spin);
+ }
+
+ /**
+ * How long this body takes to turn once, in ticks.
+ *
+ * DRAWN, not derived — and that is the honest answer. A planet's spin comes from how it
+ * accreted and what has since torqued it; nothing else this derivation knows predicts it. What it
+ * replaces was worse than a draw: {@code (1/g)^3 * DEFAULT} made the day a function of SURFACE
+ * GRAVITY, which has no bearing on rotation at all, so a half-gravity world got a day eight times
+ * longer. A drawn number is honest; a fabricated law that looks derived is not.
+ *
+ * Log-uniform across the band, so short and long days are equally likely by ratio rather than
+ * by difference. Giants spin fast, which IS a real correlation — angular momentum shed to a large
+ * envelope — so they take a tighter, faster band. Tidal locking overrides this entirely and is
+ * applied where the body is realized.
+ */
+ static int rotationalPeriodOf(long seed, GalacticCoord key, int variant, boolean giant) {
+ double lo = giant ? SPIN_GIANT_MIN : SPIN_ROCKY_MIN;
+ double hi = giant ? SPIN_GIANT_MAX : SPIN_ROCKY_MAX;
+ double u = CellHash.norm(CellHash.ofBody(seed, key, variant, SALT_SPIN));
+ double factor = lo * Math.pow(hi / lo, u);
+ long ticks = Math.round(factor * DimensionProperties.DEFAULT_ROTATIONAL_PERIOD);
+ return (int) Math.max(1L, Math.min(ticks, Integer.MAX_VALUE));
}
// ─── The individual laws ───────────────────────────────────────────────────
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/PlanetRealizer.java b/src/main/java/zmaster587/advancedRocketry/universe/PlanetRealizer.java
index f737e6880..bc3496580 100644
--- a/src/main/java/zmaster587/advancedRocketry/universe/PlanetRealizer.java
+++ b/src/main/java/zmaster587/advancedRocketry/universe/PlanetRealizer.java
@@ -187,6 +187,8 @@ private static DimensionProperties materialize(int dimId, BodyProfile profile, S
PlanetTypePreset preset = profile.preset();
if (preset != null) {
+ // The type states what the surface is made of, so it states how much light it throws back.
+ props.setAlbedo(preset.albedo());
if (!preset.biomes().isEmpty()) {
XMLPlanetLoader.applyBiomeList(props, preset.biomes());
}
@@ -232,19 +234,16 @@ private static void applyTerrain(DimensionProperties props, TerrainOption terrai
*/
private static int rotationalPeriodOf(BodyProfile profile, StellarBody star) {
if (profile.tidallyLocked()) {
- double days = AstronomicalBodyHelper.getOrbitalPeriod(profile.orbitalDistance(), star.getSize());
+ double days = AstronomicalBodyHelper.getOrbitalPeriod(profile.orbitalDistance(), star.getMass());
double ticks = days * AstronomicalBodyHelper.TICKS_PER_DAY;
if (!(ticks > 0d) || ticks > Integer.MAX_VALUE) {
return Integer.MAX_VALUE;
}
return (int) ticks;
}
- double gravity = Math.max(0.05d, profile.gravityPercent() / 100d);
- double period = Math.pow(1d / gravity, 3) * DimensionProperties.DEFAULT_ROTATIONAL_PERIOD;
- if (!(period > 0d) || period > Integer.MAX_VALUE) {
- return DimensionProperties.DEFAULT_ROTATIONAL_PERIOD;
- }
- return Math.max(1, (int) period);
+ // Spin is a property of the body, drawn where every other one is derived. It used to be
+ // computed here from surface GRAVITY, which does not bear on rotation at all.
+ return profile.rotationalPeriodTicks();
}
/** The angle of a body's cell about its system's anchor, in radians. */
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/PlanetTypePreset.java b/src/main/java/zmaster587/advancedRocketry/universe/PlanetTypePreset.java
index b2f01d19c..bd4c62ae7 100644
--- a/src/main/java/zmaster587/advancedRocketry/universe/PlanetTypePreset.java
+++ b/src/main/java/zmaster587/advancedRocketry/universe/PlanetTypePreset.java
@@ -4,6 +4,7 @@
import java.util.Collections;
import java.util.List;
+import zmaster587.advancedRocketry.util.AstronomicalBodyHelper;
import zmaster587.advancedRocketry.util.OreGenProperties;
/**
@@ -44,6 +45,7 @@ public final class PlanetTypePreset {
private final List terrain;
private final String biomes;
private final OreGenProperties oreProperties;
+ private final double albedo;
private PlanetTypePreset(Builder b) {
this.name = b.name;
@@ -64,6 +66,17 @@ private PlanetTypePreset(Builder b) {
: Collections.unmodifiableList(new ArrayList<>(b.terrain));
this.biomes = b.biomes == null ? "" : b.biomes.trim();
this.oreProperties = b.oreProperties;
+ this.albedo = Math.min(Math.max(b.albedo, 0d), 1d);
+ }
+
+ /**
+ * The fraction of incident light this kind of world reflects, 0..1 — what its temperature is
+ * actually derived from, in place of the single hard-coded 0.3 that used to stand for every
+ * surface. It belongs to the type because the type IS the statement of what the surface is made
+ * of, and it closes physically: high albedo means a colder world, which is why ice stays ice.
+ */
+ public double albedo() {
+ return albedo;
}
/** The type's name — what a scan reports and what a pack overrides by. */
@@ -206,6 +219,7 @@ public static final class Builder {
private final List terrain = new ArrayList<>();
private String biomes = "";
private OreGenProperties oreProperties;
+ private double albedo = AstronomicalBodyHelper.EARTH_ALBEDO;
private Builder(String name) {
this.name = name == null ? "" : name.trim();
@@ -277,6 +291,12 @@ public Builder ores(OreGenProperties ores) {
return this;
}
+ /** 0..1; defaults to Earth's, so a type that says nothing behaves as it did before. */
+ public Builder albedo(double a) {
+ this.albedo = a;
+ return this;
+ }
+
public PlanetTypePreset build() {
return new PlanetTypePreset(this);
}
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/PlanetTypes.java b/src/main/java/zmaster587/advancedRocketry/universe/PlanetTypes.java
index 09c03dfe8..326d9b2bd 100644
--- a/src/main/java/zmaster587/advancedRocketry/universe/PlanetTypes.java
+++ b/src/main/java/zmaster587/advancedRocketry/universe/PlanetTypes.java
@@ -201,7 +201,7 @@ public static List stockPresets() {
// The commonest body class of all — every airless moon, Mercury. Defined by having no air at
// all, which is why its pressure band is the tight one and its temperature band is not: an
// airless rock is as plausible baking beside its star as frozen far from it.
- l.add(PlanetTypePreset.builder("barren").weight(30)
+ l.add(PlanetTypePreset.builder("barren").albedo(0.12d).weight(30)
.pressure(0, 25).temperature(0, 1500).gravity(1, 90)
.biomes("advancedrocketry:moon;30,advancedrocketry:moondark;20")
.terrain(TerrainOption.ofNative(0, 1))
@@ -209,7 +209,7 @@ public static List stockPresets() {
// Everything past the snow line, thin-aired or thick: Europa and Titan are the same class of
// world, and which of the two you get is how much nitrogen the gravity managed to keep.
- l.add(PlanetTypePreset.builder("ice").weight(22)
+ l.add(PlanetTypePreset.builder("ice").albedo(0.60d).weight(22)
.pressure(0, 1600).temperature(0, 200).gravity(1, 400)
.biomes("advancedrocketry:moondark;10,minecraft:ice_flats;30,minecraft:ice_mountains;20")
.terrain(TerrainOption.ofNative(0, 1))
@@ -217,7 +217,7 @@ public static List stockPresets() {
// Tight inner orbits, common around M dwarfs. A molten surface under whatever the rock itself
// boiled off, which can be a great deal — hence no pressure ceiling.
- l.add(PlanetTypePreset.builder("lava").weight(12)
+ l.add(PlanetTypePreset.builder("lava").albedo(0.10d).weight(12)
.pressure(0, 1600).temperature(700, 6000).gravity(5, 400)
.biomes("advancedrocketry:volcanic;30,advancedrocketry:volcanicbarren;20,"
+ "advancedrocketry:hotdryrock;10")
@@ -226,7 +226,7 @@ public static List stockPresets() {
// Venus-like, and likely common in the hot zone: a thick atmosphere doing the warming, which is
// why the band is keyed on the PRESSURE floor rather than on where the world orbits.
- l.add(PlanetTypePreset.builder("greenhouse").weight(14)
+ l.add(PlanetTypePreset.builder("greenhouse").albedo(0.75d).weight(14)
.pressure(150, 1600).temperature(275, 1000).gravity(20, 400)
.biomes("advancedrocketry:hotdryrock;30,advancedrocketry:volcanicbarren;10")
.terrain(TerrainOption.ofNative(0, 1))
@@ -234,21 +234,21 @@ public static List stockPresets() {
// The commonest planet class in the galaxy, and absent from the Solar System entirely. Defined
// by MASS, not by climate: a super-Earth is one whether it is frozen or baked.
- l.add(PlanetTypePreset.builder("superearth").weight(16)
+ l.add(PlanetTypePreset.builder("superearth").albedo(0.30d).weight(16)
.pressure(0, 1600).temperature(0, 900).gravity(160, 400)
.biomes("advancedrocketry:stormland;30,advancedrocketry:hotdryrock;10")
.terrain(TerrainOption.ofNative(0, 1))
.build());
// A common end state of water loss: warm, dry, and holding just enough air to blow it around.
- l.add(PlanetTypePreset.builder("desert").weight(16)
+ l.add(PlanetTypePreset.builder("desert").albedo(0.30d).weight(16)
.pressure(0, 200).temperature(200, 700).gravity(10, 200)
.biomes("advancedrocketry:hotdryrock;30,minecraft:desert;20,minecraft:mesa;10")
.terrain(TerrainOption.ofNative(0, 1))
.build());
// Hypothesised but plausible: no exposed continent worth the name, and a deep global sea.
- l.add(PlanetTypePreset.builder("ocean").weight(7).allowsOxygen(true)
+ l.add(PlanetTypePreset.builder("ocean").albedo(0.10d).weight(7).allowsOxygen(true)
.pressure(60, 400).temperature(255, 380).gravity(50, 190)
.seaLevel(96)
.biomes("advancedrocketry:oceanspires;30,minecraft:deep_ocean;30,minecraft:ocean;20")
@@ -257,7 +257,7 @@ public static List stockPresets() {
// Life without oxygen — the crystal / stormland / alien-forest biomes, all written and nearly
// unused today. Deliberately narrow: a find, not a background.
- l.add(PlanetTypePreset.builder("exotic").weight(5)
+ l.add(PlanetTypePreset.builder("exotic").albedo(0.30d).weight(5)
.pressure(40, 1600).temperature(200, 430).gravity(10, 220)
.biomes("advancedrocketry:crystalchasms;30,advancedrocketry:stormland;20,"
+ "advancedrocketry:alien_forest;10")
@@ -265,7 +265,7 @@ public static List stockPresets() {
.build());
// Very rare, and rare on purpose: the conjunction is physics, the oxygen on top is biology.
- l.add(PlanetTypePreset.builder("earthlike").weight(3).allowsOxygen(true)
+ l.add(PlanetTypePreset.builder("earthlike").albedo(0.30d).weight(3).allowsOxygen(true)
.pressure(50, 220).temperature(255, 325).gravity(60, 145)
.biomes("minecraft:plains;30,minecraft:forest;25,minecraft:extreme_hills;15,"
+ "minecraft:ocean;15,advancedrocketry:marsh;10")
@@ -275,13 +275,13 @@ public static List stockPresets() {
// ~10-20% of stars. A real destination — fuel skimming and moons — but never a landing.
// Its bands are deliberately the widest in the table: a giant is a giant, and nothing else in
// this list will ever admit one, so a gap here would leave a whole body class untyped.
- l.add(PlanetTypePreset.builder("gasgiant").weight(14).gasGiant(true).tidallyLockable(false)
+ l.add(PlanetTypePreset.builder("gasgiant").albedo(0.50d).weight(14).gasGiant(true).tidallyLockable(false)
.pressure(0, 1600).temperature(0, 1500).gravity(1, 400)
.terrain(TerrainOption.ofNative(0, 1))
.build());
// Neptune and Uranus: the same, further out and colder.
- l.add(PlanetTypePreset.builder("icegiant").weight(9).gasGiant(true).tidallyLockable(false)
+ l.add(PlanetTypePreset.builder("icegiant").albedo(0.50d).weight(9).gasGiant(true).tidallyLockable(false)
.pressure(0, 1600).temperature(0, 250).gravity(1, 300)
.terrain(TerrainOption.ofNative(0, 1))
.build());
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/SystemContent.java b/src/main/java/zmaster587/advancedRocketry/universe/SystemContent.java
index 7736cb920..61ebb0eda 100644
--- a/src/main/java/zmaster587/advancedRocketry/universe/SystemContent.java
+++ b/src/main/java/zmaster587/advancedRocketry/universe/SystemContent.java
@@ -183,7 +183,7 @@ public static List bodiesOf(StellarBody star, GalacticCoord systemCo
private static BodyEphemeris orbitLawOf(DimensionProperties planet, StellarBody star) {
double periodTicks = star == null ? 0d
: TICKS_PER_DAY * AstronomicalBodyHelper.getOrbitalPeriod(planet.getOrbitalDist(),
- star.getSize());
+ star.getMass());
return BodyEphemeris.orbit(planet.getOrbitalDist(), planet.baseOrbitTheta, planet.orbitalPhi,
planet.isRetrograde, periodTicks, ORBIT_UNIT_BLOCKS);
}
diff --git a/src/main/java/zmaster587/advancedRocketry/util/AstronomicalBodyHelper.java b/src/main/java/zmaster587/advancedRocketry/util/AstronomicalBodyHelper.java
index b1647d8f3..c852e3945 100644
--- a/src/main/java/zmaster587/advancedRocketry/util/AstronomicalBodyHelper.java
+++ b/src/main/java/zmaster587/advancedRocketry/util/AstronomicalBodyHelper.java
@@ -25,6 +25,20 @@ public class AstronomicalBodyHelper {
public static final int KELVIN_PER_STAR_TEMPERATURE_UNIT = 58;
/** Solar radii in one astronomical unit — carries a star's size into the distance frame. */
public static final int SOLAR_RADII_PER_AU = 215;
+ /**
+ * Earth's albedo — the reflectivity a world is assumed to have when its type has not stated one.
+ * It was hard-coded into the temperature formula with a comment saying it could not easily be
+ * calculated; a planet's TYPE knows what its surface is made of, so most callers can do better.
+ */
+ public static final double EARTH_ALBEDO = 0.3d;
+ /**
+ * The bare (zero-albedo) equilibrium temperature at one AU from Sol, in Kelvin — the anchor the
+ * flux form scales from. DERIVED from the constants above rather than written as a literal, so it
+ * cannot drift away from them: {@code T☉ · sqrt(R☉ / 2 AU)}.
+ */
+ private static final double REFERENCE_EQUILIBRIUM_K =
+ (double) KELVIN_PER_STAR_TEMPERATURE_UNIT * TEMPERATURE_UNITS_PER_SOL
+ * Math.sqrt(1d / (2d * SOLAR_RADII_PER_AU));
// ─── The calendar ──────────────────────────────────────────────────────────
// Inherited from upstream: "One MC Year is 48 MC days (16 IRL Hours), one month is 8 MC Days".
@@ -34,7 +48,7 @@ public class AstronomicalBodyHelper {
// as a fraction of the year: changing one does NOT change the other. If that relation is ever
// meant to hold, encode it deliberately and record the decision here.
- /** Days in one year: the orbital period one AU from a size-1 star. */
+ /** Days in one year: the orbital period one AU from a mass-1 star. */
public static final int DAYS_PER_YEAR = 48;
/** Days in one lunar month: a moon's period at the reference distance from a mass-1 parent. */
public static final int DAYS_PER_LUNAR_MONTH = 8;
@@ -55,14 +69,21 @@ public static float getBodySizeMultiplier(float orbitalDistance) {
/**
* Returns the orbital period for a body at a given distance around its star
*
+ * The second argument is the star's MASS in solar masses. Kepler's third law is
+ * {@code P ∝ a^1.5 / sqrt(M)}; callers used to pass the star's RADIUS, giving
+ * {@code P ∝ a^1.5 / R^1.5}. Sol is exact because its mass and radius are both 1, and everything
+ * else was wrong by {@code R^1.5/sqrt(M)} — a 2 R☉ star's year came out 1.83× too short and a
+ * 0.3 R☉ red dwarf's 2.87× too long, and red dwarfs carry most of the close-in habitable worlds.
+ * {@link StellarBody#getMass()} derives a mass from the radius where none is stated.
+ *
* @param orbitalDistance the distance from the parent body
- * @param solarSize the size of the sun in question
+ * @param starMassSolar the mass of the star in question, in solar masses
* @return the orbital period in MC Days (24000 ticks)
*/
- public static double getOrbitalPeriod(int orbitalDistance, float solarSize) {
+ public static double getOrbitalPeriod(int orbitalDistance, float starMassSolar) {
//One MC Year is 48 MC days (16 IRL Hours), one month is 8 MC Days
return DAYS_PER_YEAR
- * Math.pow(Math.pow((orbitalDistance / ((double) DISTANCE_UNITS_PER_AU * solarSize)), 3), 0.5d);
+ * Math.pow(Math.pow(orbitalDistance / (double) DISTANCE_UNITS_PER_AU, 3) / starMassSolar, 0.5d);
}
/**
@@ -90,11 +111,11 @@ public static double getMoonOrbitalPeriod(float orbitalDistance, float planetary
* Returns the orbital theta for a body at a given distance around its star, at this current moment
*
* @param orbitalDistance the distance from the parent body
- * @param solarSize the size of the sun in question
+ * @param starMassSolar the mass of the star in question, in solar masses
* @return the current angle around the star in radians
*/
- public static double getOrbitalTheta(int orbitalDistance, float solarSize) {
- return getOrbitalThetaAt(orbitalDistance, solarSize, AdvancedRocketry.proxy.getWorldTimeUniversal(0));
+ public static double getOrbitalTheta(int orbitalDistance, float starMassSolar) {
+ return getOrbitalThetaAt(orbitalDistance, starMassSolar, AdvancedRocketry.proxy.getWorldTimeUniversal(0));
}
/**
@@ -105,10 +126,10 @@ public static double getOrbitalTheta(int orbitalDistance, float solarSize) {
*
* @return the angle around the star in RADIANS
*/
- public static double getOrbitalThetaAt(int orbitalDistance, float solarSize, long worldTick) {
- double periodTicks = (double) TICKS_PER_DAY * getOrbitalPeriod(orbitalDistance, solarSize);
+ public static double getOrbitalThetaAt(int orbitalDistance, float starMassSolar, long worldTick) {
+ double periodTicks = (double) TICKS_PER_DAY * getOrbitalPeriod(orbitalDistance, starMassSolar);
if (!(periodTicks > 0d) || Double.isInfinite(periodTicks)) {
- // A degenerate orbit (zero distance, or a star with no size recorded) does not move.
+ // A degenerate orbit (zero distance, or a star with no mass recorded) does not move.
// Answering 0 keeps it addressable instead of handing every caller a NaN coordinate.
return 0d;
}
@@ -173,12 +194,26 @@ public static float getParentPlanetThetaFromMoon(int rotationalPeriod, int orbit
* @return the temperature of the planet in Kelvin
*/
public static int getAverageTemperature(StellarBody star, int orbitalDistance, int atmPressure) {
- int starSurfaceTemperature = KELVIN_PER_STAR_TEMPERATURE_UNIT * star.getTemperature();
- float starRadius = star.getSize() / (float) SOLAR_RADII_PER_AU;
- //Gives output in AU
- float planetaryOrbitalRadius = orbitalDistance / (float) DISTANCE_UNITS_PER_AU;
- //Albedo is 0.3f hardcoded because of inability to easily calculate
- double averageWithoutAtmosphere = starSurfaceTemperature * Math.pow(starRadius / (2 * planetaryOrbitalRadius), 0.5) * Math.pow((1f - 0.3f), 0.25);
+ return getAverageTemperature(star, orbitalDistance, atmPressure, EARTH_ALBEDO);
+ }
+
+ /**
+ * The same, for a world whose ALBEDO is known — which is the one a planet's type states.
+ *
+ * This is the grey body written over the flux that {@link #getStellarBrightness} already
+ * computes, rather than a second copy of the same arithmetic: {@code T = T₀ · (E·(1−a))^¼}, with
+ * {@code T₀} the bare equilibrium temperature at 1 AU from Sol. Algebraically identical to the
+ * per-star form it replaces — expand {@code E} for a single star and the radii and temperatures
+ * cancel exactly — so no world's temperature moves. What it buys is that {@code E} is a SUM over
+ * every star in the system, so a binary's worlds are warmed by both without a second code path.
+ *
+ * @param albedo the fraction of incident light the surface reflects, 0..1
+ */
+ public static int getAverageTemperature(StellarBody star, int orbitalDistance, int atmPressure,
+ double albedo) {
+ double flux = getStellarBrightness(star, orbitalDistance);
+ double absorbed = flux * (1d - Math.min(Math.max(albedo, 0d), 1d));
+ double averageWithoutAtmosphere = REFERENCE_EQUILIBRIUM_K * Math.pow(absorbed, 0.25d);
//Slightly kludgey solution that works out mostly for Venus and well for Earth, without being overly complex
//Output is in Kelvin
return (int) (averageWithoutAtmosphere
diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/AstronomicalBodyHelperTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/AstronomicalBodyHelperTest.java
index 12d66b31a..4e3aceec9 100644
--- a/src/test/java/zmaster587/advancedRocketry/test/unit/AstronomicalBodyHelperTest.java
+++ b/src/test/java/zmaster587/advancedRocketry/test/unit/AstronomicalBodyHelperTest.java
@@ -204,12 +204,63 @@ public void planetaryLightMultiplierWithinExpectedBounds() {
// search-and-replace can silently corrupt one of them.
// ─────────────────────────────────────────────────────────────────────────────
+ /**
+ * A world's temperature follows its ALBEDO, which its type states. The formula used to hard-code
+ * 0.3 for every surface, so an ice world and a lava world at the same distance were the same
+ * temperature — and the physical direction matters: more reflective means colder, which is what
+ * keeps ice being ice.
+ */
+ @Test
+ public void albedoCoolsAWorldAndTheDefaultIsEarths() {
+ StellarBody star = sunLikeStar();
+ int dark = AstronomicalBodyHelper.getAverageTemperature(star, 100, 0, 0.10d);
+ int earthLike = AstronomicalBodyHelper.getAverageTemperature(star, 100, 0, 0.30d);
+ int icy = AstronomicalBodyHelper.getAverageTemperature(star, 100, 0, 0.60d);
+
+ assertTrue("a darker surface absorbs more and runs hotter", dark > earthLike);
+ assertTrue("a more reflective surface runs colder", icy < earthLike);
+ assertEquals("the albedo-less form must still mean Earth's albedo",
+ AstronomicalBodyHelper.getAverageTemperature(star, 100, 0), earthLike);
+ }
+
@Test
public void orbitalPeriodFollowsTheThreeHalvesPowerLawExactly() {
// Four times the distance is eight times the period.
assertEquals(384.0, AstronomicalBodyHelper.getOrbitalPeriod(400, 1.0f), 1e-9);
- // A bigger star pulls the same distance into a shorter year.
- assertEquals(31.176914536239792, AstronomicalBodyHelper.getOrbitalPeriod(150, 2.0f), 1e-9);
+ // A heavier star pulls the same distance into a shorter year, as sqrt(M) — Kepler's third law,
+ // P = 48 * a^1.5 / sqrt(M) = 48 * 1.5^1.5 / sqrt(2). The second argument is a MASS in solar
+ // masses; while it was read as a RADIUS this line expected 31.176914536239792, i.e. 1.5^1.5/2^1.5.
+ assertEquals(62.353829072479584, AstronomicalBodyHelper.getOrbitalPeriod(150, 2.0f), 1e-9);
+ }
+
+ /**
+ * A star's year is set by its MASS. A star that states no mass supplies one from its radius through
+ * the main-sequence relation, which is exact for Sol — and is emphatically not the radius itself.
+ */
+ @Test
+ public void aYearIsKeyedOnStellarMassAndAStarWithoutOneDerivesItFromItsRadius() {
+ StellarBody sol = sunLikeStar(); // size 1.0
+ assertEquals("Sol's mass and radius are both 1, so nothing can tell them apart here",
+ 1.0, sol.getMass(), 1e-6);
+ assertEquals(48.0, AstronomicalBodyHelper.getOrbitalPeriod(100, sol.getMass()), 1e-9);
+
+ StellarBody big = sunLikeStar();
+ big.setSize(2.0f);
+ // R = 2 gives M = 2^1.25 = 2.3784, so the year is 48/sqrt(2.3784) days. The mass is a float, so
+ // the exact figure below carries that narrowing — deliberately, per this file's header.
+ assertEquals(2.378414230005442, big.getMass(), 1e-6);
+ assertEquals(31.124149808586335, AstronomicalBodyHelper.getOrbitalPeriod(100, big.getMass()), 1e-9);
+ // A star two Sol-radii across is HEAVIER than two solar masses, so keying the year on its mass
+ // gives a shorter year than substituting the radius would. Any star but Sol separates the two.
+ assertTrue("a two-radius star masses more than two Suns", big.getMass() > big.getSize());
+ assertTrue("so its year is shorter than a radius substitution gives",
+ AstronomicalBodyHelper.getOrbitalPeriod(100, big.getMass())
+ < AstronomicalBodyHelper.getOrbitalPeriod(100, big.getSize()));
+
+ StellarBody stated = sunLikeStar();
+ stated.setSize(2.0f);
+ stated.setMass(4.0f);
+ assertEquals("a stated mass wins over the derivation", 4.0, stated.getMass(), 1e-6);
}
@Test
diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetDerivationTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetDerivationTest.java
index 491a93369..7aa5c4a2f 100644
--- a/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetDerivationTest.java
+++ b/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetDerivationTest.java
@@ -76,6 +76,50 @@ private static List system(long seed, GalacticCoord anchor, Stellar
return out;
}
+ /**
+ * A world's DAY is drawn, and it is not a function of its gravity.
+ *
+ * The law this replaced was {@code (1/g)^3 * DEFAULT}: spin computed from SURFACE GRAVITY, which
+ * has no bearing on rotation, so a half-gravity world got a day eight times longer. The pin that
+ * catches a return to it is two bodies with the SAME gravity and DIFFERENT days — impossible under
+ * any function of gravity alone, and cheap to find across a spread of seeds.
+ */
+ @Test
+ public void aDayIsDrawnAndIsNotAFunctionOfGravity() {
+ GalacticCoord anchor = cell(600, 0, 0);
+ StellarBody s = sol();
+ Map spinByGravity = new HashMap<>();
+ boolean sameGravityDifferentDay = false;
+ int seen = 0;
+
+ for (int i = 0; i < 400 && !sameGravityDifferentDay; i++) {
+ BodyProfile p = PlanetDerivation.derive(SEED + i, anchor, cell(600 + i, 7, 0), 0, s, false, 140);
+ int spin = p.rotationalPeriodTicks();
+ seen++;
+ assertTrue("a day must stay inside the drawn band: " + spin,
+ spin >= 24000 / 5 && spin <= 24000 * 5);
+ Integer earlier = spinByGravity.put(p.gravityPercent(), spin);
+ if (earlier != null && earlier.intValue() != spin) {
+ sameGravityDifferentDay = true;
+ }
+ }
+
+ assertTrue("the sweep must actually produce bodies", seen > 0);
+ assertTrue("two worlds of equal gravity must be able to have different days;"
+ + " if none did in " + seen + " bodies, spin is a function of gravity again",
+ sameGravityDifferentDay);
+ }
+
+ /** The same body answers the same day twice — a draw, not a random. */
+ @Test
+ public void aDrawnDayIsStillDeterministic() {
+ GalacticCoord anchor = cell(610, 0, 0);
+ StellarBody s = sol();
+ BodyProfile a = PlanetDerivation.derive(SEED, anchor, cell(611, 2, 0), 0, s, false, 150);
+ BodyProfile b = PlanetDerivation.derive(SEED, anchor, cell(611, 2, 0), 0, s, false, 150);
+ assertEquals(a.rotationalPeriodTicks(), b.rotationalPeriodTicks());
+ }
+
// ─── Determinism ───────────────────────────────────────────────────────────
@Test
From ab8e05f66378655dccd68ed36f98add89da31704 Mon Sep 17 00:00:00 2001
From: StannisMod
Date: Wed, 12 Aug 2026 10:11:52 +0300
Subject: [PATCH 07/42] fix: unswap the generator's atmosphere and distance,
realize a moon as a moon
- the legacy generator fed its atmosphere table into orbital distance
- both quantities sit near 100, which is why the swap stayed invisible
- a realized procedural moon had no parent, so isMoon was false forever
- it stood at its parent's own orbit and every moon path was dead for it
- take a moon's own distance from its ephemeris, the only place it lives
---
.../dimension/DimensionManager.java | 13 +++++-
.../universe/BodyEphemeris.java | 10 +++++
.../universe/PlanetRealizer.java | 30 ++++++++++++-
.../advancedRocketry/universe/SystemBody.java | 5 +++
.../test/unit/PlanetRealizationTest.java | 43 +++++++++++++++++++
5 files changed, 97 insertions(+), 4 deletions(-)
diff --git a/src/main/java/zmaster587/advancedRocketry/dimension/DimensionManager.java b/src/main/java/zmaster587/advancedRocketry/dimension/DimensionManager.java
index 602321e14..0ab04200a 100644
--- a/src/main/java/zmaster587/advancedRocketry/dimension/DimensionManager.java
+++ b/src/main/java/zmaster587/advancedRocketry/dimension/DimensionManager.java
@@ -773,7 +773,12 @@ private List generateRandomPlanets(StellarBody star, int nu
int baseAtm = 180;
int baseDistance = 100;
- DimensionProperties properties = DimensionManager.getInstance().generateRandomGasGiant(star.getId(), "", baseDistance + 50, baseAtm, 125, 100, 100, 75);
+ // Atmosphere first, then distance — the order the signature declares. These two arguments
+ // were swapped, and it was invisible because both quantities sit near 100 while meaning
+ // entirely different things (see AstronomicalBodyHelper's header: the distance, atmosphere
+ // and star-temperature scales are three separate 100s). A giant is thick-aired and far
+ // out; swapped, it came out thin-aired at 180 distance units.
+ DimensionProperties properties = DimensionManager.getInstance().generateRandomGasGiant(star.getId(), "", baseAtm, baseDistance + 50, 125, 100, 100, 75);
dimPropList.add(properties);
if (properties.gravitationalMultiplier >= 1f) {
@@ -805,7 +810,11 @@ private List generateRandomPlanets(StellarBody star, int nu
baseDistance = 30;
}
- DimensionProperties properties = DimensionManager.getInstance().generateRandom(star.getId(), baseDistance, baseAtm, 125, 100, 100, 75);
+ // Atmosphere first, then distance — see the gas-giant call above; the same two arguments
+ // were swapped here. The tables say what was meant: baseAtm is driven by i % 4 to 0 or 120
+ // (an atmosphere table, including the airless world every fourth planet was to be), and
+ // baseDistance by i % 3 to 170 or 30 (a distance table).
+ DimensionProperties properties = DimensionManager.getInstance().generateRandom(star.getId(), baseAtm, baseDistance, 125, 100, 100, 75);
if (properties == null) continue;
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/BodyEphemeris.java b/src/main/java/zmaster587/advancedRocketry/universe/BodyEphemeris.java
index 08e4e4eca..ed0a6af33 100644
--- a/src/main/java/zmaster587/advancedRocketry/universe/BodyEphemeris.java
+++ b/src/main/java/zmaster587/advancedRocketry/universe/BodyEphemeris.java
@@ -72,6 +72,16 @@ public static BodyEphemeris orbit(double distUnits, double baseTheta, double phi
}
/** {@code true} iff this law is time-invariant — the degenerate frame of a star, or of a void cell. */
+ /**
+ * The orbital distance this law was built with, in the caller's unit — for a moon, its distance
+ * from its PARENT, which lives nowhere else: {@code SystemBody.orbitalDistance()} deliberately
+ * holds the parent's distance from the star instead, because that is what a moon's climate
+ * depends on. Zero for a fixed law.
+ */
+ public double distUnits() {
+ return distUnits;
+ }
+
public boolean isStatic() {
return unitBlocks == 0L || !(periodTicks > 0d) || Double.isInfinite(periodTicks)
|| distUnits == 0d;
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/PlanetRealizer.java b/src/main/java/zmaster587/advancedRocketry/universe/PlanetRealizer.java
index bc3496580..4ebdd6119 100644
--- a/src/main/java/zmaster587/advancedRocketry/universe/PlanetRealizer.java
+++ b/src/main/java/zmaster587/advancedRocketry/universe/PlanetRealizer.java
@@ -92,6 +92,7 @@ public static int realize(MinecraftServer server, GalacticCoord bodyCell) {
List here = registry.bodiesAt(bodyCell);
SystemBody target = null;
+ SystemBody parentBody = null;
int variant = 0;
int seen = 0;
for (SystemBody body : here) {
@@ -99,6 +100,12 @@ public static int realize(MinecraftServer server, GalacticCoord bodyCell) {
|| body.kind() == SystemBodyKind.ASTEROID_BELT) {
continue;
}
+ // A moon shares its parent's cell, and the scan below can only reach one once the parent
+ // already HAS a dimension (an unrealized parent would be picked as the target first), so
+ // the parent found here is always realizable into a link.
+ if (parentBody == null && body.kind() != SystemBodyKind.MOON) {
+ parentBody = body;
+ }
// The variant is a body's rank among the worlds SHARING this cell, and it must be counted
// exactly the way the generator assigned it — a planet is 0 and its moons follow — or a
// realized moon would materialize a different world than the one that was scanned.
@@ -137,7 +144,7 @@ public static int realize(MinecraftServer server, GalacticCoord bodyCell) {
BodyProfile profile = PlanetDerivation.derive(registry.worldSeed(), anchor, target.name(), variant,
star, target.kind() == SystemBodyKind.MOON, target.orbitalDistance());
- DimensionProperties props = materialize(dimId, profile, star, anchor, target);
+ DimensionProperties props = materialize(dimId, profile, star, anchor, target, parentBody);
if (!DimensionManager.getInstance().registerDim(props, true)) {
LOGGER.error("[UNIVERSE] dimension {} was already registered while realizing {}", dimId,
@@ -162,12 +169,31 @@ public static int realize(MinecraftServer server, GalacticCoord bodyCell) {
* {@code Random}.
*/
private static DimensionProperties materialize(int dimId, BodyProfile profile, StellarBody star,
- GalacticCoord anchor, SystemBody body) {
+ GalacticCoord anchor, SystemBody body,
+ SystemBody parentBody) {
DimensionProperties props = new DimensionProperties(dimId);
props.setName(star.getName() + " " + dimId);
props.setStar(star);
props.orbitalDist = Math.max(DimensionProperties.MIN_DISTANCE, profile.orbitalDistance());
+ // A MOON must be realized as a moon. Without this it became a planet standing at its parent's
+ // exact orbit forever, and every moon-specific path — the parent-mass period law, the moon sky,
+ // the moon branch of orbitThetaAt — was dead for it, because isMoon() answered false.
+ // Its own distance from the parent lives in its ephemeris; profile.orbitalDistance() is the
+ // PARENT's distance from the star, which is what its climate is derived from and must stay.
+ if (body != null && body.kind() == SystemBodyKind.MOON && parentBody != null
+ && parentBody.dimId() != Constants.INVALID_PLANET) {
+ DimensionProperties parentProps =
+ DimensionManager.getInstance().getDimensionProperties(parentBody.dimId());
+ if (parentProps != null) {
+ int localOrbit = (int) Math.round(body.offsetLaw().distUnits());
+ props.orbitalDist = Math.max(DimensionProperties.MIN_DISTANCE, localOrbit);
+ props.setParentPlanet(parentProps);
+ } else {
+ LOGGER.warn("[UNIVERSE] moon {} realized without a parent: dim {} has no properties",
+ body.name().cellKey(), parentBody.dimId());
+ }
+ }
// The orbital angle is READ OFF the body's cell rather than drawn again, so the planet the sky
// shows and the planet the orbital elements describe are in the same place.
props.baseOrbitTheta = angleOf(anchor, body.name());
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/SystemBody.java b/src/main/java/zmaster587/advancedRocketry/universe/SystemBody.java
index 08586963d..7fe115cbd 100644
--- a/src/main/java/zmaster587/advancedRocketry/universe/SystemBody.java
+++ b/src/main/java/zmaster587/advancedRocketry/universe/SystemBody.java
@@ -101,6 +101,11 @@ private static GalacticCoord requireAddress(GalacticCoord address) {
* passing tick nor any amount of flight changes it, and membership of a cell is decided by
* comparing these.
*/
+ /** This body's motion law about its primary — see {@link BodyEphemeris#distUnits()}. */
+ public BodyEphemeris offsetLaw() {
+ return offsetLaw;
+ }
+
public GalacticCoord name() {
return name;
}
diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetRealizationTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetRealizationTest.java
index fdec9910c..5583fc1a7 100644
--- a/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetRealizationTest.java
+++ b/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetRealizationTest.java
@@ -87,6 +87,49 @@ public void theProceduralGalaxyOffersLandableBodiesThatHaveNoWorldYet() {
}
}
+ /**
+ * A moon carries TWO distances, and they are different numbers.
+ *
+ * {@code SystemBody.orbitalDistance()} deliberately holds the PARENT's distance from the star,
+ * because that is what a moon's climate depends on. Its own distance from the parent lives in its
+ * ephemeris and nowhere else — which is exactly what realization needs to write into a moon's
+ * {@code orbitalDist}, since that field means "from my parent" for a moon. If the generator ever
+ * stops carrying it, a realized moon silently lands on top of its parent again.
+ */
+ @Test
+ public void aMoonCarriesItsOwnDistanceFromItsParentSeparatelyFromItsParentsFromTheStar() {
+ UniverseRegistry reg = registryWithProceduralGalaxy();
+ SystemBody moon = null;
+ SystemBody itsParent = null;
+ outer:
+ for (long x = -8; x <= 8 && moon == null; x++) {
+ for (long y = -8; y <= 8; y++) {
+ for (long z = -8; z <= 8; z++) {
+ SystemBody parent = null;
+ for (SystemBody b : reg.bodiesAt(GalacticCoord.ofSectorLocal(x, y, z, 0L, 0L, 0L))) {
+ if (parent == null && b.kind() != SystemBodyKind.MOON && b.kind().canDescend()) {
+ parent = b;
+ } else if (b.kind() == SystemBodyKind.MOON && parent != null) {
+ moon = b;
+ itsParent = parent;
+ break outer;
+ }
+ }
+ }
+ }
+ }
+ assertNotNull("the procedural galaxy must produce a moon to test with", moon);
+ assertNotNull(itsParent);
+
+ double ownDistance = moon.offsetLaw().distUnits();
+ assertTrue("a moon's own distance from its parent must be a real, positive number: " + ownDistance,
+ ownDistance > 0d);
+ assertEquals("a moon's orbitalDistance() is its PARENT's distance from the star",
+ itsParent.orbitalDistance(), moon.orbitalDistance());
+ assertNotEquals("the two distances must not be the same number, or the seam is undetectable",
+ (double) moon.orbitalDistance(), ownDistance, 1e-9);
+ }
+
@Test
public void realizingABodyMakesItADescentTargetAndRecordsItsCellName() {
UniverseRegistry reg = registryWithProceduralGalaxy();
From d713a2bbb973c67846844294bfd321b943b80d79 Mon Sep 17 00:00:00 2001
From: StannisMod
Date: Wed, 12 Aug 2026 10:35:07 +0300
Subject: [PATCH 08/42] fix: a procedural planet orbits its star, and its moons
travel with it
- the convenience ctor pinned every procedural body to a static frame
- the orbit belongs in the frame, as an authored system already has it
- moons now ride the parent's frame instead of a static one of their own
- share the cell-angle function so the frame and baseOrbitTheta agree
- pin it with a test proven to fail on the previous code
---
.../universe/ClusteredGalaxyGenerator.java | 24 +++++++--
.../universe/PlanetRealizer.java | 8 ++-
.../test/unit/PlanetRealizationTest.java | 53 +++++++++++++++++++
3 files changed, 80 insertions(+), 5 deletions(-)
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/ClusteredGalaxyGenerator.java b/src/main/java/zmaster587/advancedRocketry/universe/ClusteredGalaxyGenerator.java
index c93eb768d..c391aaaa9 100644
--- a/src/main/java/zmaster587/advancedRocketry/universe/ClusteredGalaxyGenerator.java
+++ b/src/main/java/zmaster587/advancedRocketry/universe/ClusteredGalaxyGenerator.java
@@ -11,6 +11,7 @@
import zmaster587.advancedRocketry.api.Constants;
import zmaster587.advancedRocketry.api.dimension.solar.StellarBody;
+import zmaster587.advancedRocketry.space.AbsolutePos;
import zmaster587.advancedRocketry.space.GalacticCoord;
import zmaster587.advancedRocketry.util.AstronomicalBodyHelper;
@@ -226,14 +227,26 @@ public List bodiesFor(long seed, GalacticCoord systemCoord) {
// of being authored. Kept here rather than at realization because the nav list, the sky
// and the descent trigger all read the kind long before anyone lands.
BodyProfile profile = PlanetDerivation.derive(seed, cell, addr, 0, star, false, orbit);
+ // THE ORBIT LIVES IN THE FRAME, not in the body's own offset — the same shape an authored
+ // system uses (SystemContent: a planet sits at its frame origin and the FRAME goes round
+ // the star). Built with the convenience constructor, a procedural planet got
+ // CellFrame.staticAt(...) and a FIXED offset, so it stood still relative to its star
+ // forever while its own moons orbited it, and the identical system authored in XML moved.
+ double theta = PlanetRealizer.angleOf(cell, addr);
+ double periodTicks = AstronomicalBodyHelper.TICKS_PER_DAY
+ * AstronomicalBodyHelper.getOrbitalPeriod(orbit, star.getMass());
+ CellFrame bodyFrame = CellFrame.of(AbsolutePos.ofCellName(cell.cellCentre()),
+ BodyEphemeris.orbit(orbit, theta, 0d, false, periodTicks,
+ SystemContent.ORBIT_UNIT_BLOCKS));
// Procedural bodies have no realized dimension yet — a descent (Layer 2) realizes one.
- bodies.add(new SystemBody(addr, profile.kind(), Constants.INVALID_PLANET, starId, orbit));
+ bodies.add(new SystemBody(addr, bodyFrame, BodyEphemeris.STATIC, profile.kind(),
+ Constants.INVALID_PLANET, starId, orbit));
outermostOrbit = Math.max(outermostOrbit, orbit);
if (profile.kind() == SystemBodyKind.GAS_GIANT
&& (innermostGiantOrbit == 0 || orbit < innermostGiantOrbit)) {
innermostGiantOrbit = orbit;
}
- addMoons(bodies, seed, cell, addr, orbit, star, starId, profile);
+ addMoons(bodies, seed, cell, addr, bodyFrame, orbit, star, starId, profile);
}
// An inner belt is DERIVED from a giant and never rolled: it is material a giant's resonances
@@ -324,7 +337,8 @@ private static void addBelt(List bodies, long seed, GalacticCoord an
* thing that actually positions it.
*/
private void addMoons(List bodies, long seed, GalacticCoord anchor, GalacticCoord parent,
- int parentOrbit, StellarBody star, int starId, BodyProfile parentProfile) {
+ CellFrame parentFrame, int parentOrbit, StellarBody star, int starId,
+ BodyProfile parentProfile) {
boolean giant = parentProfile.kind() == SystemBodyKind.GAS_GIANT;
int max = giant ? MAX_MOONS_GIANT : MAX_MOONS_ROCKY;
double u = CellHash.norm(CellHash.ofCell(seed, parent, SALT_MOONCOUNT));
@@ -347,7 +361,9 @@ private void addMoons(List bodies, long seed, GalacticCoord anchor,
* AstronomicalBodyHelper.getMoonOrbitalPeriod(moonOrbit, (float) parentMass);
BodyEphemeris law = BodyEphemeris.orbit(moonOrbit, theta, 0d, false, periodTicks,
SystemContent.MOON_UNIT_BLOCKS);
- bodies.add(new SystemBody(parent, CellFrame.staticAt(parent), law, SystemBodyKind.MOON,
+ // A moon rides its PARENT's frame, so a planet and its moons travel as one destination.
+ // It used to ride a static frame of its own, which pinned the whole family in place.
+ bodies.add(new SystemBody(parent, parentFrame, law, SystemBodyKind.MOON,
Constants.INVALID_PLANET, starId, parentOrbit));
}
}
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/PlanetRealizer.java b/src/main/java/zmaster587/advancedRocketry/universe/PlanetRealizer.java
index 4ebdd6119..2ee7c8983 100644
--- a/src/main/java/zmaster587/advancedRocketry/universe/PlanetRealizer.java
+++ b/src/main/java/zmaster587/advancedRocketry/universe/PlanetRealizer.java
@@ -273,7 +273,13 @@ private static int rotationalPeriodOf(BodyProfile profile, StellarBody star) {
}
/** The angle of a body's cell about its system's anchor, in radians. */
- private static double angleOf(GalacticCoord anchor, GalacticCoord bodyCell) {
+ /**
+ * A body's orbital angle, read off its cell rather than drawn again — so the sky, the orbital
+ * elements and the frame a body rides all put it in the same place. Shared with
+ * {@link ClusteredGalaxyGenerator}, which must build the frame from the same angle this writes
+ * into {@code baseOrbitTheta}; a second copy of this arithmetic would let the two drift.
+ */
+ static double angleOf(GalacticCoord anchor, GalacticCoord bodyCell) {
long dx = bodyCell.sectorX() - anchor.sectorX();
long dz = bodyCell.sectorZ() - anchor.sectorZ();
if (dx == 0L && dz == 0L) {
diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetRealizationTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetRealizationTest.java
index 5583fc1a7..e40728ef5 100644
--- a/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetRealizationTest.java
+++ b/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetRealizationTest.java
@@ -130,6 +130,59 @@ public void aMoonCarriesItsOwnDistanceFromItsParentSeparatelyFromItsParentsFromT
(double) moon.orbitalDistance(), ownDistance, 1e-9);
}
+ /**
+ * A procedural planet ORBITS its star, and its moons travel with it.
+ *
+ * This was false: the convenience {@code SystemBody(address, kind, dimId, starId, orbit)}
+ * constructor hard-wires a static frame and a fixed offset, so every procedural planet stood
+ * still relative to its star forever — while its own moons orbited it, and while the identical
+ * system authored in XML moved. Nothing pinned it, which is why it survived.
+ *
+ * Two assertions, because either alone can be satisfied by the wrong thing: the planet must
+ * MOVE, and the moon must stay NEAR it while it does. A moon on its own static frame would leave
+ * its planet behind; a planet that only moved because its moon's law leaked into it would drag
+ * the separation open.
+ */
+ @Test
+ public void aProceduralPlanetOrbitsItsStarAndItsMoonsTravelWithIt() {
+ UniverseRegistry reg = registryWithProceduralGalaxy();
+ SystemBody planet = null;
+ SystemBody moon = null;
+ outer:
+ for (long x = -8; x <= 8; x++) {
+ for (long y = -8; y <= 8; y++) {
+ for (long z = -8; z <= 8; z++) {
+ SystemBody candidate = null;
+ for (SystemBody b : reg.bodiesAt(GalacticCoord.ofSectorLocal(x, y, z, 0L, 0L, 0L))) {
+ if (candidate == null && b.kind() != SystemBodyKind.MOON && b.kind().canDescend()) {
+ candidate = b;
+ } else if (b.kind() == SystemBodyKind.MOON && candidate != null) {
+ planet = candidate;
+ moon = b;
+ break outer;
+ }
+ }
+ }
+ }
+ }
+ assertNotNull("the procedural galaxy must produce a planet with a moon", planet);
+ assertNotNull(moon);
+
+ // One Earth-like year of ticks. A body at any orbit this generator produces turns by a
+ // substantial fraction of a revolution in that time, so "did it move" is not a rounding test.
+ long later = 24000L * 48L;
+ double planetTravelled = planet.absoluteAt(0L).minus(planet.absoluteAt(later)).length();
+ assertTrue("a procedural planet must go round its star, not stand at a fixed point"
+ + " (it moved " + planetTravelled + " blocks in a year)", planetTravelled > 1000d);
+
+ double separationNow = planet.absoluteAt(0L).minus(moon.absoluteAt(0L)).length();
+ double separationLater = planet.absoluteAt(later).minus(moon.absoluteAt(later)).length();
+ assertTrue("a moon must ride its parent's frame, so their separation stays a moon's orbit"
+ + " wide while both travel (" + separationNow + " -> " + separationLater
+ + ", planet moved " + planetTravelled + ")",
+ separationLater < planetTravelled / 2d);
+ }
+
@Test
public void realizingABodyMakesItADescentTargetAndRecordsItsCellName() {
UniverseRegistry reg = registryWithProceduralGalaxy();
From 0e8c7a960f004cac3958b8f3f2be5b48a1f74795 Mon Sep 17 00:00:00 2001
From: StannisMod
Date: Wed, 12 Aug 2026 10:48:31 +0300
Subject: [PATCH 09/42] refactor: a body that does not move must say so
- delete the SystemBody constructors that silently chose a static frame
- replace them with SystemBody.fixedAt, which names the choice
- the two production users are a star at its anchor and a belt round it
- 38 call sites converted; javac enumerated every one
---
.../command/test/TestProbeCommand.java | 3 +-
.../universe/ClusteredGalaxyGenerator.java | 6 ++--
.../advancedRocketry/universe/SystemBody.java | 23 ++++++++++----
.../test/unit/PlanetRealizationTest.java | 2 +-
.../test/unit/SystemBodiesProducerTest.java | 28 ++++++++---------
.../test/unit/SystemBodyTest.java | 30 +++++++++----------
.../test/unit/TelescopeRegionScanTest.java | 8 ++---
.../test/unit/UniverseRegistryTest.java | 8 ++---
8 files changed, 62 insertions(+), 46 deletions(-)
diff --git a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java
index baf61f4ff..7389a50fd 100644
--- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java
+++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java
@@ -3886,8 +3886,9 @@ private void handleSpace(MinecraftServer server, ICommandSender sender, String[]
int starId = parseIntOr(args[9], 0);
zmaster587.advancedRocketry.space.GalacticCoord coord =
zmaster587.advancedRocketry.space.GalacticCoord.ofSectorLocal(sx, sy, sz, lx, ly, lz);
+ // A POI planted by hand does not move — say so, rather than letting a constructor decide.
zmaster587.advancedRocketry.universe.SystemBody body =
- new zmaster587.advancedRocketry.universe.SystemBody(coord, kind, dimId, starId);
+ zmaster587.advancedRocketry.universe.SystemBody.fixedAt(coord, kind, dimId, starId);
reg.addPoi(body);
send(sender, "{\"ok\":true,\"cellKey\":\"" + coord.cellKey() + "\",\"descendTarget\":"
+ body.isDescendTarget() + "}");
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/ClusteredGalaxyGenerator.java b/src/main/java/zmaster587/advancedRocketry/universe/ClusteredGalaxyGenerator.java
index c391aaaa9..1a8d6da8f 100644
--- a/src/main/java/zmaster587/advancedRocketry/universe/ClusteredGalaxyGenerator.java
+++ b/src/main/java/zmaster587/advancedRocketry/universe/ClusteredGalaxyGenerator.java
@@ -193,7 +193,8 @@ public List bodiesFor(long seed, GalacticCoord systemCoord) {
StellarBody star = sys.get().star();
List bodies = new ArrayList<>();
// The star sits at the anchor cell's centre.
- bodies.add(new SystemBody(cell, SystemBodyKind.STAR, Constants.INVALID_PLANET, starId));
+ // A star does not move inside its own system: its frame IS the system's anchor.
+ bodies.add(SystemBody.fixedAt(cell, SystemBodyKind.STAR, Constants.INVALID_PLANET, starId));
// Bodies orbit at cell-scale radii: min 1 cell out (never the anchor cell), max = the bounded
// neighbourhood radius. The anchor sits in the middle band of its super-cell (>= 3s/8 from every
@@ -321,7 +322,8 @@ private static void addBelt(List bodies, long seed, GalacticCoord an
int clamped = Math.max(1, orbit);
GalacticCoord addr = placeBody(seed, anchor, index, clamped, star, s, taken);
if (addr != null) {
- bodies.add(new SystemBody(addr, SystemBodyKind.ASTEROID_BELT, Constants.INVALID_PLANET,
+ // A belt is centred on the star it rings, so as a whole it does not travel round it.
+ bodies.add(SystemBody.fixedAt(addr, SystemBodyKind.ASTEROID_BELT, Constants.INVALID_PLANET,
starId, clamped));
}
}
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/SystemBody.java b/src/main/java/zmaster587/advancedRocketry/universe/SystemBody.java
index 7fe115cbd..a6e033ede 100644
--- a/src/main/java/zmaster587/advancedRocketry/universe/SystemBody.java
+++ b/src/main/java/zmaster587/advancedRocketry/universe/SystemBody.java
@@ -55,14 +55,27 @@ public final class SystemBody {
* without a system to ride. {@code address}'s sector triple becomes the name and its local offset
* the (constant) in-cell offset.
*/
- public SystemBody(GalacticCoord address, SystemBodyKind kind, int dimId, int starId) {
- this(address, kind, dimId, starId, ORBIT_UNKNOWN);
+ /**
+ * A body that DOES NOT MOVE — pinned to a static frame at its own cell, forever.
+ *
+ * Named rather than offered as a plain constructor on purpose. This used to be
+ * {@code new SystemBody(address, kind, dimId, starId, orbit)}, and it read like the ordinary way
+ * to make a body while silently choosing immobility: the procedural generator built every planet
+ * through it, so a whole galaxy of worlds stood still relative to their stars while the same
+ * systems authored in XML orbited. A body that does not move is a real and legitimate thing — a
+ * star at its own system's anchor, a belt centred on that star — but it is a CHOICE, and the
+ * choice now has to be spelled.
+ *
+ * For a body that moves, pass its {@link CellFrame} and {@link BodyEphemeris} explicitly.
+ */
+ public static SystemBody fixedAt(GalacticCoord address, SystemBodyKind kind, int dimId, int starId) {
+ return fixedAt(address, kind, dimId, starId, ORBIT_UNKNOWN);
}
/** The same, carrying the body's orbital radius — see {@link #orbitalDistance()}. */
- public SystemBody(GalacticCoord address, SystemBodyKind kind, int dimId, int starId,
- int orbitalDistance) {
- this(requireAddress(address).cellCentre(), CellFrame.staticAt(address),
+ public static SystemBody fixedAt(GalacticCoord address, SystemBodyKind kind, int dimId, int starId,
+ int orbitalDistance) {
+ return new SystemBody(requireAddress(address).cellCentre(), CellFrame.staticAt(address),
BodyEphemeris.fixed(address.localX(), address.localY(), address.localZ()),
kind, dimId, starId, orbitalDistance);
}
diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetRealizationTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetRealizationTest.java
index e40728ef5..5531c6dff 100644
--- a/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetRealizationTest.java
+++ b/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetRealizationTest.java
@@ -332,7 +332,7 @@ public void aProceduralBodyCarriesTheOrbitItsPhysicsWasDerivedFrom() {
@Test
public void theOrbitSurvivesAnNbtRoundTrip() {
- SystemBody body = new SystemBody(GalacticCoord.ofSectorLocal(3, 4, 5, 0, 0, 0),
+ SystemBody body = SystemBody.fixedAt(GalacticCoord.ofSectorLocal(3, 4, 5, 0, 0, 0),
SystemBodyKind.PLANET, 12, -7, 1234);
net.minecraft.nbt.NBTTagCompound nbt = new net.minecraft.nbt.NBTTagCompound();
body.writeToNBT(nbt);
diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/SystemBodiesProducerTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/SystemBodiesProducerTest.java
index ac18a620c..41eaf37cb 100644
--- a/src/test/java/zmaster587/advancedRocketry/test/unit/SystemBodiesProducerTest.java
+++ b/src/test/java/zmaster587/advancedRocketry/test/unit/SystemBodiesProducerTest.java
@@ -67,7 +67,7 @@ public void aBodyAtTheCellCentreIsCarriedAsTheDirectionFromTheShipThatIsThere()
// Ship parked OFF the cell centre; a planet sitting AT the cell centre (local 0,0,0).
GalacticCoord ship = GalacticCoord.ofSectorLocal(0L, 0L, 0L, 100L, 50L, -30L);
GalacticCoord planet = GalacticCoord.ofSectorLocal(0L, 0L, 0L, 0L, 0L, 0L);
- SystemBody body = new SystemBody(planet, SystemBodyKind.PLANET, 3, 7);
+ SystemBody body = SystemBody.fixedAt(planet, SystemBodyKind.PLANET, 3, 7);
ShipLedger ledger = new ShipLedger();
ledger.settle(UUID.randomUUID(), ship);
@@ -97,7 +97,7 @@ public void crossCellBodyDirectionIncludesTheSectorTerm() {
// just the local delta (documents the component-wise sector-aware formula).
GalacticCoord ship = GalacticCoord.ofSectorLocal(0L, 0L, 0L, 100L, 0L, 0L);
GalacticCoord body = GalacticCoord.ofSectorLocal(1L, 0L, 0L, 0L, 0L, 0L);
- SystemBody star = new SystemBody(body, SystemBodyKind.STAR, Constants.INVALID_PLANET, 7);
+ SystemBody star = SystemBody.fixedAt(body, SystemBodyKind.STAR, Constants.INVALID_PLANET, 7);
ShipLedger ledger = new ShipLedger();
ledger.settle(UUID.randomUUID(), ship);
@@ -115,7 +115,7 @@ public void crossCellBodyDirectionIncludesTheSectorTerm() {
public void nonDescendBodyCarriesDescendTargetFalse() {
GalacticCoord ship = GalacticCoord.ofSectorLocal(0L, 0L, 0L, 5L, 0L, 0L);
GalacticCoord beltCoord = GalacticCoord.ofSectorLocal(0L, 0L, 0L, 0L, 0L, 0L);
- SystemBody belt = new SystemBody(beltCoord, SystemBodyKind.ASTEROID_BELT, Constants.INVALID_PLANET, 7);
+ SystemBody belt = SystemBody.fixedAt(beltCoord, SystemBodyKind.ASTEROID_BELT, Constants.INVALID_PLANET, 7);
ShipLedger ledger = new ShipLedger();
ledger.settle(UUID.randomUUID(), ship);
@@ -137,7 +137,7 @@ public void aLiveCellWhoseOnlyShipIsMidJumpStillShowsItsBodies() {
// one of those bodies vanished from his sky and the blank was indistinguishable from a void.
GalacticCoord cell = GalacticCoord.ofSectorLocal(57L, 0L, 5L, 0L, 0L, 0L);
GalacticCoord shipPos = GalacticCoord.ofSectorLocal(57L, 0L, 5L, 125L, 0L, -1016L);
- SystemBody moon = new SystemBody(GalacticCoord.ofSectorLocal(57L, 0L, 5L, 2900L, 0L, 0L),
+ SystemBody moon = SystemBody.fixedAt(GalacticCoord.ofSectorLocal(57L, 0L, 5L, 2900L, 0L, 0L),
SystemBodyKind.MOON, 4, 7);
ShipLedger ledger = new ShipLedger();
@@ -161,7 +161,7 @@ public void aShipMidJumpKeysNoDimensionOfItsOwn() {
ShipLedger ledger = new ShipLedger();
ledger.beginTransit(UUID.randomUUID(), GalacticCoord.ofSectorLocal(0L, 0L, 0L, 0L, 0L, 0L));
- SystemBody body = new SystemBody(GalacticCoord.ORIGIN, SystemBodyKind.PLANET, 3, 7);
+ SystemBody body = SystemBody.fixedAt(GalacticCoord.ORIGIN, SystemBodyKind.PLANET, 3, 7);
BodyLookup always = new BodyLookup() {
@Override
public List skyBodiesAt(GalacticCoord cell) {
@@ -180,7 +180,7 @@ public void aLiveCellWithNoShipInItIsStillFedFromItsCentre() {
// member whose ship departed without him, a passenger, a player put there by an on-ramp). His
// sky is the cell's, measured from the only point that is his if no ship is: the cell centre.
GalacticCoord cell = GalacticCoord.ofSectorLocal(4L, 1L, 2L, 0L, 0L, 0L);
- SystemBody planet = new SystemBody(GalacticCoord.ofSectorLocal(4L, 1L, 2L, 0L, 5000L, 0L),
+ SystemBody planet = SystemBody.fixedAt(GalacticCoord.ofSectorLocal(4L, 1L, 2L, 0L, 5000L, 0L),
SystemBodyKind.PLANET, 3, 7);
Map> byDim = SystemBodiesProducer.buildByDim(
@@ -199,7 +199,7 @@ public void aSettledShipIsPreferredOverAParkedOneAsTheObserver() {
GalacticCoord cell = GalacticCoord.ofSectorLocal(0L, 0L, 0L, 0L, 0L, 0L);
GalacticCoord settledAt = GalacticCoord.ofSectorLocal(0L, 0L, 0L, 700L, 0L, 0L);
GalacticCoord inboundTo = GalacticCoord.ofSectorLocal(0L, 0L, 0L, -900L, 0L, 0L);
- SystemBody planet = new SystemBody(cell, SystemBodyKind.PLANET, 3, 7);
+ SystemBody planet = SystemBody.fixedAt(cell, SystemBodyKind.PLANET, 3, 7);
ShipLedger ledger = new ShipLedger();
ledger.beginTransit(UUID.randomUUID(), inboundTo);
@@ -238,9 +238,9 @@ public List skyBodiesAt(GalacticCoord cell) {
public void everyLiveCellKeysItsOwnSlotDim() {
GalacticCoord shipA = GalacticCoord.ofSectorLocal(0L, 0L, 0L, 10L, 0L, 0L);
GalacticCoord shipB = GalacticCoord.ofSectorLocal(5L, 0L, 0L, 0L, 0L, 0L);
- final SystemBody planetA = new SystemBody(GalacticCoord.ofSectorLocal(0L, 0L, 0L, 0L, 0L, 0L),
+ final SystemBody planetA = SystemBody.fixedAt(GalacticCoord.ofSectorLocal(0L, 0L, 0L, 0L, 0L, 0L),
SystemBodyKind.PLANET, 3, 7);
- final SystemBody planetB = new SystemBody(GalacticCoord.ofSectorLocal(5L, 0L, 0L, 0L, 0L, 0L),
+ final SystemBody planetB = SystemBody.fixedAt(GalacticCoord.ofSectorLocal(5L, 0L, 0L, 0L, 0L, 0L),
SystemBodyKind.MOON, 4, 7);
ShipLedger ledger = new ShipLedger();
@@ -276,7 +276,7 @@ public void aShipWhoseCellIsInNoSlotContributesNothing() {
// dimension to key its sky under, and the only wrong answer is to invent one: keying the feed
// to a stale or borrowed id points a cell's bodies at a world holding somebody else's cell.
GalacticCoord ship = GalacticCoord.ofSectorLocal(4L, 0L, 0L, 0L, 0L, 0L);
- SystemBody planet = new SystemBody(ship, SystemBodyKind.PLANET, 3, 7);
+ SystemBody planet = SystemBody.fixedAt(ship, SystemBodyKind.PLANET, 3, 7);
ShipLedger ledger = new ShipLedger();
ledger.settle(UUID.randomUUID(), ship);
@@ -296,7 +296,7 @@ public void anUnboundOrMalformedBindingIsNeverKeyed() {
// The "no world" sentinel must never become a dimension key, and neither must a cell key the
// coordinate parser cannot read back - both would put a body list under an id nothing renders.
GalacticCoord cell = GalacticCoord.ofSectorLocal(1L, 1L, 1L, 0L, 0L, 0L);
- SystemBody planet = new SystemBody(cell, SystemBodyKind.PLANET, 3, 7);
+ SystemBody planet = SystemBody.fixedAt(cell, SystemBodyKind.PLANET, 3, 7);
Map hostile = new LinkedHashMap<>();
hostile.put(cell.cellKey(), SpaceManager.UNBOUND_SLOT);
@@ -393,8 +393,8 @@ public AbsolutePos originAt(GalacticCoord name, long tick) {
@Test
public void anAuthoredStarIsFedItsOwnProxyDimensionAndAProceduralOneIsNot() {
GalacticCoord cell = GalacticCoord.ORIGIN;
- SystemBody authored = new SystemBody(cell, SystemBodyKind.STAR, Constants.INVALID_PLANET, 4);
- SystemBody procedural = new SystemBody(cell, SystemBodyKind.STAR, Constants.INVALID_PLANET, -9);
+ SystemBody authored = SystemBody.fixedAt(cell, SystemBodyKind.STAR, Constants.INVALID_PLANET, 4);
+ SystemBody procedural = SystemBody.fixedAt(cell, SystemBodyKind.STAR, Constants.INVALID_PLANET, -9);
GalacticCoord ship = GalacticCoord.ofSectorLocal(0L, 0L, 0L, 500L, 0L, 0L);
ShipLedger ledger = new ShipLedger();
ledger.settle(UUID.randomUUID(), ship);
@@ -419,7 +419,7 @@ public void nullInputsYieldEmptyMap() {
.isEmpty());
// A missing ledger is NOT a missing feed: the cell is live, so its sky is drawn - from the
// cell centre, because there is no ship to measure it from.
- SystemBody planet = new SystemBody(GalacticCoord.ofSectorLocal(0L, 0L, 0L, 0L, 800L, 0L),
+ SystemBody planet = SystemBody.fixedAt(GalacticCoord.ofSectorLocal(0L, 0L, 0L, 0L, 800L, 0L),
SystemBodyKind.PLANET, 3, 7);
Map> byDim =
SystemBodiesProducer.buildByDim(live(cell, 1), null, lookupIn(cell, planet));
diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/SystemBodyTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/SystemBodyTest.java
index c3d603551..e8371b2aa 100644
--- a/src/test/java/zmaster587/advancedRocketry/test/unit/SystemBodyTest.java
+++ b/src/test/java/zmaster587/advancedRocketry/test/unit/SystemBodyTest.java
@@ -31,7 +31,7 @@ private static BodyEphemeris orbit(double distUnits, long unitBlocks) {
@Test
public void nbtRoundTripPreservesEveryField() {
- SystemBody body = new SystemBody(GalacticCoord.ofSectorLocal(4, -5, 6, 123_456, -7_890, 42),
+ SystemBody body = SystemBody.fixedAt(GalacticCoord.ofSectorLocal(4, -5, 6, 123_456, -7_890, 42),
SystemBodyKind.STATION_SLOT, 815, -12345);
NBTTagCompound tag = new NBTTagCompound();
body.writeToNBT(tag);
@@ -126,7 +126,7 @@ public void aBodyStillMovesAfterAnNbtRoundTrip() {
@Test
public void aBodyRebindsToTheFrameOfTheCellItIsServedFrom() {
GalacticCoord name = GalacticCoord.ofSectorLocal(3, 0, 0, 5_000, 0, 0);
- SystemBody station = new SystemBody(name, SystemBodyKind.STATION_SLOT,
+ SystemBody station = SystemBody.fixedAt(name, SystemBodyKind.STATION_SLOT,
Constants.INVALID_PLANET, 0);
assertEquals("a bare POI stands still", station.absoluteAt(0L), station.absoluteAt(500L));
@@ -142,32 +142,32 @@ public void aBodyRebindsToTheFrameOfTheCellItIsServedFrom() {
@Test
public void onlyRealBodiesDefineACellsFrame() {
GalacticCoord at = GalacticCoord.ORIGIN;
- assertTrue(new SystemBody(at, SystemBodyKind.STAR, Constants.INVALID_PLANET, 0).definesFrame());
- assertTrue(new SystemBody(at, SystemBodyKind.PLANET, 1, 0).definesFrame());
- assertTrue(new SystemBody(at, SystemBodyKind.GAS_GIANT, 2, 0).definesFrame());
- assertTrue(new SystemBody(at, SystemBodyKind.ASTEROID_BELT,
+ assertTrue(SystemBody.fixedAt(at, SystemBodyKind.STAR, Constants.INVALID_PLANET, 0).definesFrame());
+ assertTrue(SystemBody.fixedAt(at, SystemBodyKind.PLANET, 1, 0).definesFrame());
+ assertTrue(SystemBody.fixedAt(at, SystemBodyKind.GAS_GIANT, 2, 0).definesFrame());
+ assertTrue(SystemBody.fixedAt(at, SystemBodyKind.ASTEROID_BELT,
Constants.INVALID_PLANET, 0).definesFrame());
- assertFalse(new SystemBody(at, SystemBodyKind.MOON, 3, 0).definesFrame());
- assertFalse(new SystemBody(at, SystemBodyKind.STATION_SLOT,
+ assertFalse(SystemBody.fixedAt(at, SystemBodyKind.MOON, 3, 0).definesFrame());
+ assertFalse(SystemBody.fixedAt(at, SystemBodyKind.STATION_SLOT,
Constants.INVALID_PLANET, 0).definesFrame());
}
@Test
public void descendTargetOnlyForPlanetOrMoonWithARealDimension() {
GalacticCoord at = GalacticCoord.ofSectorLocal(1, 1, 1, 10, 20, 30);
- assertTrue(new SystemBody(at, SystemBodyKind.PLANET, 7, 1).isDescendTarget());
- assertTrue(new SystemBody(at, SystemBodyKind.MOON, 8, 1).isDescendTarget());
+ assertTrue(SystemBody.fixedAt(at, SystemBodyKind.PLANET, 7, 1).isDescendTarget());
+ assertTrue(SystemBody.fixedAt(at, SystemBodyKind.MOON, 8, 1).isDescendTarget());
assertFalse("a planet with no realized dim is not yet a descent target",
- new SystemBody(at, SystemBodyKind.PLANET, Constants.INVALID_PLANET, 1).isDescendTarget());
- assertFalse(new SystemBody(at, SystemBodyKind.STAR, Constants.INVALID_PLANET, 1).isDescendTarget());
- assertFalse(new SystemBody(at, SystemBodyKind.STATION_SLOT, Constants.INVALID_PLANET, 1).isDescendTarget());
- assertFalse(new SystemBody(at, SystemBodyKind.ASTEROID_BELT, Constants.INVALID_PLANET, 1).isDescendTarget());
+ SystemBody.fixedAt(at, SystemBodyKind.PLANET, Constants.INVALID_PLANET, 1).isDescendTarget());
+ assertFalse(SystemBody.fixedAt(at, SystemBodyKind.STAR, Constants.INVALID_PLANET, 1).isDescendTarget());
+ assertFalse(SystemBody.fixedAt(at, SystemBodyKind.STATION_SLOT, Constants.INVALID_PLANET, 1).isDescendTarget());
+ assertFalse(SystemBody.fixedAt(at, SystemBodyKind.ASTEROID_BELT, Constants.INVALID_PLANET, 1).isDescendTarget());
}
@Test
public void unknownKindDecodesToAnInertPoiRatherThanCrashing() {
NBTTagCompound tag = new NBTTagCompound();
- new SystemBody(GalacticCoord.ORIGIN, SystemBodyKind.PLANET, 5, 1).writeToNBT(tag);
+ SystemBody.fixedAt(GalacticCoord.ORIGIN, SystemBodyKind.PLANET, 5, 1).writeToNBT(tag);
tag.setString("kind", "SOME_FUTURE_KIND"); // a kind this version doesn't know
SystemBody round = SystemBody.readFromNBT(tag);
assertEquals(SystemBodyKind.STATION_SLOT, round.kind());
diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/TelescopeRegionScanTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/TelescopeRegionScanTest.java
index 9237dda6a..4da4cbd11 100644
--- a/src/test/java/zmaster587/advancedRocketry/test/unit/TelescopeRegionScanTest.java
+++ b/src/test/java/zmaster587/advancedRocketry/test/unit/TelescopeRegionScanTest.java
@@ -196,14 +196,14 @@ private UniverseRegistry threeSystems() {
UniverseRegistry registry = new UniverseRegistry();
registry.place(cell(4, 0, 0), 4);
- registry.addPoi(new SystemBody(cell(4, 0, 0), SystemBodyKind.STAR, Constants.INVALID_PLANET, 4));
- registry.addPoi(new SystemBody(cell(4, 0, 0), SystemBodyKind.PLANET, 401, 4));
+ registry.addPoi(SystemBody.fixedAt(cell(4, 0, 0), SystemBodyKind.STAR, Constants.INVALID_PLANET, 4));
+ registry.addPoi(SystemBody.fixedAt(cell(4, 0, 0), SystemBodyKind.PLANET, 401, 4));
registry.place(cell(5, 1, 0), 5);
- registry.addPoi(new SystemBody(cell(5, 1, 0), SystemBodyKind.PLANET, 501, 5));
+ registry.addPoi(SystemBody.fixedAt(cell(5, 1, 0), SystemBodyKind.PLANET, 501, 5));
registry.place(cell(9, 0, 0), 9);
- registry.addPoi(new SystemBody(cell(9, 0, 0), SystemBodyKind.PLANET, 901, 9));
+ registry.addPoi(SystemBody.fixedAt(cell(9, 0, 0), SystemBodyKind.PLANET, 901, 9));
return registry;
}
diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/UniverseRegistryTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/UniverseRegistryTest.java
index 67e9ab131..5ac4ddd5d 100644
--- a/src/test/java/zmaster587/advancedRocketry/test/unit/UniverseRegistryTest.java
+++ b/src/test/java/zmaster587/advancedRocketry/test/unit/UniverseRegistryTest.java
@@ -472,9 +472,9 @@ public void worldSeedIsTransientAndNotPersisted() {
public void poiStoreRoundTripsThroughNbt() {
UniverseRegistry source = new UniverseRegistry();
GalacticCoord sys = GalacticCoord.ofSectorLocal(3, 3, 3, 0, 0, 0);
- source.addPoi(new SystemBody(GalacticCoord.ofSectorLocal(3, 3, 3, 50_000, 0, 0),
+ source.addPoi(SystemBody.fixedAt(GalacticCoord.ofSectorLocal(3, 3, 3, 50_000, 0, 0),
SystemBodyKind.STATION_SLOT, Constants.INVALID_PLANET, 7));
- source.addPoi(new SystemBody(GalacticCoord.ofSectorLocal(3, 3, 3, -20_000, 10_000, 0),
+ source.addPoi(SystemBody.fixedAt(GalacticCoord.ofSectorLocal(3, 3, 3, -20_000, 10_000, 0),
SystemBodyKind.ASTEROID_BELT, Constants.INVALID_PLANET, 7));
assertTrue("adding a POI must mark dirty", source.isDirty());
@@ -514,7 +514,7 @@ public void bodiesAtMergesProceduralBodiesAndPois() {
assertFalse("a procedural system must have bodies", procedural.isEmpty());
int before = procedural.size();
- reg.addPoi(new SystemBody(
+ reg.addPoi(SystemBody.fixedAt(
GalacticCoord.ofSectorLocal(found.sectorX(), found.sectorY(), found.sectorZ(), 100_000, 0, 0),
SystemBodyKind.STATION_SLOT, Constants.INVALID_PLANET, -5));
List merged = reg.bodiesAt(found);
@@ -704,7 +704,7 @@ public void theSkyFeedUnionsTheSystemWithTheObserversOwnCell() {
assertTrue("the fixture's void cell must belong to the system",
reg.anchorForCell(voidCell).isPresent());
assertTrue("...and hold no body of its own", reg.bodiesAt(voidCell).isEmpty());
- reg.addPoi(new SystemBody(voidCell.plusLocalSaturating(1_000L, 0L, 0L),
+ reg.addPoi(SystemBody.fixedAt(voidCell.plusLocalSaturating(1_000L, 0L, 0L),
SystemBodyKind.STATION_SLOT, Constants.INVALID_PLANET, 6007));
List sky = reg.skyBodiesAt(voidCell);
From 5b2aabb51dc9fdc8886ae78a274582502659d026 Mon Sep 17 00:00:00 2001
From: StannisMod
Date: Wed, 12 Aug 2026 13:58:20 +0300
Subject: [PATCH 10/42] fix: a probe that mounts a pilot must say which ship
- seat-mount takes an optional near [maxDist] selector
- the seat is resolved through the owning ship chunk claim
- every reply carries seatsLoaded, so the bare form is visible
- shipIdOwningBlock answers identity, not proximity
---
.../command/test/TestProbeCommand.java | 47 ++++++++++++++++---
.../integration/vs/VSBridge.java | 15 ++++++
.../integration/vs/VSIntegration.java | 16 +++++++
3 files changed, 72 insertions(+), 6 deletions(-)
diff --git a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java
index 7389a50fd..1f26bdeac 100644
--- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java
+++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java
@@ -1135,24 +1135,58 @@ world, parseDoubleOr(args[2], 0), parseDoubleOr(args[3], 0),
+ "}");
return;
}
- // seat-mount — spawn the pilot seat's dummy mount and return its entity id, so a
- // test bot can `player mount-entity ` and become the ship's pilot. Mirrors
- // BlockPilotSeat.onBlockActivated server-side (the bot cannot right-click a ship block).
+ // seat-mount [near [maxDist]] — spawn the pilot seat's dummy mount and
+ // return its entity id, so a test bot can `player mount-entity ` and become the ship's
+ // pilot. Mirrors BlockPilotSeat.onBlockActivated server-side (the bot cannot right-click a
+ // ship block). Without "near" the FIRST loaded seat answers, which is only defensible on a
+ // world holding one ship — the reply carries "seatsLoaded" so a caller can see when it is not.
if (args.length >= 2 && "seat-mount".equalsIgnoreCase(args[0])) {
net.minecraft.world.WorldServer world = vsWorld(sender, parseIntOr(args[1], Integer.MIN_VALUE));
if (world == null) {
send(sender, "{\"error\":\"world not loaded\"}");
return;
}
- zmaster587.advancedRocketry.tile.TilePilotSeat seat = null;
+ // WHICH seat. The bare form takes the first loaded one, and on a world holding several
+ // ships that is a coin toss reported as a fact: it once mounted a pilot onto a ship
+ // 16,000,000 blocks away from the one under test, and the reply was indistinguishable
+ // from success. So the seat COUNT now travels in every reply, and a caller that means
+ // one particular ship names it with "near [maxDist]" — resolved through that
+ // ship's chunk CLAIM, an identity, rather than through whichever seat is nearest.
+ java.util.List seats =
+ new java.util.ArrayList<>();
for (TileEntity te : world.loadedTileEntityList) {
if (te instanceof zmaster587.advancedRocketry.tile.TilePilotSeat) {
- seat = (zmaster587.advancedRocketry.tile.TilePilotSeat) te;
+ seats.add((zmaster587.advancedRocketry.tile.TilePilotSeat) te);
+ }
+ }
+ String wantShipId = null;
+ if (args.length >= 6 && "near".equalsIgnoreCase(args[2])) {
+ double maxDist = args.length >= 7
+ ? parseDoubleOr(args[6], Double.POSITIVE_INFINITY) : Double.POSITIVE_INFINITY;
+ wantShipId = zmaster587.advancedRocketry.integration.vs.VSIntegration.nearestShipId(
+ world, parseDoubleOr(args[3], 0), parseDoubleOr(args[4], 0),
+ parseDoubleOr(args[5], 0), maxDist);
+ if (wantShipId == null) {
+ send(sender, "{\"seatFound\":false,\"reason\":\"no loaded ship near that point\""
+ + ",\"seatsLoaded\":" + seats.size() + "}");
+ return;
+ }
+ }
+ zmaster587.advancedRocketry.tile.TilePilotSeat seat = null;
+ for (zmaster587.advancedRocketry.tile.TilePilotSeat candidate : seats) {
+ if (wantShipId == null) {
+ seat = candidate;
+ break;
+ }
+ if (wantShipId.equals(zmaster587.advancedRocketry.integration.vs.VSIntegration
+ .shipIdOwningBlock(world, candidate.getPos()))) {
+ seat = candidate;
break;
}
}
if (seat == null) {
- send(sender, "{\"seatFound\":false}");
+ send(sender, "{\"seatFound\":false,\"seatsLoaded\":" + seats.size()
+ + (wantShipId == null ? "" : ",\"wantedShip\":\"" + wantShipId + "\"") + "}");
return;
}
BlockPos sp = seat.getPos();
@@ -1169,6 +1203,7 @@ world, parseDoubleOr(args[2], 0), parseDoubleOr(args[3], 0),
}
send(sender, "{\"seatFound\":true,\"dummyId\":" + dummy.getEntityId()
+ ",\"reused\":" + reused
+ + ",\"seatsLoaded\":" + seats.size()
+ ",\"seatX\":" + sp.getX() + ",\"seatY\":" + sp.getY() + ",\"seatZ\":" + sp.getZ() + "}");
return;
}
diff --git a/src/main/java/zmaster587/advancedRocketry/integration/vs/VSBridge.java b/src/main/java/zmaster587/advancedRocketry/integration/vs/VSBridge.java
index 48551ab6f..bada0b212 100644
--- a/src/main/java/zmaster587/advancedRocketry/integration/vs/VSBridge.java
+++ b/src/main/java/zmaster587/advancedRocketry/integration/vs/VSBridge.java
@@ -719,6 +719,21 @@ static String nearestShipId(World world, double x, double y, double z, double ma
return physo == null ? null : physo.getShipData().getUuid().toString();
}
+ /**
+ * The IDENTITY of the ship that owns a SUBSPACE block position — its VS ship uuid as a string —
+ * or {@code null} when the position belongs to no loaded ship.
+ *
+ * This is the inverse of {@link #nearestShipId}: it answers from the ship's chunk CLAIM, which
+ * contains the block or does not, rather than from a distance that is merely small. A caller
+ * holding a block of a ship (a seat, a controller, a hatch) uses this to say WHICH ship it is a
+ * block of, on a world where several ships exist and their subspace yards sit side by side.
+ */
+ static String shipIdOwningBlock(World world, net.minecraft.util.math.BlockPos pos) {
+ return ValkyrienUtils.getPhysoManagingBlock(world, pos)
+ .map(physo -> physo.getShipData().getUuid().toString())
+ .orElse(null);
+ }
+
/**
* State of the loaded ship with this uuid, in the same layout as {@link #nearestShipState}, or
* {@code null} when the id names no ship that is loaded here (unloaded, deleted, another world,
diff --git a/src/main/java/zmaster587/advancedRocketry/integration/vs/VSIntegration.java b/src/main/java/zmaster587/advancedRocketry/integration/vs/VSIntegration.java
index 08d0ec5de..b66b0b47a 100644
--- a/src/main/java/zmaster587/advancedRocketry/integration/vs/VSIntegration.java
+++ b/src/main/java/zmaster587/advancedRocketry/integration/vs/VSIntegration.java
@@ -517,6 +517,22 @@ public static int shipBlockHeight(World world, double x, double y, double z) {
* only this one ship, so any non-air block is a ship block. Shared by {@link #crossShip} (tight cut)
* and {@link #shipBlockHeight}.
*/
+ /**
+ * The identity (VS ship uuid, as a string) of the ship that OWNS a subspace block position, or
+ * {@code null} when VS is absent or the position belongs to no loaded ship.
+ *
+ * Answered from the ship's chunk claim — which contains the block or does not — so it is an
+ * identity and not a proximity. A caller holding one block of a ship (a pilot seat, a hatch) uses
+ * this to say which ship that block belongs to on a world where several ships are loaded at once
+ * and their subspace yards are neighbours.
+ */
+ public static String shipIdOwningBlock(World world, net.minecraft.util.math.BlockPos pos) {
+ if (!isAvailable()) {
+ return null;
+ }
+ return VSBridge.shipIdOwningBlock(world, pos);
+ }
+
/**
* A single subspace block position of the VS ship whose world BB contains {@code (x,y,z)}, or
* {@code null} when VS is absent / no ship is there / its shipyard is empty. Located through the
From 73b70d4d7f196abf713dc22adb2f225c4ff97a16 Mon Sep 17 00:00:00 2001
From: StannisMod
Date: Wed, 12 Aug 2026 13:58:38 +0300
Subject: [PATCH 11/42] test: measure the render, the wire and a ship at far
coordinates
- both client spikes delivered into the reserved quadrant and never ran
- render shows zero repeated frames out to 24M against an origin control
- a sub-block position survives the round trip exactly at every rung
- a ship assembled at 16M flies and keeps its rider like one at zero
- the same holds with its blocks at subspace 19.2M
---
.../SpikeFarCoordinateRenderJitterTest.java | 369 ++++++++++
.../client/SpikeFarCoordinateShipTest.java | 665 ++++++++++++++++++
.../SpikeSubBlockPositionGranularityTest.java | 298 ++++++++
3 files changed, 1332 insertions(+)
create mode 100644 src/test/java/zmaster587/advancedRocketry/test/client/SpikeFarCoordinateRenderJitterTest.java
create mode 100644 src/test/java/zmaster587/advancedRocketry/test/client/SpikeFarCoordinateShipTest.java
create mode 100644 src/test/java/zmaster587/advancedRocketry/test/client/SpikeSubBlockPositionGranularityTest.java
diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/SpikeFarCoordinateRenderJitterTest.java b/src/test/java/zmaster587/advancedRocketry/test/client/SpikeFarCoordinateRenderJitterTest.java
new file mode 100644
index 000000000..71318b892
--- /dev/null
+++ b/src/test/java/zmaster587/advancedRocketry/test/client/SpikeFarCoordinateRenderJitterTest.java
@@ -0,0 +1,369 @@
+package zmaster587.advancedRocketry.test.client;
+
+import com.github.stannismod.forge.testing.junit.AbstractClientE2ETest;
+import com.google.gson.JsonObject;
+
+import org.junit.Test;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.Assert.assertTrue;
+
+/**
+ * SPIKE — is the render actually quantized far from the origin, the thing the 4M cell was sized for?
+ *
+ * `space-model.md` justifies `CELL = 4_000_000` with "entity doubles / chunks / lighting degrade
+ * past ~±2M blocks in 1.12.2". The server half of that (chunk generation, block storage) measured
+ * CLEAN out to 28M. This measures the visual half.
+ *
+ *
The stimulus, and why it is motion rather than a still frame
+ * A float quantum does not produce a shimmer in a static scene — the error is CONSTANT, so a still
+ * camera at 16M renders a still (if slightly displaced) image. Quantization shows up when the camera
+ * moves by LESS than the quantum: the frame then refuses to change until the accumulated motion
+ * crosses one step. So the camera is walked in {@value #STEP_BLOCKS}-block increments and the metric
+ * is how many consecutive frames come back byte-identical.
+ *
+ * Expected, if the render path carried absolute coordinates in float: at ±2M the quantum is 0.25
+ * block, so ~5 frames repeat per step; at ±16M it is 2 blocks, so ~40 repeat. Expected, if the path
+ * subtracts the viewer position in double before casting (which is what
+ * {@code RenderManager.renderEntityStatic} and Valkyrien Skies' {@code PhysObjectRenderManager} both
+ * appear to do): zero repeats at every coordinate.
+ *
+ *
Two controls, because this instrument has a known way of lying
+ *
+ * - The capture must contain a scene. A framebuffer enabled at RUNTIME receives the HUD
+ * pass and not the world pass, so every capture comes back as the clear colour — which reads
+ * exactly like "the renderer drew nothing". The client must be started with
+ * {@code -PclientFbo=true}, and the first frame is checked for being more than one flat colour.
+ * - The scene must be STATIC. Two captures with no motion between them must be identical.
+ * If they are not, something in the frame is animating and "frames differ" can no longer mean
+ * "the camera moved" — the run is inconclusive and says so rather than producing a number.
+ *
+ *
+ * Why the first run of this class stopped at 4M, and why that was not the render
+ * It delivered with plain {@code /tp} into an arena at {@code Z = 0}. The physics mod cancels,
+ * silently, any teleport into its reserved shipyard quadrant — {@code chunkX >= 318401 && chunkZ >=
+ * -1599}, i.e. X ≥ 5,094,416 and Z ≥ -25,584 — while the command still reports success. Every
+ * rung from 8M up was therefore refused by a mod constant, and the camera never left the previous
+ * coordinate. The arena now sits at {@code Z = }{@value #ARENA_Z}, below the quadrant's Z edge, and
+ * the long jump between rungs goes through {@code /artest player far-tp} (vanilla's own
+ * dimension-change path, which is how a long jump escapes the speed check). The sub-block camera
+ * steps stay on plain {@code /tp}: they are not long jumps, and they are outside the quadrant.
+ *
+ * Designed to come back NO: if every coordinate shows zero repeats, the render is not the ceiling
+ * and the cell bound has to be justified by something else or dropped.
+ */
+public class SpikeFarCoordinateRenderJitterTest extends AbstractClientE2ETest {
+
+ /**
+ * The origin is carried as the CONTROL in the same run: "zero repeats at 16M" means nothing until
+ * the same instrument has shown zero repeats where no one suspects a quantum. Then today's
+ * half-cell, the ratified half-cell (16M) and the measured margin (24M).
+ */
+ private static final int[] X_LADDER = {0, 2_000_000, 8_000_000, 16_000_000, 24_000_000};
+
+ /**
+ * The arena's Z. The physics mod's reserved quadrant starts at {@code chunkZ >= -1599}
+ * (Z ≥ -25,584); this sits well below it, so its teleport veto never fires and the only thing
+ * under test is the coordinate's own magnitude.
+ */
+ private static final int ARENA_Z = -100_000;
+
+ /** Sub-block camera step. Smaller than every quantum in the table, so a quantum shows as repeats. */
+ private static final double STEP_BLOCKS = 0.05d;
+ private static final int STEPS = 12;
+ /** How many 20-tick waits the frame gets to stop changing on its own before a teleport. */
+ private static final int SETTLE_ATTEMPTS = 15;
+ /** How many (deliver, settle) rounds a rung gets before it is called undeliverable. */
+ private static final int DELIVERY_ATTEMPTS = 4;
+
+ private static final int OVERWORLD = 0;
+ private static final int FLOOR_Y = 140;
+ private static final int EYE_Y = FLOOR_Y + 1;
+
+ private Path outDir;
+ private String botName;
+
+ private String exec(String cmd) throws Exception {
+ return String.join("\n", serverClient().execute(cmd));
+ }
+
+ @Test
+ public void howFarFromTheOriginDoesTheRenderStartToQuantize() throws Exception {
+ outDir = Paths.get(System.getProperty("forge.test.client.screenshotDir", "build/test-screenshots"))
+ .toAbsolutePath();
+ Files.createDirectories(outDir);
+
+ bot().waitForWorld();
+ exec("gamerule sendCommandFeedback false");
+ exec("gamerule logAdminCommands false");
+ // Freeze everything that could change a pixel for a reason this spike did not cause.
+ exec("gamerule doDaylightCycle false");
+ exec("gamerule doMobSpawning false");
+ exec("gamerule doWeatherCycle false");
+ exec("weather clear");
+ exec("time set 6000");
+
+ String health = exec("artest player health");
+ java.util.regex.Matcher m = java.util.regex.Pattern
+ .compile("\"player\"\\s*:\\s*\"([^\"]+)\"").matcher(health);
+ assertTrue("player health must echo the player name: " + health, m.find());
+ botName = m.group(1);
+
+ JsonObject fb = bot().setFramebuffer(true);
+ assertTrue("this client's GL must support the framebuffer capture path: " + fb,
+ fb.get("supported").getAsBoolean());
+ bot().setHudHidden(true);
+ bot().setRenderDistance(4);
+
+ List report = new ArrayList<>();
+ List inconclusive = new ArrayList<>();
+ // Per rung: the longest run of byte-identical frames, i.e. the quantum in camera steps.
+ java.util.Map longestRunByX = new java.util.LinkedHashMap<>();
+
+ for (int x : X_LADDER) {
+ // A sealed stone box: the only thing in frame is a wall a few blocks away, so nothing in
+ // the picture can move on its own (no sky, no sun, no clouds, no weather).
+ exec("artest chunk forceload " + OVERWORLD + " " + (x >> 4) + " " + (ARENA_Z >> 4));
+ // Put the player there FIRST so the chunks around him are live, and build the box only
+ // then. The first two runs built into unloaded chunks, the player fell into an ocean, and
+ // every frame was animated water — the controls caught it, but the arrangement had to be
+ // read off a captured frame to see WHY. FLOOR_Y is well above sea level for the same
+ // reason: 2M and 16M are both ocean.
+ deliver(x, FLOOR_Y + 20);
+ exec("artest fill " + OVERWORLD + " " + (x - 6) + " " + FLOOR_Y + " " + (ARENA_Z - 6) + " "
+ + (x + 6) + " " + (FLOOR_Y + 5) + " " + (ARENA_Z + 6) + " minecraft:stone");
+ exec("artest fill " + OVERWORLD + " " + (x - 5) + " " + (FLOOR_Y + 1) + " " + (ARENA_Z - 5)
+ + " " + (x + 5) + " " + (FLOOR_Y + 4) + " " + (ARENA_Z + 5) + " minecraft:air");
+ // A patterned wall: a flat surface gives a frame whose pixels barely move, and a
+ // sub-block shift in a flat texture is exactly the change this must be able to see.
+ exec("artest fill " + OVERWORLD + " " + (x - 5) + " " + (FLOOR_Y + 1) + " " + (ARENA_Z + 5)
+ + " " + (x + 5) + " " + (FLOOR_Y + 4) + " " + (ARENA_Z + 5) + " minecraft:bookshelf");
+
+ // ARRANGEMENT CHECK, ON THE AXIS THAT CARRIES THE CONDITION. This used to test posY, and
+ // posY is right whenever the player stands on ANY floor — so it passed while he was still
+ // in the previous coordinate's box, and three separate readings were taken of a player who
+ // was not there. Delivery is RETRIED until the server's own posX says he arrived, and
+ // abandoned loudly if it never does.
+ double actualX = deliver(x, EYE_Y);
+ bot().setLook(0f, 0f); // face +Z, straight at the bookshelf wall
+ bot().waitTicks(40);
+ if (!(Math.abs(actualX - (x + 0.5d)) < 2d)) {
+ inconclusive.add("x=" + x + " the player never arrived (posX=" + actualX
+ + ", wanted " + (x + 0.5d) + ") - delivery, not the render");
+ continue;
+ }
+
+ BufferedImage first = capture("jitter_" + x + "_ctrl_a");
+ if (isFlat(first)) {
+ inconclusive.add("x=" + x + " capture is one flat colour " + describe(first)
+ + " - the framebuffer is not receiving the world pass (start with -PclientFbo=true)");
+ continue;
+ }
+ // SETTLE. The first run said the scene was not static and it was right: chunk streaming,
+ // lighting propagation and the client's own catch-up keep changing pixels for a while
+ // after a teleport. Wait for the frame to stop moving ON ITS OWN before asking whether
+ // MOTION moves it — an unsettled scene answers "the frame changed" to every question.
+ BufferedImage second = null;
+ int settleAttempts = 0;
+ int lastDelta = Integer.MAX_VALUE;
+ BufferedImage previousSettle = first;
+ while (settleAttempts < SETTLE_ATTEMPTS) {
+ settleAttempts++;
+ bot().waitTicks(20);
+ BufferedImage now = capture("jitter_" + x + "_settle" + settleAttempts);
+ lastDelta = differingPixels(previousSettle, now);
+ previousSettle = now;
+ if (lastDelta == 0) {
+ second = now;
+ break;
+ }
+ }
+ if (second == null) {
+ inconclusive.add("x=" + x + " the frame never stopped changing on its own after "
+ + settleAttempts + " attempts (last delta " + lastDelta + "px) - the scene is "
+ + "not static, so frame differences cannot be attributed to camera motion");
+ continue;
+ }
+
+ List movedOnServer = new ArrayList<>();
+ int repeats = 0;
+ int maxRun = 0;
+ int run = 0;
+ BufferedImage previous = second;
+ for (int step = 1; step <= STEPS; step++) {
+ double px = x + 0.5d + step * STEP_BLOCKS;
+ exec("tp " + botName + " " + fmt(px) + " " + EYE_Y + " " + fmt(ARENA_Z + 0.5d));
+ bot().waitTicks(8);
+ // THE MISSING CONTROL. "The frame did not change" and "the player did not move" are
+ // the same observation until the position is read back. The first version of this
+ // spike read only the frame and concluded the RENDER quantizes — a conclusion its own
+ // data could not support.
+ double serverX = posXOf(exec("artest player health"));
+ movedOnServer.add(serverX);
+ BufferedImage now = capture("jitter_" + x + "_step" + step);
+ if (identical(previous, now)) {
+ repeats++;
+ run++;
+ maxRun = Math.max(maxRun, run);
+ } else {
+ run = 0;
+ }
+ previous = now;
+ }
+ double serverSpan = movedOnServer.isEmpty() ? 0d
+ : movedOnServer.get(movedOnServer.size() - 1) - movedOnServer.get(0);
+ int distinctServerPositions = new java.util.HashSet<>(movedOnServer).size();
+ double impliedQuantum = maxRun == 0 ? 0d : (maxRun + 1) * STEP_BLOCKS;
+ report.add("x=" + x + " steps=" + STEPS
+ + " serverMoved=" + fmt(serverSpan) + "blk/" + distinctServerPositions + "distinct"
+ + " identicalFrames=" + repeats
+ + " longestRun=" + maxRun + (maxRun >= STEPS ? " quantum>=" + fmt(STEPS * STEP_BLOCKS) : " quantum~" + fmt(impliedQuantum))
+ + " blocks "
+ + describe(previous));
+ // A repeat means "the render did not change". That is only about the RENDER if the camera
+ // actually moved, so a rung whose stimulus did not land is inconclusive, never a finding.
+ if (distinctServerPositions < STEPS) {
+ inconclusive.add("x=" + x + " only " + distinctServerPositions + " of " + STEPS
+ + " camera steps landed on the server - the stimulus, not the render");
+ } else {
+ longestRunByX.put(x, maxRun);
+ }
+ }
+
+ StringBuilder out = new StringBuilder(
+ "[SPIKE far-coordinate render jitter] step=" + STEP_BLOCKS + " blocks\n");
+ for (String line : report) {
+ out.append(" ").append(line).append('\n');
+ }
+ for (String line : inconclusive) {
+ out.append(" INCONCLUSIVE ").append(line).append('\n');
+ }
+ System.out.println(out);
+ writeReport("far-coordinate-render-jitter.txt", out.toString());
+
+ // The control decides whether anything else here is evidence: at the origin nobody suspects a
+ // quantum, so if the instrument reports repeats THERE it cannot tell a quantized render from a
+ // camera that did not move. Asserted first, and separately.
+ Integer control = longestRunByX.get(0);
+ assertTrue("the x=0 control produced no usable measurement, so no far rung is evidence:\n" + out,
+ control != null);
+ assertTrue("the x=0 control repeated " + control + " frames in a row - a sub-block camera step "
+ + "does not change the picture even at the origin, so this instrument cannot see the "
+ + "thing it was built to see:\n" + out, control == 0);
+
+ List quantized = new ArrayList<>();
+ for (java.util.Map.Entry e : longestRunByX.entrySet()) {
+ if (e.getKey() != 0 && e.getValue() > control) {
+ quantized.add("x=" + e.getKey() + " longestRun=" + e.getValue()
+ + " (~" + fmt((e.getValue() + 1) * STEP_BLOCKS) + " blocks)");
+ }
+ }
+ assertTrue("the render quantizes further out than at the origin: " + quantized + "\n" + out,
+ quantized.isEmpty());
+ }
+
+ // ─── helpers ───────────────────────────────────────────────────────────────
+
+ /**
+ * Puts the camera at {@code (x + 0.5, y, ARENA_Z + 0.5)} through the long-jump path and returns
+ * the server's own reading of where he ended up. Retried, because the chunks are force-loaded on
+ * the SERVER while the client has not received them yet — the first delivery of a rung routinely
+ * lands in a world the client cannot see.
+ */
+ private double deliver(int x, int y) throws Exception {
+ double actualX = Double.NaN;
+ for (int attempt = 1; attempt <= DELIVERY_ATTEMPTS; attempt++) {
+ exec("artest player far-tp " + fmt(x + 0.5d) + " " + y + " " + fmt(ARENA_Z + 0.5d));
+ exec("artest server wait " + OVERWORLD + " 60");
+ bot().waitTicks(20);
+ actualX = posXOf(exec("artest player health"));
+ if (Math.abs(actualX - (x + 0.5d)) < 2d) {
+ break;
+ }
+ }
+ return actualX;
+ }
+
+ private BufferedImage capture(String name) throws Exception {
+ bot().setHudHidden(true);
+ bot().waitTicks(4);
+ JsonObject shot = bot().screenshot(name);
+ assertTrue("screenshot must land on disk: " + shot, shot.get("exists").getAsBoolean());
+ Path dst = outDir.resolve(name + ".png");
+ Files.copy(Paths.get(shot.get("path").getAsString()), dst, StandardCopyOption.REPLACE_EXISTING);
+ BufferedImage image = ImageIO.read(new File(dst.toString()));
+ assertTrue("screenshot must decode: " + dst, image != null);
+ return image;
+ }
+
+ private static boolean identical(BufferedImage a, BufferedImage b) {
+ return differingPixels(a, b) == 0;
+ }
+
+ private static int differingPixels(BufferedImage a, BufferedImage b) {
+ if (a.getWidth() != b.getWidth() || a.getHeight() != b.getHeight()) {
+ return Integer.MAX_VALUE;
+ }
+ int n = 0;
+ for (int y = 0; y < a.getHeight(); y++) {
+ for (int x = 0; x < a.getWidth(); x++) {
+ if ((a.getRGB(x, y) & 0xFFFFFF) != (b.getRGB(x, y) & 0xFFFFFF)) {
+ n++;
+ }
+ }
+ }
+ return n;
+ }
+
+ /** One flat colour = the framebuffer never received the world pass. */
+ private static boolean isFlat(BufferedImage img) {
+ int first = img.getRGB(0, 0) & 0xFFFFFF;
+ for (int y = 0; y < img.getHeight(); y += 3) {
+ for (int x = 0; x < img.getWidth(); x += 3) {
+ if ((img.getRGB(x, y) & 0xFFFFFF) != first) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ private static String describe(BufferedImage img) {
+ return "[" + img.getWidth() + "x" + img.getHeight() + "]";
+ }
+
+ /** The server's own reading of where the player is, so the stimulus can be shown to have landed. */
+ private static double posXOf(String healthJson) {
+ java.util.regex.Matcher m = java.util.regex.Pattern
+ .compile("\"posX\"\\s*:\\s*([-0-9.eE]+)").matcher(healthJson);
+ return m.find() ? Double.parseDouble(m.group(1)) : Double.NaN;
+ }
+
+ /** The report is the deliverable, so it also lands on disk and survives a truncated console. */
+ private static void writeReport(String name, String text) {
+ try {
+ Path dir = Paths.get("build", "spike-reports").toAbsolutePath();
+ Files.createDirectories(dir);
+ Files.write(dir.resolve(name), text.getBytes("UTF-8"));
+ } catch (Exception e) {
+ System.out.println("[SPIKE] could not write the report file: " + e);
+ }
+ }
+
+ private static String oneLine(String s) {
+ return s.replace((char) 10, ' ').replace((char) 13, ' ').trim();
+ }
+
+ private static String fmt(double v) {
+ return String.format(java.util.Locale.ROOT, "%.4f", v);
+ }
+}
diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/SpikeFarCoordinateShipTest.java b/src/test/java/zmaster587/advancedRocketry/test/client/SpikeFarCoordinateShipTest.java
new file mode 100644
index 000000000..5963d1586
--- /dev/null
+++ b/src/test/java/zmaster587/advancedRocketry/test/client/SpikeFarCoordinateShipTest.java
@@ -0,0 +1,665 @@
+package zmaster587.advancedRocketry.test.client;
+
+import com.github.stannismod.forge.testing.junit.AbstractClientE2ETest;
+
+import org.junit.Assume;
+import org.junit.Test;
+import org.lwjgl.input.Keyboard;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.junit.Assert.assertTrue;
+
+/**
+ * SPIKE — does a tier-2 ship survive a far world coordinate the way a bare player does?
+ *
+ * A player walks, stands, collides, holds a sub-block position and is rendered without a quantum
+ * out to 24M. None of that transfers: a ship's blocks live in the shipyard subspace while its pose
+ * lives in the world, and the two are bridged by a transform of its own. So the ship is the last
+ * subject that could still move the ratified half-cell, and this is the leg that measures it.
+ *
+ * Why this ASSEMBLES at the coordinate instead of teleporting a ship to it
+ * {@code VSShipExtremeCoordinatesE2ETest} reached extreme Y by rigid-teleporting an assembled
+ * ship, and left the extreme-|X| leg unautomated for a reason recorded in its own javadoc: after a
+ * SECOND relocation the physics goes inert — neither a pilot key nor a velocity setpoint moves the
+ * ship — and the pilot-key path dies after a dismount and re-seat across the map. Those are
+ * relocation-SEQUENCE findings. Teleporting to |X| would re-run straight into them and produce a red
+ * that says nothing about the coordinate.
+ *
+ * So the stimulus changes rather than the measurement: the fixture is built, and the ship
+ * assembled, AT the far coordinate. There is exactly one relocation in the whole leg — the player's,
+ * through {@code far-tp} — and the ship is never moved at all.
+ *
+ * Where the arena sits, and why
+ * {@code Z = }{@value #ARENA_Z}, below the physics mod's reserved quadrant
+ * ({@code chunkX >= 318401 && chunkZ >= -1599}). Above that Z the quadrant would swallow the arena at
+ * 16M: the blocks would be shipyard blocks, the player's delivery would be cancelled silently, and
+ * the leg would measure the reservation instead of the coordinate.
+ *
+ * Acceptance, stated before the run
+ * The {@code x = 0} rung is the control, assembled and flown in the same run by the same commands.
+ * At every rung:
+ *
+ * - assembly must produce a VS ship (the ship count rises), and it must LOAD ({@code managed});
+ * - the pilot seat must be findable and mountable — crew retention through the far assembly;
+ * - a real held vertical-up key must lift the server ship by more than
+ * {@value #MIN_LIFT_BLOCKS} block;
+ * - the CLIENT-rendered rider must track that climb to within {@value #TRACK_TOLERANCE} blocks —
+ * a transform that has lost precision shows up here as divergence, and nowhere earlier.
+ *
+ *
+ * Designed to come back NO. A ship that will not assemble, will not load, will not lift or
+ * whose rider drifts at 16M is a finding against the ratified half-cell, and the number moves.
+ */
+public class SpikeFarCoordinateShipTest extends AbstractClientE2ETest {
+
+ private static final Pattern BUILDER_POS =
+ Pattern.compile("\"builderPos\":\\[(-?\\d+),(-?\\d+),(-?\\d+)]");
+ private static final Pattern POS_Y = Pattern.compile("\"posY\":(-?[0-9.E\\-]+)");
+ private static final Pattern COUNT = Pattern.compile("\"count\":(-?\\d+)");
+ private static final Pattern DUMMY_ID = Pattern.compile("\"dummyId\":(-?\\d+)");
+ private static final Pattern SHIP_ID = Pattern.compile("\"id\"\\s*:\\s*\"([^\"]+)\"");
+
+ /**
+ * Bounds the ONE nearest-ship lookup this leg makes. The rungs are millions of blocks apart, so a
+ * radius this size cannot reach a neighbour — and if this rung's own ship is missing, the lookup
+ * says so instead of describing the other rung's.
+ */
+ private static final int SHIP_LOOKUP_RADIUS = 512;
+
+ private static final Pattern POS_X = Pattern.compile("\"posX\":(-?[0-9.E\\-]+)");
+ private static final Pattern POS_Z = Pattern.compile("\"posZ\":(-?[0-9.E\\-]+)");
+
+ /** One command, then this many samples this many ticks apart, watching for motion to cease. */
+ private static final int SURVIVAL_SAMPLES = 40;
+ private static final int SURVIVAL_SAMPLE_TICKS = 10;
+ /** Blocks per sample below which the ship counts as no longer being driven. */
+ private static final double SURVIVAL_STEP_EPSILON = 0.05d;
+
+ /** The control, then the ratified half-cell. 24M is not carried: one far rung is the question. */
+ private static final int[] X_LADDER = {0, 16_000_000};
+
+ /** Below the reserved quadrant's Z edge (Z ≥ -25,584), so the arena is ordinary world at every X. */
+ private static final int ARENA_Z = -100_000;
+ /** Well above sea level: 16M is ocean, and a fixture built into water is not a fixture. */
+ private static final int BASE_Y = 140;
+
+ private static final String VARIANT = "with-pilot-seat";
+ private static final double MIN_LIFT_BLOCKS = 1.0d;
+ private static final double TRACK_TOLERANCE = 3.0d;
+ private static final double ARRIVAL_TOLERANCE = 1.0d;
+ private static final int DELIVERY_ATTEMPTS = 4;
+ /** 5-tick polls the CLIENT gets to agree it is riding the seat the server already mounted it on. */
+ private static final int RIDING_ATTEMPTS = 24;
+
+ private String exec(String cmd) throws Exception {
+ return String.join("\n", serverClient().execute(cmd));
+ }
+
+ @Test
+ public void doesAShipAssembleLoadAndFlyFarFromTheOrigin() throws Exception {
+ Assume.assumeTrue("needs Valkyrien Skies on the server", serverHasVs());
+
+ bot().waitForWorld();
+ exec("gamerule sendCommandFeedback false");
+ exec("gamerule logAdminCommands false");
+ exec("gamerule doMobSpawning false");
+ exec("gamerule doDaylightCycle false");
+ exec("gamerule doWeatherCycle false");
+ exec("weather clear");
+ // Headless has no player holding a distant ship loaded, and the client is one player in one
+ // place while two ships exist in this run.
+ assertTrue(exec("artest vs permaload true").contains("\"ok\":true"));
+
+ Map verdicts = new LinkedHashMap<>();
+ // Which ship answered for which rung. Two rungs that report the same id measured one subject
+ // twice, and two rungs agreeing to four decimals is what that looks like from the outside.
+ Map shipIds = new LinkedHashMap<>();
+ List report = new ArrayList<>();
+ List inconclusive = new ArrayList<>();
+ StringBuilder out;
+
+ try {
+ for (int x : X_LADDER) {
+ int before = count("ship-count-all");
+
+ String arrangement = arrange(x);
+ if (arrangement != null) {
+ inconclusive.add("x=" + x + " " + arrangement);
+ continue;
+ }
+
+ String assemble = assembleFixture(x);
+ if (assemble == null) {
+ inconclusive.add("x=" + x + " the fixture did not build or did not assemble"
+ + " (arrangement, not the coordinate)");
+ continue;
+ }
+ if (!assemble.contains("\"rocketCount\":0")) {
+ verdicts.put(x, "the build did not route to a SHIP: " + oneLine(assemble));
+ continue;
+ }
+
+ int after = before;
+ for (int i = 0; i < 40 && after <= before; i++) {
+ bot().waitTicks(5);
+ after = count("ship-count-all");
+ }
+ if (after <= before) {
+ verdicts.put(x, "assembly created no VS ship (count " + before + " -> " + after + ")");
+ continue;
+ }
+
+ // Put the pilot on the ship. This is the ONLY relocation in the leg, and it is the
+ // player's, not the ship's.
+ String delivery = deliver(x);
+ if (delivery != null) {
+ inconclusive.add("x=" + x + " " + delivery);
+ continue;
+ }
+
+ // Capture the ship's IDENTITY once, here — the one moment this lookup is defensible,
+ // with this rung's ship freshly assembled at this spot. Every later reading goes by
+ // that id, which has no distance term to be wrong about.
+ double y0 = Double.NaN;
+ String shipId = null;
+ String lastInfo = "";
+ for (int i = 0; i < 40 && Double.isNaN(y0); i++) {
+ bot().waitTicks(5);
+ lastInfo = exec("artest vs ship-info 0 " + x + " " + BASE_Y + " " + ARENA_Z
+ + " " + SHIP_LOOKUP_RADIUS);
+ if (lastInfo.contains("\"managed\":true")) {
+ y0 = readDouble(lastInfo);
+ Matcher im = SHIP_ID.matcher(lastInfo);
+ shipId = im.find() ? im.group(1) : null;
+ }
+ }
+ if (Double.isNaN(y0)) {
+ verdicts.put(x, "the ship never LOADED with the client present: " + oneLine(lastInfo));
+ continue;
+ }
+ if (shipId == null) {
+ verdicts.put(x, "the ship loaded but reported no id, so no later reading can be "
+ + "attributed to it: " + oneLine(lastInfo));
+ continue;
+ }
+ if (shipIds.containsValue(shipId)) {
+ verdicts.put(x, "this rung's ship is the SAME ship a previous rung measured (id "
+ + shipId + ") - the ladder is measuring one subject twice");
+ continue;
+ }
+ shipIds.put(x, shipId);
+
+ // NAME the ship. The bare form takes the first loaded pilot seat, and this ladder
+ // keeps every rung's ship permanently loaded — so at 16M it mounted the pilot onto
+ // the ORIGIN ship's seat, the client 16M away saw no entity to ride, and the reply
+ // read exactly like a far-coordinate failure. It was not one.
+ String mountInfo = exec("artest vs seat-mount 0 near " + x + " " + BASE_Y + " "
+ + ARENA_Z + " 512");
+ if (!mountInfo.contains("\"seatFound\":true")) {
+ verdicts.put(x, "the pilot seat was not findable: " + oneLine(mountInfo));
+ continue;
+ }
+ Matcher dm = DUMMY_ID.matcher(mountInfo);
+ if (!dm.find()) {
+ verdicts.put(x, "seat-mount reported no dummy id: " + oneLine(mountInfo));
+ continue;
+ }
+ String mounted = exec("artest player mount-entity " + dm.group(1));
+ if (!mounted.contains("\"mounted\":true")) {
+ verdicts.put(x, "the bot could not mount the seat dummy: " + oneLine(mounted));
+ continue;
+ }
+ // "mounted":true is the SERVER's word. The climb measures the CLIENT-rendered rider,
+ // so wait until the CLIENT agrees it is riding — the first run of this leg read the
+ // rider's posY one tick too early and died on a missing field, which reads exactly
+ // like a coordinate failure and is not one.
+ String riding = awaitRiding(Integer.parseInt(dm.group(1)));
+ if (riding != null) {
+ verdicts.put(x, riding + " (server said " + oneLine(mounted) + ")");
+ continue;
+ }
+
+ String flight = climbLeg(shipId, y0);
+ // The seat's own position is a SUBSPACE coordinate — the shipyard is where a ship's
+ // blocks actually live. Recording it makes the magnitude the ship's own math runs on
+ // visible in the report, which is the only number that changes if the shipyard moves.
+ report.add("x=" + x + " ship=" + shipId + " shipY0=" + fmt(y0)
+ + " subspaceSeatX=" + fmt(field(mountInfo, "seatX"))
+ + " subspaceSeatZ=" + fmt(field(mountInfo, "seatZ"))
+ + " " + flight);
+ verdicts.put(x, flight.startsWith("OK") ? null : flight);
+
+ exec("artest player dismount");
+ bot().waitTicks(10);
+ }
+ } finally {
+ // The report is the deliverable and it is worth MOST when the leg died mid-ladder, so it
+ // is emitted before anything can escape. The first run of this leg threw past its own
+ // report writer and left nothing on disk to read.
+ for (Map.Entry e : verdicts.entrySet()) {
+ if (e.getValue() != null) {
+ report.add("x=" + e.getKey() + " FAILED " + e.getValue());
+ }
+ }
+ StringBuilder built = new StringBuilder("[SPIKE far-coordinate VS ship]\n");
+ for (String line : report) {
+ built.append(" ").append(line).append('\n');
+ }
+ for (String line : inconclusive) {
+ built.append(" INCONCLUSIVE ").append(line).append('\n');
+ }
+ for (int x : X_LADDER) {
+ if (!verdicts.containsKey(x) && !hasPrefix(inconclusive, "x=" + x + " ")) {
+ built.append(" NOT REACHED x=").append(x).append('\n');
+ }
+ }
+ System.out.println(built);
+ writeReport("far-coordinate-ship.txt", built.toString());
+ out = built;
+ try {
+ exec("artest player dismount");
+ exec("artest vs permaload false");
+ } catch (Exception ignored) {
+ // teardown must not mask the finding
+ }
+ }
+
+ // The control is asserted first and separately: a ship that will not fly at the ORIGIN makes
+ // every far reading meaningless, and that is an instrument failure, not a coordinate ceiling.
+ assertTrue("the x=0 control produced no measurement at all, so no far rung is evidence:\n" + out,
+ verdicts.containsKey(0));
+ assertTrue("the x=0 control failed - the instrument, not the coordinate: " + verdicts.get(0)
+ + "\n" + out, verdicts.get(0) == null);
+
+ List failed = new ArrayList<>();
+ for (Map.Entry e : verdicts.entrySet()) {
+ if (e.getKey() != 0 && e.getValue() != null) {
+ failed.add("x=" + e.getKey() + ": " + e.getValue());
+ }
+ }
+ assertTrue("a ship does not behave at a far coordinate as it does at the origin: " + failed
+ + "\n" + out, failed.isEmpty());
+ assertTrue("no far rung was measured at all - the leg answered nothing:\n" + out,
+ verdicts.size() > 1);
+ }
+
+ /**
+ * SPIKE — how long does a ONE-SHOT commanded setpoint survive, and does the shipyard's position
+ * change that?
+ *
+ * The question this exists to settle
+ * Two measurements of this tree disagree. Moving the shipyard to {@code CHUNK_X_START =
+ * 1,200,000} makes {@code aStillCrewMemberOnAFastClimbingShipKeepsHisCapture} report
+ * {@code travelled=0.0} on 3 of 3 runs while it is green on 3 of 3 at {@code 320000} — yet the
+ * ladder above lifts a ship 4.7–5.1 blocks at that same subspace magnitude. Both cannot be
+ * describing "a ship cannot move out there".
+ *
+ * They stop disagreeing under one hypothesis: the failure is not in DELIVERING a command but
+ * in its SURVIVAL. The ladder holds a real key, so it re-commands every tick and outlives any
+ * loss of state; {@code seat-input} writes a setpoint ONCE, into
+ * {@code TileAdvancedFlightComputer} — and a flight computer tile that is re-created underneath
+ * the ship loses every live field it holds, {@code velocitySetpoint} included, while persistent
+ * {@code stationKeeping} survives. A command that is silently dropped a few seconds in reads as
+ * {@code travelled=0.0}.
+ *
+ * Independently, the registration is known to leak in this tree:
+ * {@code ClaimedChunkCacheController:122} re-registers EVERY tile of a chunk each time the claim
+ * cache loads it, {@code MixinChunk:48} adds on tile add, and {@code MixinChunk:53} removes only
+ * when a tile is genuinely removed — so an unload/load cycle leaves the old instance registered
+ * forever and adds a new one.
+ *
+ * What this measures, and what would settle it
+ * One command, then the ship's own position sampled until it stops moving. The number is the
+ * SURVIVAL WINDOW in ticks. Run at both constants, on a wiped world, at ordinary world
+ * coordinates so the shipyard's position is the only thing that differs.
+ *
+ * - window shorter at {@code 1,200,000} → the two measurements are reconciled and the
+ * shipyard move is implicated through the recreation rate;
+ * - window the same → the recreation story is still true but does NOT explain the red, and
+ * the cause of that red is still unnamed.
+ *
+ * Prints, never asserts a threshold: there is no defensible number to assert before the first
+ * pair of readings exists.
+ */
+ @Test
+ public void howLongDoesAOneShotCommandSurvive() throws Exception {
+ Assume.assumeTrue("needs Valkyrien Skies on the server", serverHasVs());
+
+ bot().waitForWorld();
+ exec("gamerule sendCommandFeedback false");
+ exec("gamerule logAdminCommands false");
+ exec("gamerule doMobSpawning false");
+ bot().setRenderDistance(4);
+ assertTrue(exec("artest vs permaload true").contains("\"ok\":true"));
+
+ StringBuilder out = new StringBuilder("[SPIKE one-shot command survival]\n");
+ try {
+ String arrangement = arrange(0);
+ assertTrue("the arena did not build: " + arrangement, arrangement == null);
+ String assemble = assembleFixture(0);
+ assertTrue("the fixture did not assemble", assemble != null);
+ assertTrue("the build must route to a ship: " + oneLine(assemble),
+ assemble.contains("\"rocketCount\":0"));
+ for (int i = 0; i < 40 && count("ship-count-all") < 1; i++) {
+ bot().waitTicks(5);
+ }
+ String delivery = deliver(0);
+ assertTrue("the pilot was not delivered: " + delivery, delivery == null);
+
+ String shipId = null;
+ for (int i = 0; i < 40 && shipId == null; i++) {
+ bot().waitTicks(5);
+ String info = exec("artest vs ship-info 0 0 " + BASE_Y + " " + ARENA_Z + " "
+ + SHIP_LOOKUP_RADIUS);
+ if (info.contains("\"managed\":true")) {
+ Matcher im = SHIP_ID.matcher(info);
+ shipId = im.find() ? im.group(1) : null;
+ }
+ }
+ assertTrue("the ship never loaded", shipId != null);
+
+ String mountInfo = exec("artest vs seat-mount 0 near 0 " + BASE_Y + " " + ARENA_Z + " 512");
+ assertTrue("no seat: " + oneLine(mountInfo), mountInfo.contains("\"seatFound\":true"));
+ Matcher dm = DUMMY_ID.matcher(mountInfo);
+ assertTrue("no dummy id", dm.find());
+ assertTrue("could not mount",
+ exec("artest player mount-entity " + dm.group(1)).contains("\"mounted\":true"));
+ String riding = awaitRiding(Integer.parseInt(dm.group(1)));
+ assertTrue("the client never began riding: " + riding, riding == null);
+
+ // ONE command. Forward throttle rather than vertical: horizontal travel has no ceiling to
+ // be mistaken for a command that stopped surviving.
+ double[] before = shipXZ(shipId);
+ String commanded = exec("artest vs seat-input 0 1 0 0 0 0 0");
+ out.append(" commanded once: ").append(oneLine(commanded)).append('\n');
+ out.append(" subspaceSeat=(").append(fmt(field(mountInfo, "seatX"))).append(',')
+ .append(fmt(field(mountInfo, "seatZ"))).append(")\n");
+
+ double lastDist = 0d;
+ int stoppedAtTick = -1;
+ int quiet = 0;
+ for (int sample = 1; sample <= SURVIVAL_SAMPLES; sample++) {
+ bot().waitTicks(SURVIVAL_SAMPLE_TICKS);
+ double[] now = shipXZ(shipId);
+ double dist = Math.hypot(now[0] - before[0], now[1] - before[1]);
+ double step = dist - lastDist;
+ out.append(" t=").append(sample * SURVIVAL_SAMPLE_TICKS)
+ .append(" travelled=").append(fmt(dist))
+ .append(" step=").append(fmt(step)).append('\n');
+ if (step < SURVIVAL_STEP_EPSILON) {
+ quiet++;
+ if (quiet >= 3 && stoppedAtTick < 0 && dist > 0.1d) {
+ stoppedAtTick = (sample - 2) * SURVIVAL_SAMPLE_TICKS;
+ }
+ } else {
+ quiet = 0;
+ }
+ lastDist = dist;
+ }
+ out.append(" SURVIVAL WINDOW: ")
+ .append(stoppedAtTick < 0
+ ? "never stopped within " + (SURVIVAL_SAMPLES * SURVIVAL_SAMPLE_TICKS)
+ + " ticks (total " + fmt(lastDist) + " blocks)"
+ : stoppedAtTick + " ticks, then motion ceased (total " + fmt(lastDist)
+ + " blocks)")
+ .append('\n');
+ } finally {
+ System.out.println(out);
+ writeReport("one-shot-command-survival.txt", out.toString());
+ try {
+ exec("artest player dismount");
+ exec("artest vs permaload false");
+ } catch (Exception ignored) {
+ // teardown must not mask the reading
+ }
+ }
+ }
+
+ /** The ship's world X and Z, by id. */
+ private double[] shipXZ(String shipId) {
+ String last = "";
+ for (int i = 0; i < 10; i++) {
+ try {
+ last = exec("artest vs ship-info 0 id " + shipId);
+ Matcher mx = POS_X.matcher(last);
+ Matcher mz = POS_Z.matcher(last);
+ if (mx.find() && mz.find()) {
+ return new double[] {Double.parseDouble(mx.group(1)),
+ Double.parseDouble(mz.group(1))};
+ }
+ bot().waitTicks(2);
+ } catch (Exception e) {
+ throw new AssertionError("ship-info threw: " + e, e);
+ }
+ }
+ throw new AssertionError("ship-info never returned a parseable position; last: " + last);
+ }
+
+ // ─── the measurement ────────────────────────────────────────────────────────
+
+ /**
+ * One controllability measurement where the ship already is: hold the REAL vertical-up key, the
+ * SERVER ship must climb, and the CLIENT-rendered rider must climb with it. A transform that has
+ * lost precision at a far coordinate shows up as divergence between those two and nowhere else.
+ *
+ * @return {@code "OK ..."} with the numbers, or the reason it failed
+ */
+ /**
+ * Waits until the CLIENT reports it is riding something, and — if it never does — asks the three
+ * questions that decide WHICH thing failed, because "the client is not riding" on its own cannot
+ * tell a coordinate ceiling from an arrangement fault:
+ *
+ * - where the CLIENT thinks the player is (a client that never arrived explains everything);
+ * - what entities the CLIENT can see near him (an empty list means entity tracking never
+ * delivered the seat dummy — the mount had nothing to bind to);
+ * - where the SERVER holds that same dummy (so a client/server split is visible as one).
+ *
+ *
+ * @return {@code null} once the client is riding, else the reason plus that diagnosis
+ */
+ private String awaitRiding(int dummyId) throws Exception {
+ com.google.gson.JsonObject last = null;
+ for (int i = 0; i < RIDING_ATTEMPTS; i++) {
+ bot().waitTicks(5);
+ last = bot().reportRidingEntity();
+ if (last.has("riding") && last.get("riding").getAsBoolean() && last.has("posY")) {
+ return null;
+ }
+ }
+ String clientState;
+ String clientEntities;
+ try {
+ clientState = String.valueOf(bot().reportState());
+ clientEntities = String.valueOf(bot().reportEntities("", 128d));
+ } catch (Exception e) {
+ clientState = "unreadable: " + e;
+ clientEntities = "unreadable";
+ }
+ return "the CLIENT never began riding the seat after " + (RIDING_ATTEMPTS * 5)
+ + " ticks (last report: " + last + ")"
+ + " | client state: " + oneLine(clientState)
+ + " | client sees near him: " + oneLine(clientEntities)
+ + " | server holds the dummy at: "
+ + oneLine(exec("artest entity info 0 " + dummyId));
+ }
+
+ private double riderY() throws Exception {
+ return bot().reportRidingEntity().get("posY").getAsDouble();
+ }
+
+ private String climbLeg(String shipId, double yBefore) throws Exception {
+ double riderYBefore = riderY();
+ bot().holdKey(Keyboard.KEY_R); // flightVerticalUp
+ try {
+ ClientPoll.until(bot()::waitTicks,
+ () -> shipY(shipId),
+ y -> y - yBefore > 1.5, 2, 100);
+ } finally {
+ bot().releaseKey(Keyboard.KEY_R);
+ }
+ bot().waitTicks(6);
+ double serverDelta = shipY(shipId) - yBefore;
+ double riderDelta = riderY() - riderYBefore;
+ String numbers = "serverLift=" + fmt(serverDelta) + " riderLift=" + fmt(riderDelta)
+ + " divergence=" + fmt(Math.abs(riderDelta - serverDelta));
+ if (!(serverDelta > MIN_LIFT_BLOCKS)) {
+ // A third witness separates "the seat glue died" from "the ship would not move".
+ return "the vertical-up key did not lift the ship (" + numbers + "); server player: "
+ + oneLine(exec("artest player health"));
+ }
+ if (Math.abs(riderDelta - serverDelta) >= TRACK_TOLERANCE) {
+ return "the CLIENT rider did not track the server ship (" + numbers + ")";
+ }
+ return "OK " + numbers;
+ }
+
+ // ─── arrangement ────────────────────────────────────────────────────────────
+
+ /** @return {@code null} once the site is loaded and clear, else what is wrong with it */
+ private String arrange(int x) throws Exception {
+ int cx1 = (x - 32) >> 4, cz1 = (ARENA_Z - 32) >> 4;
+ int cx2 = (x + 32) >> 4, cz2 = (ARENA_Z + 32) >> 4;
+ String warm = exec("artest chunk warmup 0 " + cx1 + " " + cz1 + " " + cx2 + " " + cz2);
+ if (!warm.contains("\"ok\":true")) {
+ return "chunk warmup failed: " + oneLine(warm);
+ }
+ // A stone pad at BASE_Y-1 and air above it: 16M is ocean, and the fixture must not be built
+ // into water or into whatever the generator put there.
+ exec("artest fill 0 " + (x - 8) + " " + (BASE_Y - 1) + " " + (ARENA_Z - 8) + " "
+ + (x + 12) + " " + (BASE_Y - 1) + " " + (ARENA_Z + 12) + " minecraft:stone");
+ String clear = exec("artest fill 0 " + (x - 8) + " " + BASE_Y + " " + (ARENA_Z - 8) + " "
+ + (x + 12) + " " + (BASE_Y + 14) + " " + (ARENA_Z + 12) + " minecraft:air");
+ if (!clear.contains("\"ok\":true")) {
+ return "pre-clear failed: " + oneLine(clear);
+ }
+ String pad = exec("artest block at 0 " + x + " " + (BASE_Y - 1) + " " + ARENA_Z);
+ if (!pad.contains("stone")) {
+ return "the pad is not stone (" + oneLine(pad) + ")";
+ }
+ return null;
+ }
+
+ /** @return the assemble reply, or {@code null} if the fixture itself never landed */
+ private String assembleFixture(int x) throws Exception {
+ String fixture = exec("artest fixture rocket 0 " + x + " " + BASE_Y + " " + ARENA_Z
+ + " " + VARIANT);
+ if (!fixture.contains("\"ok\":true")) {
+ System.out.println("[SPIKE ship] fixture at x=" + x + " failed: " + oneLine(fixture));
+ return null;
+ }
+ Matcher bp = BUILDER_POS.matcher(fixture);
+ if (!bp.find()) {
+ System.out.println("[SPIKE ship] fixture at x=" + x + " gave no builderPos: "
+ + oneLine(fixture));
+ return null;
+ }
+ return exec("artest rocket assemble 0 " + bp.group(1) + " " + bp.group(2) + " " + bp.group(3));
+ }
+
+ /**
+ * Puts the pilot on the ship through the long-jump path, retried: the chunks are loaded on the
+ * SERVER while the client has not received them yet, and the first delivery of a far rung lands
+ * in a world the client cannot see.
+ *
+ * @return {@code null} once he is there, or a reason string for the INCONCLUSIVE list
+ */
+ private String deliver(int x) throws Exception {
+ double lastX = Double.NaN;
+ for (int attempt = 1; attempt <= DELIVERY_ATTEMPTS; attempt++) {
+ exec("artest player far-tp " + fmt(x + 0.5d) + " " + (BASE_Y + 6) + " "
+ + fmt(ARENA_Z + 0.5d));
+ exec("artest server wait 0 40");
+ bot().waitTicks(30);
+ lastX = field(exec("artest player health"), "posX");
+ if (Math.abs(lastX - (x + 0.5d)) < ARRIVAL_TOLERANCE) {
+ return null;
+ }
+ }
+ return "the pilot never arrived (server posX=" + lastX + ", wanted " + (x + 0.5d)
+ + ") after " + DELIVERY_ATTEMPTS + " deliveries - delivery, not the ship";
+ }
+
+ // ─── instruments ────────────────────────────────────────────────────────────
+
+ /**
+ * The server ship's {@code posY}, asked BY ID and tolerant of unrelated console lines
+ * interleaving with the probe's reply — at far coordinates a VS collision mixin can print into
+ * the same window.
+ *
+ * By id, not by position: a nearest-ship lookup has a distance term to be wrong about, and on
+ * this ladder — two ships, one of them 16M away — a rung whose own ship had unloaded would
+ * silently be answered with the OTHER rung's ship. That failure looks like two rungs agreeing to
+ * four decimals, which is exactly what a clean far-coordinate result also looks like.
+ */
+ private double shipY(String shipId) {
+ String last = "";
+ for (int i = 0; i < 10; i++) {
+ try {
+ last = exec("artest vs ship-info 0 id " + shipId);
+ Matcher m = POS_Y.matcher(last);
+ if (m.find()) {
+ return Double.parseDouble(m.group(1));
+ }
+ bot().waitTicks(2);
+ } catch (Exception e) {
+ throw new AssertionError("ship-info threw: " + e, e);
+ }
+ }
+ throw new AssertionError("ship-info never returned a parseable posY; last reply: " + last);
+ }
+
+ private int count(String sub) throws Exception {
+ Matcher m = COUNT.matcher(exec("artest vs " + sub + " 0"));
+ return m.find() ? Integer.parseInt(m.group(1)) : -1;
+ }
+
+ private double readDouble(String json) {
+ Matcher m = POS_Y.matcher(json);
+ assertTrue("expected a posY in: " + json, m.find());
+ return Double.parseDouble(m.group(1));
+ }
+
+ private static double field(String json, String key) {
+ Matcher m = Pattern.compile("\"" + key + "\"\\s*:\\s*([-0-9.eE]+)").matcher(json);
+ return m.find() ? Double.parseDouble(m.group(1)) : Double.NaN;
+ }
+
+ private boolean serverHasVs() throws Exception {
+ return exec("artest vs available").contains("\"available\":true");
+ }
+
+ /** The report is the deliverable, so it also lands on disk and survives a truncated console. */
+ private static void writeReport(String name, String text) {
+ try {
+ java.nio.file.Path dir = java.nio.file.Paths.get("build", "spike-reports").toAbsolutePath();
+ java.nio.file.Files.createDirectories(dir);
+ java.nio.file.Files.write(dir.resolve(name), text.getBytes("UTF-8"));
+ } catch (Exception e) {
+ System.out.println("[SPIKE] could not write the report file: " + e);
+ }
+ }
+
+ private static boolean hasPrefix(List lines, String prefix) {
+ for (String line : lines) {
+ if (line.startsWith(prefix)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static String oneLine(String s) {
+ return s.replace((char) 10, ' ').replace((char) 13, ' ').trim();
+ }
+
+ private static String fmt(double v) {
+ return String.format(Locale.ROOT, "%.4f", v);
+ }
+}
diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/SpikeSubBlockPositionGranularityTest.java b/src/test/java/zmaster587/advancedRocketry/test/client/SpikeSubBlockPositionGranularityTest.java
new file mode 100644
index 000000000..b79236731
--- /dev/null
+++ b/src/test/java/zmaster587/advancedRocketry/test/client/SpikeSubBlockPositionGranularityTest.java
@@ -0,0 +1,298 @@
+package zmaster587.advancedRocketry.test.client;
+
+import com.github.stannismod.forge.testing.junit.AbstractClientE2ETest;
+import com.google.gson.JsonObject;
+
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.junit.Assert.assertTrue;
+
+/**
+ * SPIKE — does a CONNECTED player's sub-block position survive the client↔server round trip far from
+ * the origin?
+ *
+ * What this is NOT, and the retraction it carries
+ * This class was first written around the observation "a 0.05-block move lands at 2M and 4M and does
+ * not land at all at 8M, 12M or 16M", and it went looking for the coordinate at which precision runs
+ * out. That observation was an artefact of its own delivery. The physics mod cancels, silently, any
+ * teleport whose destination falls in its reserved shipyard quadrant — {@code chunkX >= 318401 &&
+ * chunkZ >= -1599}, i.e. X ≥ 5,094,416 and Z ≥ -25,584 — and the old arena sat at {@code Z = 0},
+ * so every rung from 8M up was refused by a mod constant rather than by any property of the number.
+ * The command still reported success. Nothing about precision was ever measured.
+ *
+ * So the arena moves to {@code Z = }{@value #ARENA_Z}, below the quadrant's Z edge, where the
+ * predicate is false at every X, and the long jump between rungs is delivered by
+ * {@code /artest player far-tp} (vanilla's own dimension-change path, which is how a long jump escapes
+ * the speed check). The short sub-block steps stay on plain {@code /tp}: they are not long jumps and
+ * they are outside the quadrant.
+ *
+ * The question that is actually left
+ * Two neighbouring facts already exist and neither answers it:
+ *
+ * - {@code SpikeFarCoordinateIntegrityTest} spawns armour stands 0.05 apart out to 28M and reads
+ * both back exactly — but a SPAWNED entity's position never crosses the wire.
+ * - {@code SpikeFarCoordinatePlayabilityTest} measures a 0.3000 collision stand-off at 16M/20M/24M
+ * — a sub-block quantity, but one produced by the server's own physics, not asked for.
+ *
+ * What remains is the round trip: a position ASKED for at a far coordinate, written by the server,
+ * pushed to the client, and read back from both. If anything in that path narrows to a float, a
+ * quantum of 1 or 2 blocks at 16M is what it would look like — and it would show here and nowhere else.
+ *
+ * Acceptance, stated before the run
+ * At every rung, for every offset in the ladder {0, 0.05, 0.1, 0.25, 0.5, 1.0} from the same base:
+ *
+ * - the SERVER's {@code posX} must equal the asked position within
+ * {@value #SERVER_TOLERANCE} blocks;
+ * - the CLIENT's own {@code posX} must agree with it within {@value #CLIENT_TOLERANCE} blocks;
+ * - every non-zero offset must read back DISTINCT from the offset-0 base — a quantum would
+ * collapse the small ones onto it.
+ *
+ * {@code x = 0} is carried as the control in the same run, same arena shape, same commands: if the
+ * control fails, the instrument is broken and no rung is evidence of anything.
+ *
+ * Designed to come back NO. If every offset resolves at 24M exactly as at the origin, the
+ * wire is not the ceiling either, and "entity doubles degrade past ±2M" has nothing left holding it up
+ * on any of its three legs.
+ */
+public class SpikeSubBlockPositionGranularityTest extends AbstractClientE2ETest {
+
+ /** The control first, then today's half-cell, the ratified half-cell, and the measured margin. */
+ private static final int[] X_LADDER = {0, 2_000_000, 8_000_000, 16_000_000, 24_000_000};
+ private static final double[] OFFSETS = {0d, 0.05d, 0.1d, 0.25d, 0.5d, 1.0d};
+
+ /**
+ * The arena's Z. The physics mod's reserved quadrant starts at {@code chunkZ >= -1599}
+ * (Z ≥ -25,584); this sits well below it, so its teleport veto never fires and the only thing
+ * under test is the coordinate's own magnitude.
+ */
+ private static final int ARENA_Z = -100_000;
+
+ private static final int OVERWORLD = 0;
+ /** Well above sea level: 2M and 16M are both ocean, and a delivery into water measures the water. */
+ private static final int FLOOR_Y = 140;
+ private static final int STAND_Y = FLOOR_Y + 1;
+
+ private static final double SERVER_TOLERANCE = 0.001d;
+ private static final double CLIENT_TOLERANCE = 0.05d;
+ private static final double ARRIVAL_TOLERANCE = 1.0d;
+ private static final double Y_TOLERANCE = 0.05d;
+ /** How many (deliver, settle) rounds a rung gets before it is called undeliverable. */
+ private static final int DELIVERY_ATTEMPTS = 4;
+
+ private String botName;
+
+ private String exec(String cmd) throws Exception {
+ return String.join("\n", serverClient().execute(cmd));
+ }
+
+ @Test
+ public void doesASubBlockPositionSurviveTheRoundTripFarFromTheOrigin() throws Exception {
+ bot().waitForWorld();
+ exec("gamerule sendCommandFeedback false");
+ exec("gamerule logAdminCommands false");
+ exec("gamerule doMobSpawning false");
+ exec("gamerule doDaylightCycle false");
+ exec("gamerule doWeatherCycle false");
+ exec("weather clear");
+ bot().setRenderDistance(4);
+
+ String health = exec("artest player health");
+ Matcher nm = Pattern.compile("\"player\"\\s*:\\s*\"([^\"]+)\"").matcher(health);
+ assertTrue("player health must echo the player name: " + health, nm.find());
+ botName = nm.group(1);
+
+ List report = new ArrayList<>();
+ List inconclusive = new ArrayList<>();
+ List broken = new ArrayList<>();
+ boolean controlHeld = false;
+
+ // The cheapest competing explanation, asked once: a world border refuses a teleport past it
+ // while reporting success, and it would produce this whole ladder with no precision story.
+ report.add("worldborder: " + oneLine(exec("worldborder get")));
+
+ for (int x : X_LADDER) {
+ buildFloor(x);
+ String floorFault = inspectFloor(x);
+ if (floorFault != null) {
+ buildFloor(x); // one retry: a fill can lose a race with chunk loading
+ floorFault = inspectFloor(x);
+ }
+ if (floorFault != null) {
+ inconclusive.add("x=" + x + " the floor did not build - " + floorFault
+ + " (arrangement, not the coordinate)");
+ continue;
+ }
+
+ String delivery = deliverAndStand(x);
+ if (delivery != null) {
+ inconclusive.add("x=" + x + " " + delivery);
+ continue;
+ }
+
+ double base = Double.NaN;
+ List rows = new ArrayList<>();
+ List rungFailures = new ArrayList<>();
+ for (double offset : OFFSETS) {
+ double target = x + 0.5d + offset;
+ exec("tp " + botName + " " + fmt(target) + " " + STAND_Y + " " + fmt(ARENA_Z + 0.5d));
+ exec("artest server wait " + OVERWORLD + " 6");
+ bot().waitTicks(6);
+
+ double gotServer = serverX();
+ double gotClient = clientX();
+ if (offset == 0d) {
+ base = gotServer;
+ }
+ double serverErr = Math.abs(gotServer - target);
+ double clientErr = Math.abs(gotClient - gotServer);
+ boolean distinct = offset == 0d || Math.abs(gotServer - base) > SERVER_TOLERANCE;
+
+ rows.add("+" + fmt(offset) + " asked " + fmt(target)
+ + " server " + fmt(gotServer) + " (err " + fmt(serverErr) + ")"
+ + " client " + fmt(gotClient) + " (delta " + fmt(clientErr) + ")"
+ + " distinctFromBase=" + distinct);
+ if (serverErr > SERVER_TOLERANCE) {
+ rungFailures.add("+" + fmt(offset) + " server missed by " + fmt(serverErr));
+ }
+ if (clientErr > CLIENT_TOLERANCE) {
+ rungFailures.add("+" + fmt(offset) + " client disagrees by " + fmt(clientErr));
+ }
+ if (!distinct) {
+ rungFailures.add("+" + fmt(offset) + " collapsed onto the base");
+ }
+ }
+
+ report.add("x=" + x + (rungFailures.isEmpty() ? " OK" : " FAIL " + rungFailures));
+ for (String r : rows) {
+ report.add(" " + r);
+ }
+ if (x == 0) {
+ controlHeld = rungFailures.isEmpty();
+ } else if (!rungFailures.isEmpty()) {
+ broken.add(x + rungFailures.toString());
+ }
+ }
+
+ StringBuilder out = new StringBuilder("[SPIKE sub-block position round trip]\n");
+ for (String line : report) {
+ out.append(" ").append(line).append('\n');
+ }
+ for (String line : inconclusive) {
+ out.append(" INCONCLUSIVE ").append(line).append('\n');
+ }
+ System.out.println(out);
+ writeReport("far-coordinate-subblock-roundtrip.txt", out.toString());
+
+ // The control decides whether anything else in this run is evidence. Asserted FIRST, so a
+ // broken instrument reports as a broken instrument and not as a coordinate ceiling.
+ assertTrue("the x=0 control did not resolve its own offset ladder - the instrument is broken, "
+ + "so no rung here says anything about far coordinates:\n" + out, controlHeld);
+ assertTrue("a sub-block position was lost at: " + broken + "\n" + out, broken.isEmpty());
+ }
+
+ // ─── arrangement ────────────────────────────────────────────────────────────
+
+ private void buildFloor(int x) throws Exception {
+ exec("artest chunk forceload " + OVERWORLD + " " + (x >> 4) + " " + (ARENA_Z >> 4));
+ exec("artest server wait " + OVERWORLD + " 20");
+ exec("artest fill " + OVERWORLD + " " + (x - 4) + " " + FLOOR_Y + " " + (ARENA_Z - 4) + " "
+ + (x + 4) + " " + FLOOR_Y + " " + (ARENA_Z + 4) + " minecraft:stone");
+ exec("artest fill " + OVERWORLD + " " + (x - 4) + " " + STAND_Y + " " + (ARENA_Z - 4) + " "
+ + (x + 4) + " " + (STAND_Y + 2) + " " + (ARENA_Z + 4) + " minecraft:air");
+ }
+
+ /** @return {@code null} if the floor is where it must be, else what is wrong with it */
+ private String inspectFloor(int x) throws Exception {
+ for (int dx : new int[] {0, 1, 2}) {
+ String at = exec("artest block at " + OVERWORLD + " " + (x + dx) + " " + FLOOR_Y + " "
+ + ARENA_Z);
+ if (!at.contains("stone")) {
+ return "the floor is not stone at x+" + dx + " (" + oneLine(at) + ")";
+ }
+ String above = exec("artest block at " + OVERWORLD + " " + (x + dx) + " " + STAND_Y + " "
+ + ARENA_Z);
+ if (!above.contains("minecraft:air")) {
+ return "the standing space is not air at x+" + dx + " (" + oneLine(above) + ")";
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Delivers the player into the arena and does not return until he is STANDING in it. One delivery
+ * is not enough: the chunks are force-loaded on the SERVER but the client has not received them
+ * yet, so client-side physics see air and he falls through the floor. The loop converges rather
+ * than guessing a settle time, and reports which of the two conditions it never met.
+ *
+ * @return {@code null} once he is standing, or a reason string for the INCONCLUSIVE list
+ */
+ private String deliverAndStand(int x) throws Exception {
+ double lastX = Double.NaN;
+ double lastY = Double.NaN;
+ String lastReply = "";
+ for (int attempt = 1; attempt <= DELIVERY_ATTEMPTS; attempt++) {
+ lastReply = exec("artest player far-tp " + fmt(x + 0.5d) + " " + STAND_Y + " "
+ + fmt(ARENA_Z + 0.5d));
+ exec("artest server wait " + OVERWORLD + " 40");
+ bot().waitTicks(30);
+ lastX = serverX();
+ lastY = serverY();
+ if (Math.abs(lastX - (x + 0.5d)) < ARRIVAL_TOLERANCE
+ && Math.abs(lastY - STAND_Y) < Y_TOLERANCE) {
+ return null;
+ }
+ }
+ boolean arrived = Math.abs(lastX - (x + 0.5d)) < ARRIVAL_TOLERANCE;
+ return (arrived
+ ? "he arrived but would not stand (posY=" + fmt(lastY) + ", floor top " + STAND_Y + ")"
+ : "the player never arrived (server posX=" + lastX + ", wanted " + (x + 0.5d) + ")")
+ + " after " + DELIVERY_ATTEMPTS + " deliveries - arrangement, not the coordinate."
+ + " lastReply=" + oneLine(lastReply);
+ }
+
+ // ─── instruments ────────────────────────────────────────────────────────────
+
+ private double serverX() throws Exception {
+ return field(exec("artest player health"), "posX");
+ }
+
+ private double serverY() throws Exception {
+ return field(exec("artest player health"), "posY");
+ }
+
+ /** The CLIENT's own record of where it thinks it is — the far end of the round trip. */
+ private double clientX() throws Exception {
+ JsonObject state = bot().reportState();
+ return state.has("playerX") ? state.get("playerX").getAsDouble() : Double.NaN;
+ }
+
+ private static double field(String json, String key) {
+ Matcher m = Pattern.compile("\"" + key + "\"\\s*:\\s*([-0-9.eE]+)").matcher(json);
+ return m.find() ? Double.parseDouble(m.group(1)) : Double.NaN;
+ }
+
+ /** The report is the deliverable, so it also lands on disk and survives a truncated console. */
+ private static void writeReport(String name, String text) {
+ try {
+ java.nio.file.Path dir = java.nio.file.Paths.get("build", "spike-reports").toAbsolutePath();
+ java.nio.file.Files.createDirectories(dir);
+ java.nio.file.Files.write(dir.resolve(name), text.getBytes("UTF-8"));
+ } catch (Exception e) {
+ System.out.println("[SPIKE] could not write the report file: " + e);
+ }
+ }
+
+ private static String oneLine(String s) {
+ return s.replace((char) 10, ' ').replace((char) 13, ' ').trim();
+ }
+
+ private static String fmt(double v) {
+ return String.format(Locale.ROOT, "%.4f", v);
+ }
+}
From b1d5670bbd393e59a35d78e5bae47b920d17e5c7 Mon Sep 17 00:00:00 2001
From: StannisMod
Date: Fri, 14 Aug 2026 15:52:03 +0300
Subject: [PATCH 12/42] feat: one chart metric for the whole universe layer
- state the chart scale (250 m/block) and derive AU, light year, orbit unit
- place procedural bodies by their own orbital law, not a second layout
- a system that will not fit loses bodies, never scale
- separate star spacing from a system's clear space (10 000 AU floor)
- tilt an orbit without enlarging it
- companions become first-class stars with real orbits and ids
- every star of a system lights every world in it
- fix groupMax reading the groupMin attribute
---
.../api/dimension/solar/StellarBody.java | 149 +++++++-
.../render/planet/RenderAsteroidSky.java | 2 +-
.../render/planet/RenderPlanetarySky.java | 2 +-
.../render/planet/RenderSpaceTravelSky.java | 4 +-
.../dimension/DimensionManager.java | 54 ++-
.../modules/ModulePlanetSelector.java | 10 +-
.../TileHolographicPlanetSelector.java | 10 +-
.../universe/BodyEphemeris.java | 28 +-
.../universe/ClusteredGalaxyGenerator.java | 159 +++++---
.../universe/GalaxyGenConfig.java | 22 +-
.../universe/PlanetDerivation.java | 29 +-
.../universe/PlanetRealizer.java | 34 +-
.../universe/SystemContent.java | 63 ++--
.../universe/UniverseScale.java | 106 ++++++
.../util/AstronomicalBodyHelper.java | 111 ++++--
.../util/XMLPlanetLoader.java | 31 +-
.../test/integration/SystemContentTest.java | 48 +++
.../test/unit/AstronomicalBodyHelperTest.java | 55 ++-
.../unit/ClusteredGalaxyGeneratorTest.java | 345 ++++++++++++------
.../test/unit/PlanetDerivationTest.java | 28 +-
.../test/unit/PlanetRealizationTest.java | 122 ++++---
.../test/unit/StellarHierarchyTest.java | 188 ++++++++++
.../test/unit/SystemRetinueTest.java | 90 ++++-
.../test/unit/UniverseRegistryTest.java | 17 +-
24 files changed, 1313 insertions(+), 394 deletions(-)
create mode 100644 src/main/java/zmaster587/advancedRocketry/universe/UniverseScale.java
create mode 100644 src/test/java/zmaster587/advancedRocketry/test/unit/StellarHierarchyTest.java
diff --git a/src/main/java/zmaster587/advancedRocketry/api/dimension/solar/StellarBody.java b/src/main/java/zmaster587/advancedRocketry/api/dimension/solar/StellarBody.java
index 87c86e24f..d9b9d385a 100644
--- a/src/main/java/zmaster587/advancedRocketry/api/dimension/solar/StellarBody.java
+++ b/src/main/java/zmaster587/advancedRocketry/api/dimension/solar/StellarBody.java
@@ -20,6 +20,29 @@ public class StellarBody {
/** {@code M ≈ R^1.25} — the inverse of the main-sequence {@code R ≈ M^0.8}. Exact for Sol. */
private static final double MAIN_SEQUENCE_MASS_EXPONENT = 1.25d;
+ /**
+ * How far a companion orbits its primary when nothing has said — in the same distance units a
+ * planet's orbit is in (100 = 1 AU), so this is 0.05 AU: a close pair, the kind that reads as two
+ * suns in one sky rather than as a second star elsewhere in the system.
+ *
+ * The field this replaces was an ANGLE with the same default of 5, applied to the sky as a
+ * tilt. An angle cannot say where a companion is — only how far off the primary it looks from one
+ * particular world — so nothing could place it, light a planet by it, or let it move.
+ */
+ public static final int DEFAULT_COMPANION_ORBIT = 5;
+
+ /**
+ * Solar-map units per AU — the multiplier {@code DimensionProperties.getSpacePosition} lays a
+ * planet out with (100 map units per 100 distance units, i.e. per AU). Stated here so a star and
+ * a planet at the same orbital distance land at the same place on one map.
+ */
+ private static final double PLANET_MAP_UNITS_PER_AU = 100d;
+
+ /** Sentinel for {@link #baseTheta}: nobody has stated one, so binding picks a phase. */
+ private static final double THETA_UNSTATED = Double.NaN;
+ /** The golden angle, in radians — how unstated companion phases are spread. */
+ private static final double GOLDEN_ANGLE = 2.399963229728653d;
+
public List subStars;
int numPlanets;
int discoveredPlanets;
@@ -29,7 +52,10 @@ public class StellarBody {
private float mass = MASS_UNSET;
String name;
short posX, posZ;
- float starSeperation;
+ /** This star's orbit about its primary, in distance units (100 = 1 AU). Zero for a primary. */
+ private int orbitalDistance;
+ /** Its angle on that orbit at tick zero, in radians; {@link #THETA_UNSTATED} until bound. */
+ private double baseTheta = THETA_UNSTATED;
StellarBody parentStar;
private int temperature;
private HashMap planets;
@@ -40,7 +66,7 @@ public StellarBody() {
planets = new HashMap<>();
size = 1f;
subStars = new LinkedList<>();
- starSeperation = 5f;
+ orbitalDistance = DEFAULT_COMPANION_ORBIT;
isBlackHole = false;
diskAngle = 70;
}
@@ -49,14 +75,31 @@ public List getSubStars() {
return subStars;
}
+ /**
+ * Bind {@code star} as a companion of this one.
+ *
+ * The companion keeps its own identity. This used to overwrite the companion's id with
+ * the primary's, which made a companion unaddressable: a planet binds to its star by a flat
+ * {@code starId}, so with both stars answering the same number there was no value that could mean
+ * "I orbit the companion" — no companion could own a world, and neither a wide binary nor a
+ * three-star hierarchy was expressible however well the storage nested. Minting the id is the star
+ * registry's job, because the id space is the registry's; this method only states the
+ * relationship.
+ */
public void addSubStar(StellarBody star) {
if (star.name == null)
star.setName(name + "-" + (subStars.size() + 1));
- star.setId(this.id);
+ if (Double.isNaN(star.baseTheta))
+ star.baseTheta = subStars.size() * GOLDEN_ANGLE;
subStars.add(star);
star.parentStar = this;
}
+ /** This star's primary, or {@code null} when it is the one its system is named for. */
+ public StellarBody getParentStar() {
+ return parentStar;
+ }
+
public boolean isBlackHole() {
return isBlackHole;
}
@@ -69,13 +112,68 @@ public int getDisplayRadius() {
return (int) (100 * size);
}
- //Returns the distance between the star and sub stars
- public float getStarSeparation() {
- return starSeperation;
+ /**
+ * How far this star orbits its primary, in distance units (100 = 1 AU) — the same field a planet
+ * carries, meaning the same thing. Zero, and meaningless, for a star that is nobody's companion.
+ */
+ public int getOrbitalDistance() {
+ return orbitalDistance;
+ }
+
+ public void setOrbitalDistance(int distanceUnits) {
+ this.orbitalDistance = Math.max(0, distanceUnits);
+ }
+
+ /** This star's angle on its orbit at tick zero, in radians. */
+ public double getBaseTheta() {
+ return Double.isNaN(baseTheta) ? 0d : baseTheta;
}
- public void setStarSeparation(float seperation) {
- this.starSeperation = seperation;
+ public void setBaseTheta(double radians) {
+ this.baseTheta = radians;
+ }
+
+ /**
+ * This star's offset from the one its SYSTEM is named for, in AU, as a two-element
+ * {@code (x, z)} pair at tick zero — the barycentric geometry a companion needs to be placed,
+ * lit by, or measured against.
+ *
+ * Zero for a primary, and composed up the chain for a companion of a companion, so a
+ * three-star hierarchy is the same arithmetic as a pair rather than a special case.
+ */
+ public double[] offsetFromSystemAu() {
+ if (parentStar == null) {
+ return new double[] {0d, 0d};
+ }
+ double[] parent = parentStar.offsetFromSystemAu();
+ double a = orbitalDistance / 100d; // 100 distance units to the AU
+ double theta = getBaseTheta();
+ return new double[] {parent[0] + a * Math.cos(theta), parent[1] + a * Math.sin(theta)};
+ }
+
+ /** The distance between two stars of one system, in AU, at tick zero. */
+ public double separationAuFrom(StellarBody other) {
+ if (other == null) {
+ return 0d;
+ }
+ double[] a = offsetFromSystemAu();
+ double[] b = other.offsetFromSystemAu();
+ return Math.hypot(a[0] - b[0], a[1] - b[1]);
+ }
+
+ /**
+ * How far apart this star and its primary look, in DEGREES, seen from a world orbiting the
+ * primary at {@code observerOrbitalDistance}.
+ *
+ * A real angle from a real distance, so a close pair reads as two suns almost together and a
+ * wide one puts its companion somewhere else in the sky entirely — which is the difference the
+ * old constant tilt could not express.
+ */
+ public float apparentSeparationDegrees(int observerOrbitalDistance) {
+ if (parentStar == null || orbitalDistance <= 0 || observerOrbitalDistance <= 0) {
+ return 0f;
+ }
+ return (float) Math.toDegrees(Math.atan2(orbitalDistance, observerOrbitalDistance));
}
public float getSize() {
@@ -142,11 +240,14 @@ public IDimensionProperties removePlanet(IDimensionProperties planet) {
}
/**
- * @return the number of planets orbiting this star
+ * @return the number of planets orbiting THIS star
+ *
+ * A companion answers for its own worlds, not for its primary's. It used to delegate upward
+ * while {@link #addPlanet} filled the companion's own map, so a companion with planets reported
+ * its primary's count and a companion with none reported a number that was not zero — the same
+ * object disagreeing with itself about what it holds.
*/
public int getNumPlanets() {
- if (parentStar != null)
- return parentStar.getNumPlanets();
return numPlanets;
}
@@ -263,7 +364,8 @@ public void writeToNBT(NBTTagCompound nbt) {
if (mass > MASS_UNSET) {
nbt.setFloat("mass", mass);
}
- nbt.setFloat("seperation", starSeperation);
+ nbt.setInteger("companionOrbit", orbitalDistance);
+ nbt.setDouble("companionTheta", getBaseTheta());
nbt.setBoolean("isBlackHole", isBlackHole);
nbt.setFloat("diskAngle", diskAngle);
@@ -293,8 +395,9 @@ public void readFromNBT(NBTTagCompound nbt) {
mass = nbt.hasKey("mass") ? nbt.getFloat("mass") : MASS_UNSET;
- if (nbt.hasKey("seperation"))
- starSeperation = nbt.getFloat("seperation");
+ if (nbt.hasKey("companionOrbit"))
+ orbitalDistance = nbt.getInteger("companionOrbit");
+ baseTheta = nbt.hasKey("companionTheta") ? nbt.getDouble("companionTheta") : THETA_UNSTATED;
subStars.clear();
if (nbt.hasKey("subStars")) {
@@ -309,8 +412,22 @@ public void readFromNBT(NBTTagCompound nbt) {
}
}
+ /**
+ * Where this star stands on the legacy solar map: the system's own star at the origin, and a
+ * companion offset by its orbit about whatever it orbits.
+ *
+ * It used to answer an empty position for every star, so the space layer placed every
+ * companion of every system at the same point — the one place a star of a binary certainly is
+ * not. The offset uses the same distance multiplier a planet's does, so a companion and a planet
+ * at the same orbital distance land at the same place on the map, which is the whole reason the
+ * two carry the same field in the same unit.
+ */
public SpacePosition getSpacePosition() {
- //TODO
- return new SpacePosition();
+ SpacePosition position = new SpacePosition();
+ position.star = this;
+ double[] offset = offsetFromSystemAu();
+ position.x = offset[0] * PLANET_MAP_UNITS_PER_AU;
+ position.z = offset[1] * PLANET_MAP_UNITS_PER_AU;
+ return position;
}
}
diff --git a/src/main/java/zmaster587/advancedRocketry/client/render/planet/RenderAsteroidSky.java b/src/main/java/zmaster587/advancedRocketry/client/render/planet/RenderAsteroidSky.java
index 8d062ea8a..27c74bd0a 100644
--- a/src/main/java/zmaster587/advancedRocketry/client/render/planet/RenderAsteroidSky.java
+++ b/src/main/java/zmaster587/advancedRocketry/client/render/planet/RenderAsteroidSky.java
@@ -659,7 +659,7 @@ protected void drawStarAndSubStars(BufferBuilder buffer, StellarBody sun, Dimens
GL11.glRotatef(phaseInc, 0, 1, 0);
GL11.glPushMatrix();
- GL11.glRotatef(subStar.getStarSeparation() * AstronomicalBodyHelper.getBodySizeMultiplier(solarOrbitalDistance), 1, 0, 0);
+ GL11.glRotatef(subStar.apparentSeparationDegrees(solarOrbitalDistance), 1, 0, 0);
float[] color = subStar.getColor();
drawStar(buffer, subStar, properties, solarOrbitalDistance, subStar.getSize(),
new Vec3d(color[0], color[1], color[2]), multiplier);
diff --git a/src/main/java/zmaster587/advancedRocketry/client/render/planet/RenderPlanetarySky.java b/src/main/java/zmaster587/advancedRocketry/client/render/planet/RenderPlanetarySky.java
index 47d28b170..458714777 100644
--- a/src/main/java/zmaster587/advancedRocketry/client/render/planet/RenderPlanetarySky.java
+++ b/src/main/java/zmaster587/advancedRocketry/client/render/planet/RenderPlanetarySky.java
@@ -1146,7 +1146,7 @@ protected void drawStarAndSubStars(BufferBuilder buffer, StellarBody sun, Dimens
GL11.glRotatef(phaseInc, 0, 1, 0);
GL11.glPushMatrix();
- GL11.glRotatef(subStar.getStarSeparation() * AstronomicalBodyHelper.getBodySizeMultiplier(solarOrbitalDistance), 1, 0, 0);
+ GL11.glRotatef(subStar.apparentSeparationDegrees(solarOrbitalDistance), 1, 0, 0);
float[] color = subStar.getColor();
drawStar(buffer, subStar, properties, solarOrbitalDistance, subStar.getSize(), new Vec3d(color[0], color[1], color[2]), multiplier);
GL11.glPopMatrix();
diff --git a/src/main/java/zmaster587/advancedRocketry/client/render/planet/RenderSpaceTravelSky.java b/src/main/java/zmaster587/advancedRocketry/client/render/planet/RenderSpaceTravelSky.java
index ed3d2e159..2c3ed2f00 100644
--- a/src/main/java/zmaster587/advancedRocketry/client/render/planet/RenderSpaceTravelSky.java
+++ b/src/main/java/zmaster587/advancedRocketry/client/render/planet/RenderSpaceTravelSky.java
@@ -649,7 +649,9 @@ private void buildSolarSystem(SpacePosition playerPosition) {
phase += phaseInc;
//Get substar separation for placement from the orbital distance of the substars
- SpacePosition subStarSpacePosition = mainStarPos.getFromSpherical(40 * subStar.getStarSeparation(), theta);
+ SpacePosition subStarSpacePosition =
+ mainStarPos.getFromSpherical(40d * subStar.getOrbitalDistance(),
+ subStar.getBaseTheta());
renderStar(subStar, subStarSpacePosition, playerPosition);
}
diff --git a/src/main/java/zmaster587/advancedRocketry/dimension/DimensionManager.java b/src/main/java/zmaster587/advancedRocketry/dimension/DimensionManager.java
index 7504c9ea7..290e631b0 100644
--- a/src/main/java/zmaster587/advancedRocketry/dimension/DimensionManager.java
+++ b/src/main/java/zmaster587/advancedRocketry/dimension/DimensionManager.java
@@ -597,24 +597,68 @@ public StellarBody getStar(int id) {
}
/**
- * @return a list of star ids
+ * @return the ids of the SYSTEMS — one per star that is nobody's companion
+ *
+ * Companions are addressable through {@link #getStar(int)} but are not systems: they are drawn,
+ * saved, synced and placed as part of the primary they orbit. A consumer that walked every
+ * registered star instead would draw a binary twice on the map, write it twice to XML and give
+ * its companion a galactic address of its own.
*/
public Set getStarIds() {
- return starList.keySet();
+ Set ids = new HashSet<>();
+ for (Entry e : starList.entrySet()) {
+ if (e.getValue() != null && e.getValue().getParentStar() == null) {
+ ids.add(e.getKey());
+ }
+ }
+ return ids;
}
+ /** The SYSTEMS — see {@link #getStarIds()}. */
public Collection getStars() {
-
- return starList.values();
+ List primaries = new ArrayList<>();
+ for (StellarBody star : starList.values()) {
+ if (star != null && star.getParentStar() == null) {
+ primaries.add(star);
+ }
+ }
+ return primaries;
}
/**
- * Adds a star to the handler
+ * Adds a star to the handler, together with every companion under it.
+ *
+ * A companion is a star like any other and gets an id of its own here, because the id space is
+ * this registry's to hand out and a companion that is not in {@code starList} cannot be resolved
+ * by {@link #getStar(int)} — which is how a planet finds the star it orbits. Without that, a
+ * companion could be described but never orbited: the hierarchy existed in storage and nowhere
+ * else.
+ *
+ * An id already in use by a DIFFERENT star is replaced rather than honoured; a companion that
+ * already holds its own id (a reload, a re-registration) keeps it, so ids survive a save.
*
* @param star star to add
*/
public void addStar(StellarBody star) {
+ if (star == null) {
+ return;
+ }
starList.put(star.getId(), star);
+ addCompanionsOf(star);
+ }
+
+ private void addCompanionsOf(StellarBody primary) {
+ for (StellarBody companion : primary.getSubStars()) {
+ if (companion == null) {
+ continue;
+ }
+ StellarBody holder = starList.get(companion.getId());
+ if (holder != null && holder != companion) {
+ companion.setId(getNextFreeStarId());
+ }
+ starList.put(companion.getId(), companion);
+ addCompanionsOf(companion);
+ }
}
/**
diff --git a/src/main/java/zmaster587/advancedRocketry/inventory/modules/ModulePlanetSelector.java b/src/main/java/zmaster587/advancedRocketry/inventory/modules/ModulePlanetSelector.java
index b4106eef7..45d3bf747 100644
--- a/src/main/java/zmaster587/advancedRocketry/inventory/modules/ModulePlanetSelector.java
+++ b/src/main/java/zmaster587/advancedRocketry/inventory/modules/ModulePlanetSelector.java
@@ -209,8 +209,10 @@ private void renderGalaxyMap(IGalaxy galaxy, int posX, int posY, float distanceZ
displaySize = (int) (planetSizeMultiplier * star2.getDisplayRadius());
int deltaX, deltaY;
- deltaX = (int) ((int) (star2.getStarSeparation() * MathHelper.cos(phase) * 0.5*distanceZoomMultiplier));
- deltaY = (int) ((int) (star2.getStarSeparation() * MathHelper.sin(phase) * 0.5*distanceZoomMultiplier));
+ deltaX = (int) (star2.getOrbitalDistance()
+ * Math.cos(star2.getBaseTheta()) * 0.5 * distanceZoomMultiplier);
+ deltaY = (int) (star2.getOrbitalDistance()
+ * Math.sin(star2.getBaseTheta()) * 0.5 * distanceZoomMultiplier);
planetList.add(button = new ModuleButton(
offsetX + deltaX,
@@ -270,8 +272,8 @@ private void renderStarSystem(StellarBody star, int posX, int posY, float distan
displaySize = (int) (planetSizeMultiplier * star2.getDisplayRadius());
int deltaX, deltaY;
- deltaX = (int) (star2.getStarSeparation() * MathHelper.cos(phase) * 0.5);
- deltaY = (int) (star2.getStarSeparation() * MathHelper.sin(phase) * 0.5);
+ deltaX = (int) (star2.getOrbitalDistance() * Math.cos(star2.getBaseTheta()) * 0.5);
+ deltaY = (int) (star2.getOrbitalDistance() * Math.sin(star2.getBaseTheta()) * 0.5);
planetList.add(button = new ModuleButton(
offsetX + deltaX, offsetY + deltaY,
diff --git a/src/main/java/zmaster587/advancedRocketry/tile/station/TileHolographicPlanetSelector.java b/src/main/java/zmaster587/advancedRocketry/tile/station/TileHolographicPlanetSelector.java
index 9798a5a9a..7bc96084c 100644
--- a/src/main/java/zmaster587/advancedRocketry/tile/station/TileHolographicPlanetSelector.java
+++ b/src/main/java/zmaster587/advancedRocketry/tile/station/TileHolographicPlanetSelector.java
@@ -127,8 +127,10 @@ public void update() {
float phase = 0;
for (EntityUIStar entity : starEntities) {
double deltaX, deltaY;
- deltaX = (entity.getStarProperties().getStarSeparation() * MathHelper.cos(phase) * 0.05);
- deltaY = (entity.getStarProperties().getStarSeparation() * MathHelper.sin(phase) * 0.05);
+ deltaX = entity.getStarProperties().getOrbitalDistance()
+ * Math.cos(entity.getStarProperties().getBaseTheta()) * 0.05;
+ deltaY = entity.getStarProperties().getOrbitalDistance()
+ * Math.sin(entity.getStarProperties().getBaseTheta()) * 0.05;
entity.setPosition(this.pos.getX() + .5 + getInterpHologramSize() * deltaX, this.pos.getY() + 1, this.pos.getZ() + .5 + getInterpHologramSize() * deltaY);
entity.setScale(getInterpHologramSize() * entity.getStarProperties().getSize());
@@ -287,8 +289,8 @@ private void rebuildSystem() {
for (StellarBody body : starList) {
double deltaX, deltaY;
- deltaX = (body.getStarSeparation() * MathHelper.cos(phase) * 0.05);
- deltaY = (body.getStarSeparation() * MathHelper.sin(phase) * 0.05);
+ deltaX = body.getOrbitalDistance() * Math.cos(body.getBaseTheta()) * 0.05;
+ deltaY = body.getOrbitalDistance() * Math.sin(body.getBaseTheta()) * 0.05;
EntityUIStar entity = new EntityUIStar(world, body, count++, this, this.pos.getX() + .5 + deltaX, this.pos.getY() + 1, this.pos.getZ() + .5 + deltaY);
this.getWorld().spawnEntity(entity);
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/BodyEphemeris.java b/src/main/java/zmaster587/advancedRocketry/universe/BodyEphemeris.java
index ed0a6af33..196eae559 100644
--- a/src/main/java/zmaster587/advancedRocketry/universe/BodyEphemeris.java
+++ b/src/main/java/zmaster587/advancedRocketry/universe/BodyEphemeris.java
@@ -58,8 +58,15 @@ public static BodyEphemeris fixed(long dx, long dy, long dz) {
}
/**
- * An orbit about whatever this body is bound to: {@code (d·cos θ, d·sin φ, d·sin θ)} in units of
- * {@code unitBlocks}, with {@code θ = (2π·(t mod P)/P + baseTheta) · (retrograde ? −1 : +1)}.
+ * An orbit about whatever this body is bound to: {@code (d·cos φ·cos θ, d·sin φ, d·cos φ·sin θ)} in
+ * units of {@code unitBlocks}, with {@code θ = (2π·(t mod P)/P + baseTheta) · (retrograde ? −1 : +1)}.
+ *
+ * The inclination tilts the orbit; it does not enlarge it. The law used to read
+ * {@code (d·cos θ, d·sin φ, d·sin θ)}, whose length is {@code d·√(1 + sin²φ)} — so an inclined body
+ * stood further from its primary than its own orbital distance said, by up to 41 % at the steepest
+ * authored angle. Every number derived from that distance (insolation, temperature, period) said
+ * one thing while the flight said another, which is exactly the split this frame exists to close.
+ * With the cosine factor the offset's length is {@code d} at every inclination.
*
* The retrograde sign multiplies the SUM, not the time term alone — that is the shipped law and
* a body's NAME is derived through it, so changing the grouping would move every retrograde body's
@@ -82,6 +89,18 @@ public double distUnits() {
return distUnits;
}
+ /**
+ * The base angle this law was built with, in RADIANS — where the body stands at tick zero, before
+ * any time has passed.
+ *
+ *
Read it rather than recovering an angle from where the body's cell ended up: a cell is coarse,
+ * so the recovered angle is the drawn one rounded to whatever the cell grid could express, and two
+ * consumers rounding it separately put the same body in two places.
+ */
+ public double baseTheta() {
+ return baseTheta;
+ }
+
public boolean isStatic() {
return unitBlocks == 0L || !(periodTicks > 0d) || Double.isInfinite(periodTicks)
|| distUnits == 0d;
@@ -94,10 +113,11 @@ public BlockDelta offsetAt(long tick) {
}
double theta = thetaAt(tick);
double phi = Math.toRadians(phiDegrees);
+ double inPlane = distUnits * Math.cos(phi);
return BlockDelta.of(
- Math.round(distUnits * Math.cos(theta) * unitBlocks),
+ Math.round(inPlane * Math.cos(theta) * unitBlocks),
Math.round(distUnits * Math.sin(phi) * unitBlocks),
- Math.round(distUnits * Math.sin(theta) * unitBlocks));
+ Math.round(inPlane * Math.sin(theta) * unitBlocks));
}
/** The orbital angle (radians) at {@code tick}. */
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/ClusteredGalaxyGenerator.java b/src/main/java/zmaster587/advancedRocketry/universe/ClusteredGalaxyGenerator.java
index 1a8d6da8f..018608d76 100644
--- a/src/main/java/zmaster587/advancedRocketry/universe/ClusteredGalaxyGenerator.java
+++ b/src/main/java/zmaster587/advancedRocketry/universe/ClusteredGalaxyGenerator.java
@@ -12,6 +12,7 @@
import zmaster587.advancedRocketry.api.Constants;
import zmaster587.advancedRocketry.api.dimension.solar.StellarBody;
import zmaster587.advancedRocketry.space.AbsolutePos;
+import zmaster587.advancedRocketry.space.BlockDelta;
import zmaster587.advancedRocketry.space.GalacticCoord;
import zmaster587.advancedRocketry.util.AstronomicalBodyHelper;
@@ -106,9 +107,9 @@ public final class ClusteredGalaxyGenerator implements IGalaxyGenerator {
private static final double NUDGE_ANGLE = 2.399963229728653d; // the golden angle, in radians
/** How many relocations a body gets before its system is declared full. */
private static final int NUDGE_ATTEMPTS = 96;
- /** Neighbourhood margin (cells) kept clear of the super-cell boundary. */
+ /** Neighbourhood margin (cells) kept clear of the seat's own clear space. */
private static final int NEIGHBOURHOOD_MARGIN_CELLS = 2;
- /** Thin-disk half-thickness as a fraction of the orbit radius (bodies keep honest 3D Y — A#1a e1). */
+ /** Thin-disk half-thickness as a fraction of the orbit radius (bodies keep honest 3D Y). */
private static final double PROC_DISK_FRACTION = 0.1d;
private final GalaxyGenConfig config;
@@ -196,11 +197,12 @@ public List bodiesFor(long seed, GalacticCoord systemCoord) {
// A star does not move inside its own system: its frame IS the system's anchor.
bodies.add(SystemBody.fixedAt(cell, SystemBodyKind.STAR, Constants.INVALID_PLANET, starId));
- // Bodies orbit at cell-scale radii: min 1 cell out (never the anchor cell), max = the bounded
- // neighbourhood radius. The anchor sits in the middle band of its super-cell (>= 3s/8 from every
- // face), so a radius <= 3s/8 - margin keeps every body inside the anchor's super-cell — member-cell
- // attribution by floorDiv stays exact. (The per-body box clamp below covers the tiny-spacing floor.)
+ // A body sits where its ORBIT puts it — one law, one constant, the same one an authored system
+ // uses. What the neighbourhood decides is not how far a body goes but how many bodies there is
+ // room for: orbits are drawn inside a bracket that already fits, and a system that would run
+ // past its own clear space loses BODIES rather than being squashed to fit.
long s = config.minSpacing;
+ double outerBound = maxNamedOrbitUnits(s);
// AT MOST ONE REAL BODY PER CELL, moons excepted. The draw picks each body's angle and radius
// independently, so two of them CAN land on the same cell — and two real bodies in one cell are
@@ -215,39 +217,43 @@ public List bodiesFor(long seed, GalacticCoord systemCoord) {
int outermostOrbit = 0;
int innermostGiantOrbit = 0;
for (int i = 0; i < count; i++) {
- // The ORBIT is drawn first and the cell radius follows it, rather than the other way round:
- // a body's physics is derived from its orbit, so letting the placement pick the radius would
- // make every world's climate a function of the layout arithmetic.
+ // The ORBIT is drawn first and the cell follows from it, rather than the other way round:
+ // a body's physics is derived from its orbit, so letting the placement pick the distance
+ // would make every world's climate a function of the layout arithmetic.
+ //
+ // The orbit is drawn across the STAR'S OWN zone, and a body that lands outside the room
+ // this system has is DROPPED. Narrowing the bracket instead would have kept the body and
+ // moved it inward, which is the one thing this whole seam exists to prevent: a world's
+ // distance is its star's business, and a system squeezed by its neighbours holds fewer
+ // worlds rather than the same worlds at the wrong distances.
int orbit = PlanetDerivation.orbitalDistanceOf(seed, cell, i, count, star);
- GalacticCoord addr = placeBody(seed, cell, i, orbit, star, s, taken);
- if (addr == null) {
+ if (orbit > outerBound) {
+ continue; // outside this system's clear space — a bound of the layout, not a failure
+ }
+ Seat seat = seatBody(seed, cell, i, orbit, star, s, taken);
+ if (seat == null) {
continue; // this system's neighbourhood is full — a bound of the layout, not a failure
}
// Planet or giant is not a roll of its own: it falls out of the body's derived physics,
// which is what makes the zoning (rock inside, giants past the snow line) emerge instead
// of being authored. Kept here rather than at realization because the nav list, the sky
// and the descent trigger all read the kind long before anyone lands.
- BodyProfile profile = PlanetDerivation.derive(seed, cell, addr, 0, star, false, orbit);
+ BodyProfile profile = PlanetDerivation.derive(seed, cell, seat.cell, 0, star, false, orbit);
// THE ORBIT LIVES IN THE FRAME, not in the body's own offset — the same shape an authored
// system uses (SystemContent: a planet sits at its frame origin and the FRAME goes round
// the star). Built with the convenience constructor, a procedural planet got
// CellFrame.staticAt(...) and a FIXED offset, so it stood still relative to its star
// forever while its own moons orbited it, and the identical system authored in XML moved.
- double theta = PlanetRealizer.angleOf(cell, addr);
- double periodTicks = AstronomicalBodyHelper.TICKS_PER_DAY
- * AstronomicalBodyHelper.getOrbitalPeriod(orbit, star.getMass());
- CellFrame bodyFrame = CellFrame.of(AbsolutePos.ofCellName(cell.cellCentre()),
- BodyEphemeris.orbit(orbit, theta, 0d, false, periodTicks,
- SystemContent.ORBIT_UNIT_BLOCKS));
+ CellFrame bodyFrame = CellFrame.of(AbsolutePos.ofCellName(cell.cellCentre()), seat.law);
// Procedural bodies have no realized dimension yet — a descent (Layer 2) realizes one.
- bodies.add(new SystemBody(addr, bodyFrame, BodyEphemeris.STATIC, profile.kind(),
+ bodies.add(new SystemBody(seat.cell, bodyFrame, BodyEphemeris.STATIC, profile.kind(),
Constants.INVALID_PLANET, starId, orbit));
outermostOrbit = Math.max(outermostOrbit, orbit);
if (profile.kind() == SystemBodyKind.GAS_GIANT
&& (innermostGiantOrbit == 0 || orbit < innermostGiantOrbit)) {
innermostGiantOrbit = orbit;
}
- addMoons(bodies, seed, cell, addr, bodyFrame, orbit, star, starId, profile);
+ addMoons(bodies, seed, cell, seat.cell, bodyFrame, orbit, star, starId, profile);
}
// An inner belt is DERIVED from a giant and never rolled: it is material a giant's resonances
@@ -259,9 +265,14 @@ public List bodiesFor(long seed, GalacticCoord systemCoord) {
// The outer belt is MANDATORY on every system — the Kuiper analogue, and the reason every system
// is worth arriving in: it is a gravity-well-free mining site that needs no landing, so a ship
// that drifts into any system at all has something to work.
- int outerBelt = (int) Math.max(outermostOrbit * OUTER_BELT_FACTOR,
+ //
+ // It is the one body allowed to sit past the drawn bracket, because it is defined as being
+ // beyond the outermost world; what it may NOT pass is the system's own clear space, and there
+ // it is bounded like everything else rather than being quietly dropped.
+ double outerBelt = Math.max(outermostOrbit * OUTER_BELT_FACTOR,
PlanetDerivation.innerOrbit(star) * 2d);
- addBelt(bodies, seed, cell, outerBelt, star, s, starId, taken, count + 2);
+ addBelt(bodies, seed, cell, (int) Math.min(outerBelt, outerBound), star, s, starId, taken,
+ count + 2);
return bodies;
}
@@ -276,55 +287,80 @@ public static int retinueSize(long seed, GalacticCoord anchor) {
return Math.max(1, Math.min(MAX_PROC_PLANETS, n));
}
+ /**
+ * How far this system's NAMED bodies may reach from its star, in orbital-distance units: the
+ * declared clear space around a seat, or as much of it as this spacing can actually give.
+ */
+ private static double maxNamedOrbitUnits(long s) {
+ long reachCells = Math.max(1L, UniverseScale.seatMarginCells(s) - NEIGHBOURHOOD_MARGIN_CELLS);
+ return Math.min(UniverseScale.MAX_NAMED_ORBIT_UNITS,
+ UniverseScale.orbitUnitsForCells(reachCells));
+ }
+
/**
* Claim a free cell for a body orbiting at {@code orbit}, or {@code null} when the neighbourhood has
* no room left.
*
- * The first choice puts the body at the cell radius its orbit maps to, at a drawn angle. If that
- * cell is already spoken for, the body is walked around the ring by the golden angle — which keeps
- * its radius, and therefore keeps the system's cell layout in the same order as its orbits — and
- * only then allowed to drift outward. A body that still finds nothing is dropped: a neighbourhood
- * holds what it holds, and inventing a second occupant for a cell is the one outcome that is worse
- * than a smaller system.
+ * The cell is READ OFF the body's own orbital law at the naming instant, not computed by a second
+ * arithmetic beside it: the name a body carries and the frame its cell rides are then the same
+ * statement evaluated once, and cannot drift apart when either is retuned. That is exactly how an
+ * authored body is named, which is what makes one orbital distance mean one distance in both
+ * families.
+ *
+ * If the first choice is already spoken for, the body is walked around its ring by the golden
+ * angle — a relocation costs a body its ANGLE and never its distance, so no world's climate is
+ * disturbed by the layout arithmetic and the orbital order survives. A body that still finds nothing
+ * is dropped: a neighbourhood holds what it holds, and inventing a second occupant for a cell is the
+ * one outcome that is worse than a smaller system.
*/
- private static GalacticCoord placeBody(long seed, GalacticCoord anchor, int index, int orbit,
- StellarBody star, long s, Set taken) {
- long maxRadiusCells = Math.max(1L, 3L * s / 8L - NEIGHBOURHOOD_MARGIN_CELLS);
- double maxRadiusBlocks = (double) maxRadiusCells * GalacticCoord.CELL;
- double minRadiusBlocks = GalacticCoord.CELL;
+ private static Seat seatBody(long seed, GalacticCoord anchor, int index, int orbit,
+ StellarBody star, long s, Set taken) {
double baseAngle = CellHash.norm(CellHash.ofBody(seed, anchor, index, SALT_BODYANG)) * 2d * Math.PI;
- double baseRadius = minRadiusBlocks + PlanetDerivation.orbitFraction(orbit, star)
- * Math.max(0d, maxRadiusBlocks - minRadiusBlocks);
- double heightFraction = CellHash.norm(CellHash.ofBody(seed, anchor, index, SALT_BODYY)) - 0.5d;
+ // Out-of-plane displacement lives in the LAW as an inclination, so a body's height above the
+ // disk is part of where it IS at every tick rather than a one-off nudge applied to its name.
+ double sinPhi = (CellHash.norm(CellHash.ofBody(seed, anchor, index, SALT_BODYY)) - 0.5d)
+ * PROC_DISK_FRACTION;
+ double phiDegrees = Math.toDegrees(Math.asin(sinPhi));
+ double periodTicks = AstronomicalBodyHelper.TICKS_PER_DAY
+ * AstronomicalBodyHelper.getOrbitalPeriod(orbit, star.getMass());
for (int attempt = 0; attempt < NUDGE_ATTEMPTS; attempt++) {
- double angle = baseAngle + attempt * NUDGE_ANGLE;
- // Radius is held for a full turn of the ring before it is allowed to grow, so a relocation
- // costs the body its angle long before it costs it its place in the orbital order.
- double radius = Math.min(maxRadiusBlocks, baseRadius * (1d + 0.06d * (attempt / 16)));
- long lx = (long) (radius * Math.cos(angle));
- long lz = (long) (radius * Math.sin(angle));
- long ly = (long) (heightFraction * radius * PROC_DISK_FRACTION);
- // The body's address is its OWN cell's centre (zone content sits near the cell centre — A#1a),
- // box-clamped into the anchor's super-cell so member attribution stays exact at ANY minSpacing
- // (at tiny spacings the floor above can otherwise push a body across the super-cell face).
- GalacticCoord addr = clampIntoSuperCell(anchor.plusLocal(lx, ly, lz).cellCentre(), anchor, s);
+ BodyEphemeris law = BodyEphemeris.orbit(orbit, baseAngle + attempt * NUDGE_ANGLE,
+ phiDegrees, false, periodTicks, AstronomicalBodyHelper.BLOCKS_PER_ORBIT_UNIT);
+ BlockDelta at0 = law.offsetAt(SystemContent.NAME_TICK);
+ // The body's address is its OWN cell's centre (zone content sits near the cell centre),
+ // box-clamped into the anchor's super-cell so member attribution stays exact at ANY
+ // spacing — at tiny spacings a whole orbit can otherwise reach across the super-cell face.
+ GalacticCoord addr = clampIntoSuperCell(
+ anchor.plusLocal(at0.dx(), at0.dy(), at0.dz()).cellCentre(), anchor, s);
if (taken.add(addr.cellKey())) {
- return addr;
+ return new Seat(addr, law);
}
}
return null;
}
+ /** A body's claimed cell together with the orbital law that put it there — one statement, not two. */
+ private static final class Seat {
+ final GalacticCoord cell;
+ final BodyEphemeris law;
+
+ Seat(GalacticCoord cell, BodyEphemeris law) {
+ this.cell = cell;
+ this.law = law;
+ }
+ }
+
/** Append an asteroid belt at {@code orbit}, if the neighbourhood still has a cell for one. */
private static void addBelt(List bodies, long seed, GalacticCoord anchor, int orbit,
StellarBody star, long s, int starId, Set taken, int index) {
int clamped = Math.max(1, orbit);
- GalacticCoord addr = placeBody(seed, anchor, index, clamped, star, s, taken);
- if (addr != null) {
- // A belt is centred on the star it rings, so as a whole it does not travel round it.
- bodies.add(SystemBody.fixedAt(addr, SystemBodyKind.ASTEROID_BELT, Constants.INVALID_PLANET,
- starId, clamped));
+ Seat seat = seatBody(seed, anchor, index, clamped, star, s, taken);
+ if (seat != null) {
+ // A belt is centred on the star it rings, so as a whole it does not travel round it. Its
+ // cell is a marker on the ring; the ring itself does not go anywhere.
+ bodies.add(SystemBody.fixedAt(seat.cell, SystemBodyKind.ASTEROID_BELT,
+ Constants.INVALID_PLANET, starId, clamped));
}
}
@@ -432,11 +468,18 @@ private Optional systemForSuperCell(long seed, long supX, long supY,
return Optional.empty();
}
long s = config.minSpacing;
- // Seat the anchor in the middle band of the super-cell ([3s/8, 5s/8)): every face stays >= 3s/8
- // cells away, so a body neighbourhood of radius <= 3s/8 - margin can never cross into the
- // neighbouring super-cell (A#1a attribution guarantee).
- long band = Math.max(1L, s / 4L);
- long base = 3L * s / 8L;
+ // Seat the anchor anywhere in its cube except a declared margin at the faces. That margin is
+ // the system's own CLEAR SPACE, not a fraction of the cube: it is what guarantees two stars
+ // never stand closer than the separation floor, and what keeps one system's named bodies from
+ // reaching into the next cube (so member-cell attribution by floorDiv stays exact).
+ //
+ // It used to be the middle quarter per axis, which confined the seat to 1.6 % of the cube's
+ // volume — a lattice of tight clumps with guaranteed-empty walls between them, visible in any
+ // rendered star field. The margin now costs a couple of percent per face instead, because it
+ // is sized by what a system actually needs rather than by the distance to the next star.
+ long margin = UniverseScale.seatMarginCells(s);
+ long band = Math.max(1L, s - 2L * margin);
+ long base = margin;
long ox = base + Math.floorMod(CellHash.of(seed, supX, supY, supZ, SALT_OX), band);
long oy = base + Math.floorMod(CellHash.of(seed, supX, supY, supZ, SALT_OY), band);
long oz = base + Math.floorMod(CellHash.of(seed, supX, supY, supZ, SALT_OZ), band);
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/GalaxyGenConfig.java b/src/main/java/zmaster587/advancedRocketry/universe/GalaxyGenConfig.java
index a2ffdaae4..09cbc2783 100644
--- a/src/main/java/zmaster587/advancedRocketry/universe/GalaxyGenConfig.java
+++ b/src/main/java/zmaster587/advancedRocketry/universe/GalaxyGenConfig.java
@@ -17,13 +17,18 @@
public final class GalaxyGenConfig {
/**
- * Default super-cell edge in cells. Sized so a system's per-body-cell NEIGHBOURHOOD (planets at their
- * own cells, ~1M blocks per orbit-unit — universe-model §2 amendment A#1a) fits inside half a
- * super-cell: neighbourhoods of two neighbouring systems can never interleave. Deliberately a FIXED
- * constant, never derived from the planet catalog — {@code minSpacing} partitions procedural space, and
- * deriving it from XML content would silently relocate the whole procedural galaxy on any catalog edit.
+ * Default super-cell edge in cells: the mean distance between neighbouring stars, converted through
+ * the chart metric by {@link UniverseScale#DEFAULT_SPACING_CELLS}.
+ *
+ * It no longer decides how big a system is. A system's extent follows its outermost orbit and is
+ * bounded by the separation floor, so this number moves the STARS apart and nothing else — raising
+ * it does not inflate a single planet's orbit, and lowering it does not squash one.
+ *
+ * Deliberately a FIXED constant, never derived from the planet catalog: it partitions procedural
+ * space, and deriving it from XML content would silently relocate the whole procedural galaxy on any
+ * catalog edit.
*/
- public static final int DEFAULT_MIN_SPACING = 512;
+ public static final int DEFAULT_MIN_SPACING = UniverseScale.DEFAULT_SPACING_CELLS;
/** A weighted star archetype: a temperature (drives colour) and a size range. */
public static final class StarType {
@@ -42,7 +47,10 @@ public StarType(int temperature, float minSize, float maxSize, int weight) {
/** Per-super-cell occupancy probability inside a galaxy (before the void mask). */
public final double density;
- /** Super-cell edge in cells: at most one system per {@code minSpacing}-cube. Minimum system spacing. */
+ /**
+ * Super-cell edge in cells: at most one system per {@code minSpacing}-cube, i.e. how far apart
+ * stars stand. It bounds no orbit — see {@link #DEFAULT_MIN_SPACING}.
+ */
public final int minSpacing;
/** Blob field resolution in super-cells — the size of a galaxy cluster. */
public final int clusterScale;
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/PlanetDerivation.java b/src/main/java/zmaster587/advancedRocketry/universe/PlanetDerivation.java
index cc9e6d245..920e14d73 100644
--- a/src/main/java/zmaster587/advancedRocketry/universe/PlanetDerivation.java
+++ b/src/main/java/zmaster587/advancedRocketry/universe/PlanetDerivation.java
@@ -185,8 +185,13 @@ public static int referenceDistance(StellarBody star) {
*
* Each body owns a SLOT of the logarithmic range and is jittered inside it by less than half a
* slot, so the draw is irregular but the ordering is not: body {@code i} is always inside body
- * {@code i+1}. That is what lets the placement map an orbit onto a cell radius monotonically, and it
- * is why two bodies of one system cannot swap places when a tuning constant moves.
+ * {@code i+1}. That is why two bodies of one system cannot swap places when a tuning constant
+ * moves.
+ *
+ * The zone is the STAR'S, and nothing else's. How much room the system has where it
+ * sits is not an input here: a body that will not fit is the caller's to drop, because a distance
+ * bent to fit a neighbourhood is a world whose climate, insolation and year all describe a place
+ * it is not standing.
*/
public static int orbitalDistanceOf(long seed, GalacticCoord anchor, int index, int count,
StellarBody star) {
@@ -210,22 +215,10 @@ public static double outerOrbit(StellarBody star) {
return Math.max(innerOrbit(star) * 1.5d, referenceDistance(star) * OUTER_ORBIT_FACTOR);
}
- /**
- * Where {@code orbitalDistance} sits in this star's zone, as a fraction in {@code [0,1]} on a
- * LOGARITHMIC scale — the inverse of the orbital draw.
- *
- * This is what lets the galactic placement map an orbit onto a cell radius: the two layouts then
- * agree by construction, so a body that is third from its star is also third out from the anchor
- * cell, and neither can be re-tuned without the other following.
- */
- public static double orbitFraction(int orbitalDistance, StellarBody star) {
- double lo = innerOrbit(star);
- double hi = outerOrbit(star);
- if (!(hi > lo)) {
- return 0d;
- }
- return clamp(Math.log(Math.max(lo, orbitalDistance) / lo) / Math.log(hi / lo), 0d, 1d);
- }
+ // orbitFraction — where an orbit sat in its star's zone, as a fraction — lived here to map an
+ // orbit onto a cell radius, which is a job the placement no longer has: a body's cell is read off
+ // its own orbital law, so there is nothing left to normalise against. Removed rather than left
+ // callerless, because the next caller would be re-introducing the second scale it existed to serve.
/** The bare (no-atmosphere) equilibrium temperature at a distance — the zoning reading. */
public static int bareTemperature(StellarBody star, int orbitalDistance) {
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/PlanetRealizer.java b/src/main/java/zmaster587/advancedRocketry/universe/PlanetRealizer.java
index 2ee7c8983..04bea31ce 100644
--- a/src/main/java/zmaster587/advancedRocketry/universe/PlanetRealizer.java
+++ b/src/main/java/zmaster587/advancedRocketry/universe/PlanetRealizer.java
@@ -144,7 +144,7 @@ public static int realize(MinecraftServer server, GalacticCoord bodyCell) {
BodyProfile profile = PlanetDerivation.derive(registry.worldSeed(), anchor, target.name(), variant,
star, target.kind() == SystemBodyKind.MOON, target.orbitalDistance());
- DimensionProperties props = materialize(dimId, profile, star, anchor, target, parentBody);
+ DimensionProperties props = materialize(dimId, profile, star, target, parentBody);
if (!DimensionManager.getInstance().registerDim(props, true)) {
LOGGER.error("[UNIVERSE] dimension {} was already registered while realizing {}", dimId,
@@ -169,8 +169,7 @@ public static int realize(MinecraftServer server, GalacticCoord bodyCell) {
* {@code Random}.
*/
private static DimensionProperties materialize(int dimId, BodyProfile profile, StellarBody star,
- GalacticCoord anchor, SystemBody body,
- SystemBody parentBody) {
+ SystemBody body, SystemBody parentBody) {
DimensionProperties props = new DimensionProperties(dimId);
props.setName(star.getName() + " " + dimId);
props.setStar(star);
@@ -194,9 +193,13 @@ private static DimensionProperties materialize(int dimId, BodyProfile profile, S
body.name().cellKey(), parentBody.dimId());
}
}
- // The orbital angle is READ OFF the body's cell rather than drawn again, so the planet the sky
- // shows and the planet the orbital elements describe are in the same place.
- props.baseOrbitTheta = angleOf(anchor, body.name());
+ // The orbital angle is taken from the body's own law, so the planet the sky shows and the
+ // planet the orbital elements describe are in the same place. A planet's angle lives in the
+ // FRAME its cell rides; a moon's lives in its own offset law, because a moon shares its
+ // parent's frame and going through that would hand it its parent's angle instead of its own.
+ BodyEphemeris ownLaw = body.kind() == SystemBodyKind.MOON
+ ? body.offsetLaw() : body.frame().law();
+ props.baseOrbitTheta = ownLaw.baseTheta();
props.orbitTheta = props.baseOrbitTheta;
props.setAtmosphereDensityDirect(profile.pressure());
@@ -272,20 +275,7 @@ private static int rotationalPeriodOf(BodyProfile profile, StellarBody star) {
return profile.rotationalPeriodTicks();
}
- /** The angle of a body's cell about its system's anchor, in radians. */
- /**
- * A body's orbital angle, read off its cell rather than drawn again — so the sky, the orbital
- * elements and the frame a body rides all put it in the same place. Shared with
- * {@link ClusteredGalaxyGenerator}, which must build the frame from the same angle this writes
- * into {@code baseOrbitTheta}; a second copy of this arithmetic would let the two drift.
- */
- static double angleOf(GalacticCoord anchor, GalacticCoord bodyCell) {
- long dx = bodyCell.sectorX() - anchor.sectorX();
- long dz = bodyCell.sectorZ() - anchor.sectorZ();
- if (dx == 0L && dz == 0L) {
- return 0d;
- }
- double theta = Math.atan2((double) dz, (double) dx);
- return theta < 0d ? theta + 2d * Math.PI : theta;
- }
+ // angleOf — recovering a body's orbital angle from where its cell ended up — is gone: the angle is
+ // now carried by the body's own law, which is what the cell was derived FROM. Recovering it was
+ // only ever an approximation of the drawn value, accurate to whatever the cell grid could express.
}
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/SystemContent.java b/src/main/java/zmaster587/advancedRocketry/universe/SystemContent.java
index 61ebb0eda..879f8326c 100644
--- a/src/main/java/zmaster587/advancedRocketry/universe/SystemContent.java
+++ b/src/main/java/zmaster587/advancedRocketry/universe/SystemContent.java
@@ -25,25 +25,29 @@
* Derives the addressable {@link SystemBody} content of an AUTHORED system (a catalogued {@link StellarBody})
* from its planets/moons (universe-model.md §2 amendment A#1a + §4). A system is an anchored
* NEIGHBOURHOOD of cells: the star sits at the anchor cell's centre; every planet/belt gets its own cell
- * at a sector offset scaled from its orbital position ({@link #ORBIT_UNIT_BLOCKS ~1M blocks per orbit-unit},
- * {@code tunable}), snapped to that cell's centre (zone content sits near the cell centre); moons stay LOCAL
+ * at a sector offset scaled from its orbital position ({@link #ORBIT_UNIT_BLOCKS blocks per orbit-unit}),
+ * snapped to that cell's centre (zone content sits near the cell centre); moons stay LOCAL
* inside their parent planet's cell. Inter-body space is cells of void.
*
* A body's cell is its durable NAME, derived once at {@link #NAME_TICK} and thereafter recorded. Where
* that cell IS stays a function of time: each body cell carries a {@link CellFrame} whose origin is its
* primary's position, so the neighbourhood rides the body it belongs to and a moon orbits inside it.
*
- * The neighbourhood is BOUNDED: every body cell is clamped (with a WARN) into the anchor's
- * {@code minSpacing}-cube super-cell, {@link #BOX_MARGIN_CELLS} cells clear of its faces — the load-time
- * guard that keeps two systems' neighbourhoods from interleaving, whatever an XML author wrote for
- * {@code orbitalDistance} (its cap is {@code Integer.MAX_VALUE}).
+ * The neighbourhood is BOUNDED: every body cell is clamped (with a WARN) into the system's declared
+ * clear space around its anchor — the load-time guard that keeps two systems' neighbourhoods from
+ * interleaving, whatever an XML author wrote for {@code orbitalDistance} (its cap is
+ * {@code Integer.MAX_VALUE}).
*
* Pure DATA — a walkable realization is Layer 2. Scale constants are {@code tunable}.
*/
public final class SystemContent {
- /** Blocks per unit of {@code DimensionProperties} orbital distance (A#1a: ~1M blocks per orbit-unit). */
- static final long ORBIT_UNIT_BLOCKS = 1_000_000L;
+ /**
+ * Blocks per unit of {@code DimensionProperties} orbital distance — the chart metric's own
+ * conversion, shared with the procedural generator so that one orbital distance means one distance
+ * in both families.
+ */
+ static final long ORBIT_UNIT_BLOCKS = AstronomicalBodyHelper.BLOCKS_PER_ORBIT_UNIT;
/** Blocks per unit of a moon's (parent-relative) orbital distance — moons cluster near their planet. */
static final long MOON_UNIT_BLOCKS = 200L;
/** Cells kept clear of the super-cell faces when clamping a body cell into its system's box. */
@@ -299,15 +303,15 @@ private static void auditOneRealBodyPerCell(List bodies, int starId)
private static GalacticCoord clampIntoBox(GalacticCoord bodyCell, GalacticCoord anchor,
int minSpacingCells, int dimId) {
long s = Math.max(1, minSpacingCells);
- long margin = (s > 2L * BOX_MARGIN_CELLS) ? BOX_MARGIN_CELLS : 0L;
- long cx = clampAxis(bodyCell.sectorX(), anchor.sectorX(), s, margin);
- long cy = clampAxis(bodyCell.sectorY(), anchor.sectorY(), s, margin);
- long cz = clampAxis(bodyCell.sectorZ(), anchor.sectorZ(), s, margin);
+ long reach = reachCells(s);
+ long cx = clampAxis(bodyCell.sectorX(), anchor.sectorX(), reach);
+ long cy = clampAxis(bodyCell.sectorY(), anchor.sectorY(), reach);
+ long cz = clampAxis(bodyCell.sectorZ(), anchor.sectorZ(), reach);
if ((cx != bodyCell.sectorX() || cy != bodyCell.sectorY() || cz != bodyCell.sectorZ())
&& REPORTED.add("clamp:" + dimId + ':' + bodyCell.cellKey())) {
- LOGGER.warn("orbit of dim {} exceeds the system neighbourhood bound (minSpacing {} cells); "
- + "clamping its cell from ({},{},{}) into the anchor's super-cell",
- dimId, s, bodyCell.sectorX(), bodyCell.sectorY(), bodyCell.sectorZ());
+ LOGGER.warn("orbit of dim {} reaches past its system's clear space ({} cells at a spacing "
+ + "of {}); clamping its cell from ({},{},{}) back inside it",
+ dimId, reach, s, bodyCell.sectorX(), bodyCell.sectorY(), bodyCell.sectorZ());
}
return GalacticCoord.ofSectorLocal(cx, cy, cz, 0L, 0L, 0L);
}
@@ -322,16 +326,30 @@ public static boolean withinBoxOf(GalacticCoord cell, GalacticCoord anchor, int
if (cell == null || anchor == null) {
return false;
}
- long s = Math.max(1, minSpacingCells);
- long margin = (s > 2L * BOX_MARGIN_CELLS) ? BOX_MARGIN_CELLS : 0L;
- long reach = Math.max(0L, s / 2L - margin);
+ long reach = reachCells(Math.max(1, minSpacingCells));
return Math.abs(cell.sectorX() - anchor.sectorX()) <= reach
&& Math.abs(cell.sectorY() - anchor.sectorY()) <= reach
&& Math.abs(cell.sectorZ() - anchor.sectorZ()) <= reach;
}
/**
- * The per-axis bound: {@code half - margin} cells either side OF THE ANCHOR.
+ * How far from its anchor a body of this system may be NAMED, in cells: the system's declared clear
+ * space, or as much of it as this spacing can give.
+ *
+ * It used to be half the spacing outright, which was the same number while a system's extent was
+ * defined as a fraction of the distance to the next star. Once stars stand a real distance apart,
+ * half of that is several thousand times more room than a system has any business occupying, and an
+ * authored orbit could be named right up against the neighbouring star. The bound that matters is
+ * the system's own clear space, and it is the same one the procedural generator seats against.
+ */
+ private static long reachCells(long s) {
+ long margin = (s > 2L * BOX_MARGIN_CELLS) ? BOX_MARGIN_CELLS : 0L;
+ return Math.min(Math.max(0L, s / 2L - margin),
+ Math.max(0L, UniverseScale.SEAT_MARGIN_CELLS - margin));
+ }
+
+ /**
+ * The per-axis bound: {@code reach} cells either side OF THE ANCHOR.
*
* This used to snap to the GRID super-cell containing the anchor —
* {@code [floorDiv(anchor,s)*s + margin, … + s-1-margin]} — which is a different box, and for the
@@ -345,12 +363,7 @@ public static boolean withinBoxOf(GalacticCoord cell, GalacticCoord anchor, int
* Centring the box on the anchor is also what this class's javadoc and
* {@code ClusteredGalaxyGenerator} ("minSpacing/2 - margin") always claimed it did.
*/
- private static long clampAxis(long sector, long anchorSector, long s, long margin) {
- long half = s / 2L;
- long reach = half - margin;
- if (reach < 0L) {
- reach = 0L;
- }
+ private static long clampAxis(long sector, long anchorSector, long reach) {
long lo = anchorSector - reach;
long hi = anchorSector + reach;
if (sector < lo) {
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/UniverseScale.java b/src/main/java/zmaster587/advancedRocketry/universe/UniverseScale.java
new file mode 100644
index 000000000..8b2c765d4
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/universe/UniverseScale.java
@@ -0,0 +1,106 @@
+package zmaster587.advancedRocketry.universe;
+
+import zmaster587.advancedRocketry.space.GalacticCoord;
+import zmaster587.advancedRocketry.util.AstronomicalBodyHelper;
+
+/**
+ * How big the universe layer's furniture is, in one place: how far apart stars stand, how much room a
+ * system is allowed to occupy, and how those two turn into cells.
+ *
+ * Every number here is stated as a PHYSICAL length and converted, never written as a cell count.
+ * A cell count is a reading of {@link GalacticCoord#CELL}, and that constant may move; a light year
+ * may not. Stating the physics and deriving the cells is what keeps the star field looking the same
+ * after the cell edge is retuned.
+ *
+ * The two lengths, and why they are separate
+ *
+ * - Star separation — the edge of the cube that holds at most one system. It decides how
+ * far a flight between two stars is, and nothing else.
+ * - The separation floor — the guaranteed clear space around a system. It decides how much
+ * room a system's bodies have and how close two unrelated systems may ever be seen to stand.
+ *
+ *
+ * These used to be one number: a system's extent was defined as a fraction of the
+ * interstellar step, which truncated systems at a few AU, filled half the gap to the next star with
+ * one system's neighbourhood, and forced the orbit scale to shrink to compensate. Separating them is
+ * what lets a system be as big as its outermost orbit while the sky still reads as a sky.
+ *
+ * A near-pair of lattice seats is NOT a binary — it is two unrelated systems with two names, two
+ * frames and no gravitational relation between them. The floor is therefore set comfortably wider
+ * than any binary the model describes, so multiplicity is something the generator states inside ONE
+ * system rather than something the lattice fakes by accident.
+ */
+public final class UniverseScale {
+
+ /**
+ * Mean distance between neighbouring star seats, in light years. Real stellar neighbourhoods run
+ * 4–5 light years between neighbours; the lattice is stratified rather than Poisson, so the
+ * mean neighbour distance it produces comes out somewhat above this edge.
+ */
+ public static final double MEAN_STAR_SEPARATION_LY = 4.23d;
+
+ /**
+ * The guaranteed clear space around a system, in AU: two stars never stand closer than this,
+ * however the lattice falls. Four times the widest binary the star model describes, so a lattice
+ * near-pair can never be mistaken for one.
+ *
+ * It bounds SEATS. Each system's named bodies then stay inside half of it (see
+ * {@link #MAX_NAMED_ORBIT_UNITS}), which is what makes two neighbourhoods unable to overlap.
+ */
+ public static final double SEPARATION_FLOOR_AU = 10_000d;
+
+ /**
+ * How far a system's NAMED bodies may reach from their star, in orbital-distance units — half the
+ * separation floor, which is exactly what makes two systems' neighbourhoods unable to overlap.
+ *
+ * It is a bound, not a size. An ordinary system ends at its outermost orbit (a few tens of AU);
+ * this is the wall a system that would grow past its own clear space runs into, and the rule when
+ * it does is that the system loses BODIES, never scale.
+ *
+ * Diffuse, nameless matter — a comet cloud — is not bound by it and may reach past a
+ * neighbour's, exactly as real ones nearly touch: attribution reads names, not matter.
+ */
+ public static final int MAX_NAMED_ORBIT_UNITS =
+ (int) Math.min(Integer.MAX_VALUE,
+ Math.round(SEPARATION_FLOOR_AU / 2d * AstronomicalBodyHelper.DISTANCE_UNITS_PER_AU));
+
+ /** The same reach, in cells: the margin a system's seat keeps clear of its cube's faces. */
+ public static final long SEAT_MARGIN_CELLS = cellsForOrbitUnits(MAX_NAMED_ORBIT_UNITS);
+
+ /**
+ * Default edge of the cube that holds at most one system, in cells. Derived from
+ * {@link #MEAN_STAR_SEPARATION_LY}; a balance knob, overridable from the universe generator's
+ * configuration, and never a contract.
+ */
+ public static final int DEFAULT_SPACING_CELLS = (int) Math.min(Integer.MAX_VALUE,
+ Math.max(1L, Math.round(MEAN_STAR_SEPARATION_LY
+ * AstronomicalBodyHelper.BLOCKS_PER_LIGHT_YEAR / (double) GalacticCoord.CELL)));
+
+ private UniverseScale() {
+ }
+
+ /** How many cells an orbital distance spans. Rounded up: a reach must not come out short. */
+ public static long cellsForOrbitUnits(double orbitUnits) {
+ double blocks = Math.max(0d, orbitUnits) * AstronomicalBodyHelper.BLOCKS_PER_ORBIT_UNIT;
+ return (long) Math.ceil(blocks / (double) GalacticCoord.CELL);
+ }
+
+ /** The largest orbital distance that fits inside {@code cells} cells of a system's star. */
+ public static double orbitUnitsForCells(long cells) {
+ return Math.max(0d, cells) * (double) GalacticCoord.CELL
+ / AstronomicalBodyHelper.BLOCKS_PER_ORBIT_UNIT;
+ }
+
+ /**
+ * The clear margin a system of edge {@code spacingCells} actually gets: the declared one, or as
+ * much of it as a cube that small can give.
+ *
+ * A cube smaller than twice the floor cannot honour the floor — that is a degenerate galaxy,
+ * not an error, and it is what a test or a pack asking for a compact universe gets. What may
+ * never happen is a margin so large that no seat is left, so it stops one short of half the cube.
+ */
+ public static long seatMarginCells(long spacingCells) {
+ long half = Math.max(0L, (spacingCells - 1L) / 2L);
+ return Math.min(SEAT_MARGIN_CELLS, half);
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/util/AstronomicalBodyHelper.java b/src/main/java/zmaster587/advancedRocketry/util/AstronomicalBodyHelper.java
index c852e3945..44dffdfbd 100644
--- a/src/main/java/zmaster587/advancedRocketry/util/AstronomicalBodyHelper.java
+++ b/src/main/java/zmaster587/advancedRocketry/util/AstronomicalBodyHelper.java
@@ -25,6 +25,44 @@ public class AstronomicalBodyHelper {
public static final int KELVIN_PER_STAR_TEMPERATURE_UNIT = 58;
/** Solar radii in one astronomical unit — carries a star's size into the distance frame. */
public static final int SOLAR_RADII_PER_AU = 215;
+
+ // ─── The CHART metric ──────────────────────────────────────────────────────
+ // How a physical length becomes a number of blocks in the chart — the space bodies are placed,
+ // sized and separated in. It is NOT the metric of a world anyone walks on: a loaded world is
+ // metres per block, and the two are never added. A length that crosses the boundary crosses it
+ // at materialization (a descent shell), nowhere else.
+ //
+ // Everything below is DERIVED from the two physical facts and the scale, so no consumer may
+ // write its own conversion: one edit to the scale moves the whole chart consistently.
+
+ /** Metres in one chart block — the scale the whole universe layer is drawn at. */
+ public static final int METRES_PER_CHART_BLOCK = 250;
+ /** Metres in one astronomical unit (IAU 2012). */
+ public static final double METRES_PER_AU = 1.495_978_707e11d;
+ /** Metres in one Julian light year. */
+ public static final double METRES_PER_LIGHT_YEAR = 9.460_730_472_580_8e15d;
+
+ /** Chart blocks in one astronomical unit. */
+ public static final long BLOCKS_PER_AU =
+ Math.round(METRES_PER_AU / METRES_PER_CHART_BLOCK);
+ /** Chart blocks in one light year. */
+ public static final long BLOCKS_PER_LIGHT_YEAR =
+ Math.round(METRES_PER_LIGHT_YEAR / METRES_PER_CHART_BLOCK);
+ /**
+ * Chart blocks per unit of {@code orbitalDistance} — the ONE law that turns an orbit into a
+ * place, for authored and procedural systems alike.
+ *
+ * It used to be a literal million blocks per unit, six times too small, because a system's
+ * extent was defined as a fraction of the distance to the next star and the orbit scale was
+ * shrunk until systems fit. Extent now follows the outermost orbit, so the scale can be what the
+ * metric says it is and one orbit unit means one distance everywhere.
+ */
+ public static final long BLOCKS_PER_ORBIT_UNIT = BLOCKS_PER_AU / DISTANCE_UNITS_PER_AU;
+
+ /** Earth's equatorial radius in metres — the unit a body's {@code radius} is stated in. */
+ public static final double EARTH_RADIUS_METRES = 6_378_137d;
+ /** Earth's radius in chart blocks: what one unit of a body's radius is worth on the chart. */
+ public static final double EARTH_RADIUS_BLOCKS = EARTH_RADIUS_METRES / METRES_PER_CHART_BLOCK;
/**
* Earth's albedo — the reflectivity a world is assumed to have when its type has not stated one.
* It was hard-coded into the temperature formula with a comment saying it could not easily be
@@ -234,32 +272,30 @@ public static double getStellarBrightness(StellarBody star, int orbitalDistance)
return MIN_BRIGHTNESS;
}
float planetaryOrbitalRadius = orbitalDistance / (float) DISTANCE_UNITS_PER_AU;
- // EVERY star that shines on this world contributes, and what ADDS is the FLUX each one
- // delivers here — not their luminosities. Radiant power from mutually incoherent sources
- // superposes linearly, so E = sum of L_i / d_i², with each star's own distance under its own
- // luminosity. Summing luminosities first and dividing once is the same number only while all
- // the stars are equidistant from the planet.
+ // EVERY star of the system shines on this world, and what ADDS is the FLUX each one delivers
+ // here — not their luminosities. Radiant power from mutually incoherent sources superposes
+ // linearly, so E = sum of L_i / d_i², with each star's own distance under its own luminosity.
+ // Summing luminosities first and dividing once is the same number only while all the stars
+ // are equidistant from the planet.
//
- // Today they are, by construction rather than by physics: a companion's separation is stored
- // as an ANGLE in the sky (StellarBody.getStarSeparation), so there is no distance to give it,
- // and every companion is fed the primary's. That is exact for the close binaries the model can
- // actually describe, and it is why this sums flux terms rather than luminosities — when a
- // companion gains a real orbital radius, only the argument below changes.
+ // The walk starts at the system's ROOT, not at the star the planet is bound to, so a world of
+ // a companion is lit by the primary as well — an S-type planet is a planet in a binary, not a
+ // planet with one sun that happens to have a bright neighbour. Each star's distance is the
+ // separation between it and the planet's own star, combined with the planet's orbit: the
+ // planet's direction round its star is not known here, so the two lengths compose in
+ // quadrature. That is exact when they are perpendicular, and correct in both limits — a close
+ // companion converges to the planet's own orbital radius, a distant one to the separation.
//
- // This replaces a walk over the companions whose only effect was to clear a boolean: any
- // ordinary companion turned the accretion-disc dimming OFF, after which the brightness came
- // from the BLACK HOLE's own size and temperature at full strength, and the companion itself
- // never contributed a photon.
+ // This replaces feeding every companion the PRIMARY's distance, which was exact only for the
+ // close binaries the old angle-valued separation could describe: a companion twenty AU out
+ // warmed a world as though it were sitting one AU away.
//Returns ratio compared to a planet at 1 AU for Sol, because the other values in AR are normalized,
//and this works fairly well for hooking into with other mod's solar panels & such
- double brightness = fluxOf(star, planetaryOrbitalRadius);
- Iterable companions = star.getSubStars();
- if (companions != null) {
- for (StellarBody companion : companions) {
- if (companion != null) {
- brightness += fluxOf(companion, planetaryOrbitalRadius);
- }
- }
+ double brightness = 0d;
+ for (StellarBody member : systemOf(star)) {
+ double separationAu = member == star ? 0d : member.separationAuFrom(star);
+ brightness += fluxOf(member,
+ (float) Math.hypot(planetaryOrbitalRadius, separationAu));
}
// Guarantee: never return 0, NaN, or Infinity
@@ -269,6 +305,37 @@ public static double getStellarBrightness(StellarBody star, int orbitalDistance)
return brightness;
}
+ /**
+ * Every star of the system {@code member} belongs to — its root primary and every companion
+ * under it, at any depth. A three-star hierarchy is walked the same way a pair is, so nothing
+ * downstream needs a case for one.
+ */
+ public static java.util.List systemOf(StellarBody member) {
+ java.util.List all = new java.util.ArrayList<>();
+ if (member == null) {
+ return all;
+ }
+ StellarBody root = member;
+ while (root.getParentStar() != null) {
+ root = root.getParentStar();
+ }
+ collectStars(root, all);
+ return all;
+ }
+
+ private static void collectStars(StellarBody star, java.util.List into) {
+ if (star == null || into.contains(star)) {
+ return; // a cycle in an authored hierarchy must not hang the light calculation
+ }
+ into.add(star);
+ Iterable companions = star.getSubStars();
+ if (companions != null) {
+ for (StellarBody companion : companions) {
+ collectStars(companion, into);
+ }
+ }
+ }
+
/**
* The flux one star delivers at {@code orbitalRadiusAu}, relative to Sol at 1 AU:
* {@code size² · (T/Sol)⁴ / r²} — Stefan-Boltzmann over the inverse square, both in solar units.
diff --git a/src/main/java/zmaster587/advancedRocketry/util/XMLPlanetLoader.java b/src/main/java/zmaster587/advancedRocketry/util/XMLPlanetLoader.java
index 6f3255c1c..88899f8c6 100644
--- a/src/main/java/zmaster587/advancedRocketry/util/XMLPlanetLoader.java
+++ b/src/main/java/zmaster587/advancedRocketry/util/XMLPlanetLoader.java
@@ -100,7 +100,8 @@ public class XMLPlanetLoader {
private static final String ATTR_SIZE = "size";
private static final String ATTR_NUMPLANETS = "numPlanets";
private static final String ATTR_NUMGASPLANETS = "numGasGiants";
- private static final String ATTR_SEPERATION = "separation";
+ private static final String ATTR_COMPANION_ORBIT = "orbitalDistance";
+ private static final String ATTR_COMPANION_THETA = "orbitalTheta";
private static final String ATTR_DIMID = "DIMID";
private static final String ATTR_NATIVEDIM = "dimMapping";
private static final String ATTR_ICON = "customIcon";
@@ -516,7 +517,9 @@ public static String writeXML(IGalaxy galaxy) {
nodeSubStar.setAttribute(ATTR_BLACKHOLE_DISK_ANGLE, Float.toString(star2.diskAngle));
nodeSubStar.setAttribute(ATTR_TEMP, Integer.toString(star2.getTemperature()));
nodeSubStar.setAttribute(ATTR_SIZE, Float.toString(star2.getSize()));
- nodeSubStar.setAttribute(ATTR_SEPERATION, Float.toString(star2.getStarSeparation()));
+ nodeSubStar.setAttribute(ATTR_COMPANION_ORBIT, Integer.toString(star2.getOrbitalDistance()));
+ nodeSubStar.setAttribute(ATTR_COMPANION_THETA,
+ Double.toString(Math.toDegrees(star2.getBaseTheta())));
nodeStar.appendChild(nodeSubStar);
}
@@ -1151,7 +1154,7 @@ else if (planetPropertyNode.getNodeName().equalsIgnoreCase(ELEMENT_BIOMEIDS)) {
String nbtString = "";
Node weightNode = planetPropertyNode.getAttributes().getNamedItem(ATTR_WEIGHT);
Node groupMinNode = planetPropertyNode.getAttributes().getNamedItem(ATTR_GROUPMIN);
- Node groupMaxNode = planetPropertyNode.getAttributes().getNamedItem(ATTR_GROUPMIN);
+ Node groupMaxNode = planetPropertyNode.getAttributes().getNamedItem(ATTR_GROUPMAX);
Node nbtNode = planetPropertyNode.getAttributes().getNamedItem(ATTR_NBT);
//Get spawn properties
@@ -1320,7 +1323,6 @@ else if (planetPropertyNode.getNodeName().equalsIgnoreCase(ELEMENT_CAN_DECORATE)
properties.setDecoratoration(Boolean.parseBoolean(planetPropertyNode.getTextContent()));
else if (planetPropertyNode.getNodeName().equalsIgnoreCase(ELEMENT_RING_ANGLE)) {
properties.ringAngle = Integer.parseInt(planetPropertyNode.getTextContent());
- System.out.println("read rings: "+properties.ringAngle);
}
else if (planetPropertyNode.getNodeName().equalsIgnoreCase(ELEMENT_RINGCOLOR)) {
String[] colors = planetPropertyNode.getTextContent().split(",");
@@ -1524,10 +1526,27 @@ public StellarBody readSubStar(Node planetNode) {
}
}
- nameNode = planetNode.getAttributes().getNamedItem(ATTR_SEPERATION);
+ // A companion's orbit about its primary, in the same distance units a planet's is in.
+ // It used to be an angle called "separation", which could say how far off the primary a
+ // companion LOOKED from one particular world and nothing else — not where it was, not
+ // what it lit, and not that it moved.
+ nameNode = planetNode.getAttributes().getNamedItem(ATTR_COMPANION_ORBIT);
if (nameNode != null && !nameNode.getNodeValue().isEmpty()) {
try {
- star.setStarSeparation(Float.parseFloat(nameNode.getNodeValue()));
+ star.setOrbitalDistance(Integer.parseInt(nameNode.getNodeValue()));
+ } catch (NumberFormatException e) {
+ AdvancedRocketry.logger.warn("Error Reading star " + star.getName());
+ }
+ }
+
+ nameNode = planetNode.getAttributes().getNamedItem(ATTR_COMPANION_THETA);
+ if (nameNode != null && !nameNode.getNodeValue().isEmpty()) {
+ try {
+ // DEGREES, exactly as a planet's is. One name, one unit: an
+ // angle that meant radians here and degrees one element away would be a trap
+ // no author could see, because both parse and neither complains.
+ star.setBaseTheta(Math.toRadians(
+ Double.parseDouble(nameNode.getNodeValue()) % 360d));
} catch (NumberFormatException e) {
AdvancedRocketry.logger.warn("Error Reading star " + star.getName());
}
diff --git a/src/test/java/zmaster587/advancedRocketry/test/integration/SystemContentTest.java b/src/test/java/zmaster587/advancedRocketry/test/integration/SystemContentTest.java
index d39acbcd3..b85ea7034 100644
--- a/src/test/java/zmaster587/advancedRocketry/test/integration/SystemContentTest.java
+++ b/src/test/java/zmaster587/advancedRocketry/test/integration/SystemContentTest.java
@@ -14,6 +14,7 @@
import zmaster587.advancedRocketry.space.GalacticCoord;
import zmaster587.advancedRocketry.test.MinecraftBootstrap;
import zmaster587.advancedRocketry.util.AstronomicalBodyHelper;
+import zmaster587.advancedRocketry.universe.ClusteredGalaxyGenerator;
import zmaster587.advancedRocketry.universe.GalaxyGenConfig;
import zmaster587.advancedRocketry.universe.SystemBody;
import zmaster587.advancedRocketry.universe.SystemBodyKind;
@@ -122,6 +123,53 @@ public void authoredPlanetsGetTheirOwnCellsInsideTheSuperCellBox() {
}
}
+ @Test
+ public void oneOrbitalDistanceMeansOneDistanceInBothFamilies() {
+ // The acceptance the scale rework exists for. An authored planet and a procedural one at the
+ // same orbital distance must stand the same distance from their stars — the field is
+ // documented in one unit, and every derived number (insolation, temperature, period) is
+ // computed from it and never from where the body was placed. They used to be turned into
+ // positions by two different laws: authored linear and absolute, procedural logarithmic and
+ // normalised to whatever neighbourhood the system had been given. Order survived; proportion
+ // did not, and the science and the flight time disagreed.
+ StellarBody star = new StellarBody();
+ star.setId(4244);
+ star.setName("ScaleStar");
+ planet(720, 300, 0.0).setStar(star);
+
+ GalacticCoord anchor = GalacticCoord.ofSectorLocal(11, -4, 6, 0, 0, 0);
+ SystemBody authored = null;
+ for (SystemBody b : SystemContent.bodiesOf(star, anchor)) {
+ if (b.dimId() == 720) {
+ authored = b;
+ }
+ }
+ assertNotNull(authored);
+ double authoredPerUnit = authored.absoluteAt(0L).distanceTo(
+ zmaster587.advancedRocketry.space.AbsolutePos.ofCellName(anchor))
+ / authored.orbitalDistance();
+
+ ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(
+ new GalaxyGenConfig(1.0d, GalaxyGenConfig.DEFAULT_MIN_SPACING, 8, 0.0d, null));
+ long spacing = GalaxyGenConfig.DEFAULT_MIN_SPACING;
+ Optional seat = gen.anchorAt(0xBEEFL,
+ GalacticCoord.ofSectorLocal(spacing, spacing, spacing, 0L, 0L, 0L));
+ assertTrue("the fixture needs an occupied super-cell", seat.isPresent());
+ int compared = 0;
+ for (SystemBody b : gen.bodiesFor(0xBEEFL, seat.get())) {
+ if (b.kind() != SystemBodyKind.PLANET && b.kind() != SystemBodyKind.GAS_GIANT) {
+ continue;
+ }
+ double proceduralPerUnit = b.absoluteAt(0L).distanceTo(
+ zmaster587.advancedRocketry.space.AbsolutePos.ofCellName(seat.get()))
+ / b.orbitalDistance();
+ assertEquals("one orbit unit must be one distance in both families",
+ authoredPerUnit, proceduralPerUnit, authoredPerUnit * 1e-6d);
+ compared++;
+ }
+ assertTrue("the procedural system must have bodies to compare against", compared > 0);
+ }
+
@Test
public void planetResolvesToItsOwnCellThroughTheRegistry() {
StellarBody star = new StellarBody();
diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/AstronomicalBodyHelperTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/AstronomicalBodyHelperTest.java
index 4e3aceec9..ec543fd60 100644
--- a/src/test/java/zmaster587/advancedRocketry/test/unit/AstronomicalBodyHelperTest.java
+++ b/src/test/java/zmaster587/advancedRocketry/test/unit/AstronomicalBodyHelperTest.java
@@ -98,11 +98,54 @@ public void blackHoleStarReducesBrightness() {
public void everyStarInASystemContributesItsOwnLight() {
double alone = AstronomicalBodyHelper.getStellarBrightness(sunLikeStar(), 100);
- StellarBody binary = sunLikeStar();
- binary.addSubStar(sunLikeStar());
+ StellarBody contactPair = sunLikeStar();
+ StellarBody touching = sunLikeStar();
+ touching.setOrbitalDistance(0); // the degenerate case: both stars at the same place
+ contactPair.addSubStar(touching);
- assertEquals("two identical stars light a world twice as brightly as one does",
- 2 * alone, AstronomicalBodyHelper.getStellarBrightness(binary, 100), 1e-9);
+ assertEquals("two identical stars in the same place light a world twice as brightly",
+ 2 * alone, AstronomicalBodyHelper.getStellarBrightness(contactPair, 100), 1e-9);
+ }
+
+ @Test
+ public void aCompanionsContributionFallsOffWithItsOwnDistance() {
+ // The defect: every companion used to be fed the PRIMARY's distance, so a companion twenty AU
+ // away warmed a world exactly as much as one sitting beside its star. A separation that costs
+ // nothing is a separation the model does not really have.
+ double alone = AstronomicalBodyHelper.getStellarBrightness(sunLikeStar(), 100);
+
+ StellarBody close = sunLikeStar();
+ StellarBody nearby = sunLikeStar();
+ nearby.setOrbitalDistance(5); // 0.05 AU
+ close.addSubStar(nearby);
+
+ StellarBody wide = sunLikeStar();
+ StellarBody distant = sunLikeStar();
+ distant.setOrbitalDistance(2_000); // 20 AU, an Alpha-Centauri-like pair
+ wide.addSubStar(distant);
+
+ double closeBrightness = AstronomicalBodyHelper.getStellarBrightness(close, 100);
+ double wideBrightness = AstronomicalBodyHelper.getStellarBrightness(wide, 100);
+
+ assertTrue("a close companion nearly doubles the light", closeBrightness > 1.9 * alone);
+ assertTrue("a distant one adds only a little", wideBrightness < 1.1 * alone);
+ assertTrue("but it is never nothing", wideBrightness > alone);
+ }
+
+ @Test
+ public void aWorldOfTheCompanionIsLitByThePrimaryToo() {
+ // An S-type planet is a planet in a binary, not a planet with one sun that happens to have a
+ // bright neighbour. The walk therefore starts at the system's root, not at the star the
+ // planet is bound to.
+ double alone = AstronomicalBodyHelper.getStellarBrightness(sunLikeStar(), 100);
+
+ StellarBody primary = sunLikeStar();
+ StellarBody companion = sunLikeStar();
+ companion.setOrbitalDistance(0);
+ primary.addSubStar(companion);
+
+ assertEquals("a world of the companion sees both stars", 2 * alone,
+ AstronomicalBodyHelper.getStellarBrightness(companion, 100), 1e-9);
}
/**
@@ -123,7 +166,9 @@ public void aCompanionDoesNotTurnABlackHoleBackIntoAStar() {
StellarBody holeWithCompanion = sunLikeStar();
holeWithCompanion.setBlackHole(true);
- holeWithCompanion.addSubStar(sunLikeStar());
+ StellarBody companion = sunLikeStar();
+ companion.setOrbitalDistance(0); // separation is not what this test is about
+ holeWithCompanion.addSubStar(companion);
double together = AstronomicalBodyHelper.getStellarBrightness(holeWithCompanion, 100);
assertEquals("a black hole and its companion each light the world on their own terms",
diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/ClusteredGalaxyGeneratorTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/ClusteredGalaxyGeneratorTest.java
index 4dab7b93e..b47d9d4a2 100644
--- a/src/test/java/zmaster587/advancedRocketry/test/unit/ClusteredGalaxyGeneratorTest.java
+++ b/src/test/java/zmaster587/advancedRocketry/test/unit/ClusteredGalaxyGeneratorTest.java
@@ -16,6 +16,8 @@
import zmaster587.advancedRocketry.universe.StarSystem;
import zmaster587.advancedRocketry.universe.SystemBody;
import zmaster587.advancedRocketry.universe.SystemBodyKind;
+import zmaster587.advancedRocketry.universe.UniverseScale;
+import zmaster587.advancedRocketry.util.AstronomicalBodyHelper;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@@ -25,9 +27,15 @@
* Contract tests for the deterministic clustered galaxy generator. Pure-JUnit; no MC bootstrap.
*
* Pins the generation CONTRACTS: pure determinism over {@code (seed, cell)}, the minimum-spacing
- * guarantee, that the distribution actually clusters (void + dense regions), that {@code systemsInRegion}
- * agrees cell-for-cell with {@code systemAt}, and that the tunable params drive the outcome. Balance numbers
- * are exercised as inputs, never pinned as expected values.
+ * guarantee, the separation floor between two seats, that the distribution actually clusters (void +
+ * dense regions), that {@code systemsInRegion} agrees with {@code systemAt}, and that the tunable
+ * params drive the outcome. Balance numbers are exercised as inputs, never pinned as expected
+ * values.
+ *
+ * Sampling is by SUPER-CELL, never by cell. A star seat is one cell in a cube of tens of
+ * millions, so sweeping cells finds nothing whatever the galaxy holds — and a spacing small enough to
+ * sweep is a spacing with no room for a system in it, which is a different generator from the shipped
+ * one. Every sweep here walks the partition the generator itself walks.
*/
public class ClusteredGalaxyGeneratorTest {
@@ -37,24 +45,27 @@ private static GalacticCoord cell(long sx, long sy, long sz) {
return GalacticCoord.ofSectorLocal(sx, sy, sz, 0L, 0L, 0L);
}
- /**
- * A compact galaxy for sampling tests: the production DEFAULT spacing (a balance number, never pinned)
- * is far too sparse to sample in a unit-test-sized volume.
- */
- private static GalaxyGenConfig smallCfg() {
- return new GalaxyGenConfig(0.35d, 4, 16, 0.6d, null);
+ /** The shipped spacing: what the sampled galaxy is is what the game ships. */
+ private static final int SPACING = GalaxyGenConfig.DEFAULT_MIN_SPACING;
+
+ private static GalaxyGenConfig cfg(double density, int spacing, int clusterScale, double voidFraction) {
+ return new GalaxyGenConfig(density, spacing, clusterScale, voidFraction, null);
+ }
+
+ private static GalaxyGenConfig defaultsCfg() {
+ return cfg(0.35d, SPACING, 16, 0.6d);
}
- /** Iterate an inclusive sector box, calling the visitor with each cell coordinate. */
+ /** Iterate an inclusive box of SUPER-CELLS, calling the visitor with each one's probe cell. */
private interface CellVisitor {
void visit(GalacticCoord c);
}
- private static void forEachCell(long r, CellVisitor v) {
+ private static void forEachSuperCell(long r, long spacing, CellVisitor v) {
for (long x = -r; x <= r; x++) {
for (long y = -r; y <= r; y++) {
for (long z = -r; z <= r; z++) {
- v.visit(cell(x, y, z));
+ v.visit(cell(x * spacing, y * spacing, z * spacing));
}
}
}
@@ -62,63 +73,121 @@ private static void forEachCell(long r, CellVisitor v) {
@Test
public void systemAtIsDeterministic() {
- ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(smallCfg());
- forEachCell(6, c -> {
- Optional a = gen.systemAt(SEED, c);
- Optional b = gen.systemAt(SEED, c);
- assertEquals("presence must be stable at " + c, a.isPresent(), b.isPresent());
- if (a.isPresent()) {
- assertEquals("id stable", a.get().starId(), b.get().starId());
- assertEquals("temperature stable", a.get().star().getTemperature(),
- b.get().star().getTemperature());
- assertEquals("size stable", a.get().star().getSize(), b.get().star().getSize(), 0f);
+ ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(defaultsCfg());
+ forEachSuperCell(6, SPACING, probe -> {
+ Optional anchor = gen.anchorAt(SEED, probe);
+ if (!anchor.isPresent()) {
+ return;
}
+ Optional a = gen.systemAt(SEED, anchor.get());
+ Optional b = gen.systemAt(SEED, anchor.get());
+ assertTrue("an attributed anchor must point-resolve at " + anchor.get(), a.isPresent());
+ assertEquals("presence must be stable", a.isPresent(), b.isPresent());
+ assertEquals("id stable", a.get().starId(), b.get().starId());
+ assertEquals("temperature stable", a.get().star().getTemperature(),
+ b.get().star().getTemperature());
+ assertEquals("size stable", a.get().star().getSize(), b.get().star().getSize(), 0f);
});
}
+ @Test
+ public void onlyTheSeatCellItselfHoldsTheSystem() {
+ // The anchor NAMES the system; its neighbours are ordinary space that merely attributes to it.
+ ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(defaultsCfg());
+ int checked = 0;
+ for (GalacticCoord anchor : anchors(gen, SEED, SPACING, 3)) {
+ assertTrue(gen.systemAt(SEED, anchor).isPresent());
+ assertFalse("a cell beside the seat must not itself be the system",
+ gen.systemAt(SEED, anchor.plusLocal(GalacticCoord.CELL, 0L, 0L)).isPresent());
+ checked++;
+ }
+ assertTrue(checked > 5);
+ }
+
@Test
public void differentSeedsProduceDifferentGalaxies() {
- ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(smallCfg());
- Set occupiedA = occupiedCellKeys(gen, SEED, 8);
- Set occupiedB = occupiedCellKeys(gen, SEED + 1, 8);
+ ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(defaultsCfg());
+ Set occupiedA = occupiedSeats(gen, SEED, SPACING, 6);
+ Set occupiedB = occupiedSeats(gen, SEED + 1, SPACING, 6);
assertFalse("a different seed must not reproduce the same galaxy", occupiedA.equals(occupiedB));
}
@Test
public void minimumSpacingIsRespected() {
// At most one system per minSpacing-cube super-cell, anywhere in the sampled volume.
- GalaxyGenConfig cfg = new GalaxyGenConfig(0.9d, 4, 8, 0.0d, null); // dense, no void: stress spacing
- ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(cfg);
+ GalaxyGenConfig config = cfg(0.9d, SPACING, 8, 0.0d); // dense, no void: stress spacing
+ ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(config);
Map perSuperCell = new HashMap<>();
- forEachCell(10, c -> {
- if (gen.systemAt(SEED, c).isPresent()) {
- long s = cfg.minSpacing;
- String superKey = Math.floorDiv(c.sectorX(), s) + "_"
- + Math.floorDiv(c.sectorY(), s) + "_" + Math.floorDiv(c.sectorZ(), s);
- perSuperCell.merge(superKey, 1, Integer::sum);
- }
- });
+ for (GalacticCoord anchor : anchors(gen, SEED, SPACING, 4)) {
+ long s = config.minSpacing;
+ String superKey = Math.floorDiv(anchor.sectorX(), s) + "_"
+ + Math.floorDiv(anchor.sectorY(), s) + "_" + Math.floorDiv(anchor.sectorZ(), s);
+ perSuperCell.merge(superKey, 1, Integer::sum);
+ }
+ assertFalse("the sweep must find systems", perSuperCell.isEmpty());
for (Map.Entry e : perSuperCell.entrySet()) {
assertTrue("super-cell " + e.getKey() + " holds " + e.getValue() + " systems (max 1)",
e.getValue() <= 1);
}
}
+ @Test
+ public void noTwoStarsStandCloserThanTheSeparationFloor() {
+ // The floor is what makes a near-pair of seats impossible, and it is what stops two unrelated
+ // systems — two names, two frames, no gravitational relation — from being read as a binary.
+ // Multiplicity is something a system states about itself, never something the lattice fakes.
+ GalaxyGenConfig config = cfg(1.0d, SPACING, 8, 0.0d); // every cube occupied: the tightest case
+ ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(config);
+ List seats = anchors(gen, SEED, SPACING, 2);
+ assertTrue("the sweep must find systems", seats.size() > 10);
+ double floorBlocks = UniverseScale.SEPARATION_FLOOR_AU * AstronomicalBodyHelper.BLOCKS_PER_AU;
+ for (int i = 0; i < seats.size(); i++) {
+ for (int j = i + 1; j < seats.size(); j++) {
+ double d = seats.get(i).staticFrameDistanceTo(seats.get(j));
+ assertTrue("seats " + seats.get(i).cellKey() + " and " + seats.get(j).cellKey()
+ + " stand " + d + " blocks apart, inside the floor of " + floorBlocks,
+ d >= floorBlocks);
+ }
+ }
+ }
+
+ @Test
+ public void aSeatIsNotConfinedToTheMiddleOfItsCube() {
+ // The seat used to be pinned into the middle quarter per axis — 1.6 % of the cube's volume —
+ // which reads as a lattice of tight clumps with guaranteed-empty walls. What replaces it is a
+ // margin sized by what a system NEEDS, so most of the cube is reachable.
+ GalaxyGenConfig config = cfg(1.0d, SPACING, 8, 0.0d);
+ ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(config);
+ long s = config.minSpacing;
+ double nearestFaceFraction = 1d;
+ int checked = 0;
+ for (GalacticCoord anchor : anchors(gen, SEED, SPACING, 2)) {
+ long offset = Math.floorMod(anchor.sectorX(), s);
+ nearestFaceFraction = Math.min(nearestFaceFraction, offset / (double) s);
+ nearestFaceFraction = Math.min(nearestFaceFraction, (s - offset) / (double) s);
+ checked++;
+ }
+ assertTrue(checked > 10);
+ assertTrue("some seat must sit well outside the middle quarter, nearest face fraction was "
+ + nearestFaceFraction, nearestFaceFraction < 0.25d);
+ }
+
@Test
public void distributionClustersIntoGalaxiesAndVoid() {
- // A strongly-clustered config: expect BOTH occupied sub-regions and entirely-empty (void) sub-regions.
- GalaxyGenConfig cfg = new GalaxyGenConfig(0.6d, 2, 8, 0.6d, null);
- ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(cfg);
+ // A strongly-clustered config: expect BOTH occupied sub-regions and entirely-empty (void) ones.
+ GalaxyGenConfig config = cfg(0.6d, SPACING, 8, 0.6d);
+ ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(config);
int emptyBlocks = 0;
int nonEmptyBlocks = 0;
- // Scan 16x16 coarse blocks (each 6x6x1 cells) across a wide plane; classify each as void or populated.
+ // Scan 16x16 coarse blocks (each 6x6x1 super-cells) across a wide plane.
for (long bx = -8; bx < 8; bx++) {
for (long by = -8; by < 8; by++) {
boolean any = false;
for (long dx = 0; dx < 6 && !any; dx++) {
for (long dy = 0; dy < 6 && !any; dy++) {
- if (gen.systemAt(SEED, cell(bx * 6 + dx, by * 6 + dy, 0)).isPresent()) {
+ if (gen.anchorAt(SEED, cell((bx * 6 + dx) * SPACING, (by * 6 + dy) * SPACING, 0))
+ .isPresent()) {
any = true;
}
}
@@ -136,17 +205,21 @@ public void distributionClustersIntoGalaxiesAndVoid() {
@Test
public void systemsInRegionAgreesWithSystemAt() {
- // The single most important consistency contract: the region enumeration and the point query must
- // never diverge, or a telescope scan would show systems a jump can't reach (or vice versa).
- ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(smallCfg());
- long r = 9;
-
- Set byPointQuery = new HashSet<>();
- forEachCell(r, c -> {
- if (gen.systemAt(SEED, c).isPresent()) {
- byPointQuery.add(c.cellKey());
+ // The single most important consistency contract: the region enumeration and the point query
+ // must never diverge, or a telescope scan would show systems a jump can't reach (or vice versa).
+ ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(defaultsCfg());
+ // The sweep is one super-cell narrower than the box, because a seat sits at an offset INSIDE
+ // its cube: the outermost swept cube's seat would fall outside a box cut at that cube's face.
+ long r = 3L * SPACING;
+
+ Set byAttribution = new HashSet<>();
+ forEachSuperCell(2, SPACING, probe -> {
+ Optional anchor = gen.anchorAt(SEED, probe);
+ if (anchor.isPresent()) {
+ byAttribution.add(anchor.get().cellKey());
}
});
+ assertFalse("the sweep must find systems", byAttribution.isEmpty());
Map region = gen.systemsInRegion(SEED, cell(-r, -r, -r), cell(r, r, r));
Set byRegion = new HashSet<>();
@@ -157,34 +230,35 @@ public void systemsInRegionAgreesWithSystemAt() {
assertTrue("region cell " + e.getKey() + " must point-resolve", point.isPresent());
assertEquals(point.get().starId(), e.getValue().starId());
}
- assertEquals("systemsInRegion must enumerate exactly the point-query occupied cells",
- byPointQuery, byRegion);
+ assertTrue("every seat the sweep attributed must be enumerated by the region query",
+ byRegion.containsAll(byAttribution));
}
@Test
public void systemsInRegionHandlesSwappedBounds() {
- ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(smallCfg());
- Map ordered = gen.systemsInRegion(SEED, cell(-4, -4, -4), cell(4, 4, 4));
- Map swapped = gen.systemsInRegion(SEED, cell(4, 4, 4), cell(-4, -4, -4));
+ ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(defaultsCfg());
+ long r = 2L * SPACING;
+ Map ordered = gen.systemsInRegion(SEED, cell(-r, -r, -r), cell(r, r, r));
+ Map swapped = gen.systemsInRegion(SEED, cell(r, r, r), cell(-r, -r, -r));
assertEquals("swapped min/max must enumerate the same box", ordered.keySet(), swapped.keySet());
}
@Test
public void voidFractionDrivesOccupancy() {
- int allVoid = occupiedCellKeys(new ClusteredGalaxyGenerator(
- new GalaxyGenConfig(0.8d, 2, 8, 1.0d, null)), SEED, 8).size();
- int noVoid = occupiedCellKeys(new ClusteredGalaxyGenerator(
- new GalaxyGenConfig(0.8d, 2, 8, 0.0d, null)), SEED, 8).size();
+ int allVoid = occupiedSeats(new ClusteredGalaxyGenerator(cfg(0.8d, SPACING, 8, 1.0d)),
+ SEED, SPACING, 6).size();
+ int noVoid = occupiedSeats(new ClusteredGalaxyGenerator(cfg(0.8d, SPACING, 8, 0.0d)),
+ SEED, SPACING, 6).size();
assertEquals("voidFraction=1 must yield an empty galaxy", 0, allVoid);
assertTrue("voidFraction=0 must populate the galaxy", noVoid > 0);
}
@Test
public void densityDrivesOccupancy() {
- int sparse = occupiedCellKeys(new ClusteredGalaxyGenerator(
- new GalaxyGenConfig(0.1d, 2, 8, 0.0d, null)), SEED, 10).size();
- int dense = occupiedCellKeys(new ClusteredGalaxyGenerator(
- new GalaxyGenConfig(0.9d, 2, 8, 0.0d, null)), SEED, 10).size();
+ int sparse = occupiedSeats(new ClusteredGalaxyGenerator(cfg(0.1d, SPACING, 8, 0.0d)),
+ SEED, SPACING, 7).size();
+ int dense = occupiedSeats(new ClusteredGalaxyGenerator(cfg(0.9d, SPACING, 8, 0.0d)),
+ SEED, SPACING, 7).size();
assertTrue("higher density must place more systems (" + sparse + " vs " + dense + ")",
dense > sparse);
}
@@ -195,22 +269,22 @@ public void starTypesAreDrawnFromTheConfiguredSetAndWeighted() {
List types = new ArrayList<>();
types.add(new GalaxyGenConfig.StarType(50, 0.5f, 1.0f, 100)); // common
types.add(new GalaxyGenConfig.StarType(250, 2.0f, 3.0f, 1)); // rare
- GalaxyGenConfig cfg = new GalaxyGenConfig(0.9d, 1, 8, 0.0d, types);
- ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(cfg);
+ GalaxyGenConfig config = new GalaxyGenConfig(0.9d, SPACING, 8, 0.0d, types);
+ ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(config);
int common = 0;
int rare = 0;
int other = 0;
int total = 0;
Set seenTemps = new HashSet<>();
- // Iterate the underlying loop directly for a large sample.
for (long x = -20; x <= 20; x++) {
for (long y = -20; y <= 20; y++) {
- Optional sys = gen.systemAt(SEED, cell(x, y, 0));
- if (!sys.isPresent()) {
+ Optional anchor = gen.anchorAt(SEED, cell(x * SPACING, y * SPACING, 0));
+ if (!anchor.isPresent()) {
continue;
}
- int temp = sys.get().star().getTemperature();
+ StarSystem sys = gen.systemAt(SEED, anchor.get()).get();
+ int temp = sys.star().getTemperature();
seenTemps.add(Integer.toString(temp));
total++;
if (temp == 50) {
@@ -221,7 +295,7 @@ public void starTypesAreDrawnFromTheConfiguredSetAndWeighted() {
other++;
}
// size must lie in the archetype's range
- float size = sys.get().star().getSize();
+ float size = sys.star().getSize();
if (temp == 50) {
assertTrue(size >= 0.5f && size <= 1.0f);
} else if (temp == 250) {
@@ -237,16 +311,12 @@ public void starTypesAreDrawnFromTheConfiguredSetAndWeighted() {
@Test
public void proceduralSystemIdsAreNegative() {
- ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(
- new GalaxyGenConfig(0.9d, 1, 8, 0.0d, null));
+ ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(cfg(0.9d, SPACING, 8, 0.0d));
boolean sawAny = false;
- for (long x = -10; x <= 10; x++) {
- Optional sys = gen.systemAt(SEED, cell(x, 0, 0));
- if (sys.isPresent()) {
- sawAny = true;
- assertTrue("procedural systems must carry a synthetic negative id, got " + sys.get().starId(),
- sys.get().starId() < 0);
- }
+ for (GalacticCoord anchor : anchors(gen, SEED, SPACING, 2)) {
+ sawAny = true;
+ assertTrue("procedural systems must carry a synthetic negative id",
+ gen.systemAt(SEED, anchor).get().starId() < 0);
}
assertTrue(sawAny);
}
@@ -276,14 +346,15 @@ public void hugeStarWeightsDoNotCollapseTheDistribution() {
types.add(new GalaxyGenConfig.StarType(50, 0.5f, 1.0f, Integer.MAX_VALUE));
types.add(new GalaxyGenConfig.StarType(250, 2.0f, 3.0f, Integer.MAX_VALUE));
ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(
- new GalaxyGenConfig(0.9d, 1, 8, 0.0d, types));
+ new GalaxyGenConfig(0.9d, SPACING, 8, 0.0d, types));
Set seenTemps = new HashSet<>();
for (long x = -20; x <= 20; x++) {
for (long y = -20; y <= 20; y++) {
- Optional sys = gen.systemAt(SEED, cell(x, y, 0));
- if (sys.isPresent()) {
- seenTemps.add(Integer.toString(sys.get().star().getTemperature()));
+ Optional anchor = gen.anchorAt(SEED, cell(x * SPACING, y * SPACING, 0));
+ if (anchor.isPresent()) {
+ seenTemps.add(Integer.toString(
+ gen.systemAt(SEED, anchor.get()).get().star().getTemperature()));
}
}
}
@@ -293,24 +364,18 @@ public void hugeStarWeightsDoNotCollapseTheDistribution() {
@Test
public void proceduralBodiesGetTheirOwnCellsInsideTheSuperCell() {
- // A#1a: a system is an anchored NEIGHBOURHOOD — the star holds the anchor cell, each planet/belt
- // its own cell (snapped to that cell's centre), all inside the anchor's minSpacing super-cell.
- GalaxyGenConfig cfg = new GalaxyGenConfig(0.9d, 16, 8, 0.0d, null);
- ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(cfg);
- long s = cfg.minSpacing;
+ // A system is an anchored NEIGHBOURHOOD — the star holds the anchor cell, each planet/belt its
+ // own cell (snapped to that cell's centre), all inside the anchor's minSpacing super-cell.
+ GalaxyGenConfig config = cfg(0.9d, SPACING, 8, 0.0d);
+ ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(config);
+ long s = config.minSpacing;
boolean checkedAny = false;
- for (long sup = -3; sup <= 3; sup++) {
- GalacticCoord probe = cell(sup * s, 0, 0);
- java.util.Optional anchorOpt = gen.anchorAt(SEED, probe);
- if (!anchorOpt.isPresent()) {
- continue;
- }
+ for (GalacticCoord anchor : anchors(gen, SEED, SPACING, 1)) {
checkedAny = true;
- GalacticCoord anchor = anchorOpt.get();
List a = gen.bodiesFor(SEED, anchor);
assertEquals("bodiesFor must be deterministic", a, gen.bodiesFor(SEED, anchor));
assertEquals("bodiesFor must accept a member cell and answer for the whole system",
- a, gen.bodiesFor(SEED, probe));
+ a, gen.bodiesFor(SEED, anchor.plusLocal(GalacticCoord.CELL, 0L, 0L)));
assertFalse("an occupied system must have bodies", a.isEmpty());
assertEquals("first body is the star at the anchor", SystemBodyKind.STAR, a.get(0).kind());
@@ -338,18 +403,63 @@ public void proceduralBodiesGetTheirOwnCellsInsideTheSuperCell() {
assertTrue(checkedAny);
}
+ @Test
+ public void aBodyStandsExactlyWhereItsOrbitalDistanceSaysItDoes() {
+ // The acceptance the whole scale rework exists for: ONE law, ONE constant. A body at orbital
+ // distance d is d units from its star, in blocks, and its cell NAME is a reading of that same
+ // position rather than a second layout arithmetic beside it. When those two came apart, the
+ // science said one thing and the flight time said another.
+ ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(cfg(0.9d, SPACING, 8, 0.0d));
+ int checked = 0;
+ for (GalacticCoord anchor : anchors(gen, SEED, SPACING, 1)) {
+ List bodies = gen.bodiesFor(SEED, anchor);
+ SystemBody star = bodies.get(0);
+ for (SystemBody body : bodies) {
+ if (body.kind() == SystemBodyKind.STAR || body.kind() == SystemBodyKind.MOON
+ || body.kind() == SystemBodyKind.ASTEROID_BELT) {
+ continue;
+ }
+ double expected = (double) body.orbitalDistance()
+ * AstronomicalBodyHelper.BLOCKS_PER_ORBIT_UNIT;
+ double placed = body.absoluteAt(0L).distanceTo(star.absoluteAt(0L));
+ assertEquals("body at orbit " + body.orbitalDistance() + " of system "
+ + anchor.cellKey() + " must stand that far from its star",
+ expected, placed, expected * 1e-6d + 2d);
+ // And the cell it is NAMED by is a reading of that same place, to within a cell.
+ double named = body.name().staticFrameDistanceTo(anchor);
+ assertTrue("the body's cell name (" + named + " blocks out) must agree with where it "
+ + "is (" + placed + ")",
+ Math.abs(named - placed) <= 2d * GalacticCoord.CELL);
+ checked++;
+ }
+ }
+ assertTrue("the sweep must find bodies", checked > 10);
+ }
+
+ @Test
+ public void aSystemNeverReachesPastItsOwnClearSpace() {
+ // The bound that replaces "a system is a fraction of the distance to the next star": named
+ // bodies stay inside half the separation floor, whatever a star's own zone would have drawn.
+ ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(cfg(1.0d, SPACING, 8, 0.0d));
+ int checked = 0;
+ for (GalacticCoord anchor : anchors(gen, SEED, SPACING, 1)) {
+ for (SystemBody body : gen.bodiesFor(SEED, anchor)) {
+ assertTrue("body at orbit " + body.orbitalDistance() + " reaches past its system's "
+ + "clear space of " + UniverseScale.MAX_NAMED_ORBIT_UNITS + " units",
+ body.orbitalDistance() <= UniverseScale.MAX_NAMED_ORBIT_UNITS);
+ checked++;
+ }
+ }
+ assertTrue(checked > 10);
+ }
+
@Test
public void tinySpacingDegeneratesIntoALoneStar() {
// minSpacing=1: the super-cell IS one cell, and the star already holds it. A second real body
// would have to share that cell, which at most one real body per cell forbids — so the system
// degenerates to its star alone. Degenerate but CONSISTENT: attribution stays exact, nothing
// escapes the box, and no cell ends up with two destinations in it.
- //
- // (Before the retinue gained a distinctness rule this read "every body clamps into the anchor
- // cell", which was the same arrangement described from the other side — and describing it that
- // way made the invariant violation sound like the intended behaviour.)
- ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(
- new GalaxyGenConfig(0.9d, 1, 8, 0.0d, null));
+ ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(cfg(0.9d, 1, 8, 0.0d));
boolean checkedAny = false;
for (long x = -6; x <= 6; x++) {
GalacticCoord c = cell(x, 0, 0);
@@ -368,12 +478,12 @@ public void tinySpacingDegeneratesIntoALoneStar() {
@Test
public void anchorAtAttributesEveryCellOfAnOccupiedSuperCell() {
- GalaxyGenConfig cfg = new GalaxyGenConfig(0.9d, 8, 8, 0.0d, null);
- ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(cfg);
- long s = cfg.minSpacing;
+ GalaxyGenConfig config = cfg(0.9d, SPACING, 8, 0.0d);
+ ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(config);
+ long s = config.minSpacing;
boolean checkedAny = false;
for (long sup = -2; sup <= 2; sup++) {
- java.util.Optional anchor = gen.anchorAt(SEED, cell(sup * s, 0, 0));
+ Optional anchor = gen.anchorAt(SEED, cell(sup * s, 0, 0));
if (!anchor.isPresent()) {
continue;
}
@@ -383,7 +493,7 @@ public void anchorAtAttributesEveryCellOfAnOccupiedSuperCell() {
for (long dy : new long[] {0, s - 1}) {
GalacticCoord member = cell(sup * s + dx, dy, 0);
assertEquals("member " + member + " must attribute to the super-cell's anchor",
- java.util.Optional.of(anchor.get()), gen.anchorAt(SEED, member));
+ anchor, gen.anchorAt(SEED, member));
}
}
// The anchor itself point-resolves to the system.
@@ -394,13 +504,26 @@ public void anchorAtAttributesEveryCellOfAnOccupiedSuperCell() {
// ─── helpers ───────────────────────────────────────────────────────────────
- private static Set occupiedCellKeys(ClusteredGalaxyGenerator gen, long seed, long r) {
- Set keys = new HashSet<>();
- forEachCell(r, c -> {
- if (gen.systemAt(seed, c).isPresent()) {
- keys.add(c.cellKey());
+ /** Every distinct seat in a sweep of super-cells. */
+ private static List anchors(ClusteredGalaxyGenerator gen, long seed, long spacing,
+ long r) {
+ Set seen = new HashSet<>();
+ List out = new ArrayList<>();
+ forEachSuperCell(r, spacing, probe -> {
+ Optional a = gen.anchorAt(seed, probe);
+ if (a.isPresent() && seen.add(a.get().cellKey())) {
+ out.add(a.get());
}
});
+ return out;
+ }
+
+ private static Set occupiedSeats(ClusteredGalaxyGenerator gen, long seed, long spacing,
+ long r) {
+ Set keys = new HashSet<>();
+ for (GalacticCoord a : anchors(gen, seed, spacing, r)) {
+ keys.add(a.cellKey());
+ }
return keys;
}
}
diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetDerivationTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetDerivationTest.java
index 7aa5c4a2f..c62429627 100644
--- a/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetDerivationTest.java
+++ b/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetDerivationTest.java
@@ -434,18 +434,24 @@ public void orbitsAreOrderedAndSpreadLogarithmically() {
}
@Test
- public void theCellPlacementFractionAgreesWithTheOrbitItCameFrom() {
- // The placement maps an orbit onto a cell radius through this fraction, so a body that is third
- // from its star is third out from the anchor cell. If the two ever disagreed, the sky would show
- // a system laid out differently from the one the physics describes.
- StellarBody s = sol();
- double previous = -1d;
- for (int d = 1; d <= 20_000; d += 37) {
- double f = PlanetDerivation.orbitFraction(d, s);
- assertTrue("fraction must stay in [0,1]", f >= 0d && f <= 1d);
- assertTrue("fraction must not decrease as the orbit grows", f >= previous);
- previous = f;
+ public void aStarsZoneIsItsOwnBusinessAndNotItsNeighbourhoods() {
+ // How much room a system has where it happens to sit is not an input to where its worlds
+ // orbit. A cramped system holds FEWER worlds — the generator drops what does not fit — and
+ // never the same worlds moved closer to their star than their own climate says they are.
+ // The defect this replaces normalised every orbit to the neighbourhood, so one orbital
+ // distance was one distance in a roomy system and another in a cramped one.
+ StellarBody dwarf = star(40, 0.6f);
+ StellarBody giant = star(220, 2.6f);
+ GalacticCoord anchor = cell(4, -2, 7);
+
+ for (int i = 0; i < 6; i++) {
+ int cool = PlanetDerivation.orbitalDistanceOf(SEED, anchor, i, 6, dwarf);
+ int hot = PlanetDerivation.orbitalDistanceOf(SEED, anchor, i, 6, giant);
+ assertTrue("a hot star's zone must be wider than a cool one's at every rank ("
+ + cool + " vs " + hot + ")", hot > cool);
}
+ assertTrue("a cool dwarf's system is compact",
+ PlanetDerivation.outerOrbit(dwarf) < PlanetDerivation.outerOrbit(giant));
}
// ─── D6: the availability filter runs BEFORE the draw ──────────────────────
diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetRealizationTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetRealizationTest.java
index 5531c6dff..a89f3c3e8 100644
--- a/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetRealizationTest.java
+++ b/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetRealizationTest.java
@@ -41,37 +41,77 @@ public void resetSeams() {
UniverseRegistry.setStarLookup(null);
}
- /**
- * A dense, void-free galaxy so a small sweep is guaranteed to find systems. The spacing is
- * deliberately tiny: a system's anchor is seated in the MIDDLE BAND of its super-cell, so at the
- * production spacing of 512 the nearest anchor is hundreds of cells from the origin and a
- * unit-test-sized sweep finds an empty universe.
- */
+ /** The shipped spacing: a system sampled here is a system the game ships. */
+ private static final int SPACING = GalaxyGenConfig.DEFAULT_MIN_SPACING;
+
+ /** A dense, void-free galaxy, so the first super-cell probed holds a system. */
private static UniverseRegistry registryWithProceduralGalaxy() {
UniverseRegistry reg = new UniverseRegistry();
UniverseRegistry.setGenerator(new ClusteredGalaxyGenerator(
- new GalaxyGenConfig(0.9d, 4, 8, 0.0d, null)));
+ new GalaxyGenConfig(1.0d, SPACING, 8, 0.0d, null)));
reg.bindWorldSeed(SEED);
return reg;
}
- /** The first cell in a small sweep holding a body a ship could land on but that has no world. */
+ /**
+ * The seat of a system near the origin.
+ *
+ * Probed one SUPER-CELL at a time, never cell by cell: a star's seat is one cell in a cube of
+ * tens of millions, so a sweep of adjacent cells finds nothing however full the galaxy is. The
+ * partition is the thing to walk, and it is what the generator itself walks.
+ */
+ private static GalacticCoord systemAnchor(UniverseRegistry reg) {
+ for (long i = 0; i <= 8; i++) {
+ Optional anchor = reg.anchorForCell(
+ GalacticCoord.ofSectorLocal(i * SPACING, 0L, 0L, 0L, 0L, 0L));
+ if (anchor.isPresent()) {
+ return anchor.get();
+ }
+ }
+ return null;
+ }
+
+ /** The cell of the first body in that system a ship could land on but that has no world yet. */
private static GalacticCoord findLandableCell(UniverseRegistry reg) {
- for (long x = -8; x <= 8; x++) {
- for (long y = -8; y <= 8; y++) {
- for (long z = -8; z <= 8; z++) {
- GalacticCoord cell = GalacticCoord.ofSectorLocal(x, y, z, 0L, 0L, 0L);
- for (SystemBody b : reg.bodiesAt(cell)) {
- if (b.kind().canDescend() && b.dimId() == Constants.INVALID_PLANET) {
- return cell;
- }
- }
- }
+ GalacticCoord anchor = systemAnchor(reg);
+ if (anchor == null) {
+ return null;
+ }
+ for (SystemBody b : reg.systemBodiesAt(anchor)) {
+ if (b.kind().canDescend() && b.dimId() == Constants.INVALID_PLANET) {
+ return b.name();
}
}
return null;
}
+ /**
+ * The first {@code (parent, moon)} pair found in a sweep of nearby systems, or {@code null}s.
+ *
+ * Several systems, because moons are a draw: most bodies have none and a giant has several, so
+ * one system is not guaranteed to hold a pair and a fixture that assumed it would be flaky for a
+ * reason that has nothing to do with what it tests.
+ */
+ private static SystemBody[] findPlanetWithMoon(UniverseRegistry reg) {
+ for (long i = 0; i <= 8; i++) {
+ Optional seat = reg.anchorForCell(
+ GalacticCoord.ofSectorLocal(i * SPACING, 0L, 0L, 0L, 0L, 0L));
+ if (!seat.isPresent()) {
+ continue;
+ }
+ SystemBody parent = null;
+ for (SystemBody b : reg.systemBodiesAt(seat.get())) {
+ if (b.kind() != SystemBodyKind.MOON && b.kind().canDescend()) {
+ parent = b;
+ } else if (b.kind() == SystemBodyKind.MOON && parent != null
+ && b.name().sameCell(parent.name())) {
+ return new SystemBody[] {parent, b};
+ }
+ }
+ }
+ return new SystemBody[] {null, null};
+ }
+
@Test
public void theProceduralGalaxyOffersLandableBodiesThatHaveNoWorldYet() {
// The precondition of everything below, and the defect the whole batch exists to fix: the
@@ -99,25 +139,9 @@ public void theProceduralGalaxyOffersLandableBodiesThatHaveNoWorldYet() {
@Test
public void aMoonCarriesItsOwnDistanceFromItsParentSeparatelyFromItsParentsFromTheStar() {
UniverseRegistry reg = registryWithProceduralGalaxy();
- SystemBody moon = null;
- SystemBody itsParent = null;
- outer:
- for (long x = -8; x <= 8 && moon == null; x++) {
- for (long y = -8; y <= 8; y++) {
- for (long z = -8; z <= 8; z++) {
- SystemBody parent = null;
- for (SystemBody b : reg.bodiesAt(GalacticCoord.ofSectorLocal(x, y, z, 0L, 0L, 0L))) {
- if (parent == null && b.kind() != SystemBodyKind.MOON && b.kind().canDescend()) {
- parent = b;
- } else if (b.kind() == SystemBodyKind.MOON && parent != null) {
- moon = b;
- itsParent = parent;
- break outer;
- }
- }
- }
- }
- }
+ SystemBody[] pair = findPlanetWithMoon(reg);
+ SystemBody itsParent = pair[0];
+ SystemBody moon = pair[1];
assertNotNull("the procedural galaxy must produce a moon to test with", moon);
assertNotNull(itsParent);
@@ -146,25 +170,9 @@ public void aMoonCarriesItsOwnDistanceFromItsParentSeparatelyFromItsParentsFromT
@Test
public void aProceduralPlanetOrbitsItsStarAndItsMoonsTravelWithIt() {
UniverseRegistry reg = registryWithProceduralGalaxy();
- SystemBody planet = null;
- SystemBody moon = null;
- outer:
- for (long x = -8; x <= 8; x++) {
- for (long y = -8; y <= 8; y++) {
- for (long z = -8; z <= 8; z++) {
- SystemBody candidate = null;
- for (SystemBody b : reg.bodiesAt(GalacticCoord.ofSectorLocal(x, y, z, 0L, 0L, 0L))) {
- if (candidate == null && b.kind() != SystemBodyKind.MOON && b.kind().canDescend()) {
- candidate = b;
- } else if (b.kind() == SystemBodyKind.MOON && candidate != null) {
- planet = candidate;
- moon = b;
- break outer;
- }
- }
- }
- }
- }
+ SystemBody[] pair = findPlanetWithMoon(reg);
+ SystemBody planet = pair[0];
+ SystemBody moon = pair[1];
assertNotNull("the procedural galaxy must produce a planet with a moon", planet);
assertNotNull(moon);
@@ -266,7 +274,7 @@ public void aPinnedSystemsStarSurvivesAChangeOfGenerator() {
// A pack edit: a different spacing, a different density, a whole different galaxy.
UniverseRegistry.setGenerator(new ClusteredGalaxyGenerator(
- new GalaxyGenConfig(0.2d, 32, 4, 0.5d, null)));
+ new GalaxyGenConfig(0.2d, SPACING / 2, 4, 0.5d, null)));
Optional after = reg.starAt(cell);
assertTrue(after.isPresent());
diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/StellarHierarchyTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/StellarHierarchyTest.java
new file mode 100644
index 000000000..9a1de99c9
--- /dev/null
+++ b/src/test/java/zmaster587/advancedRocketry/test/unit/StellarHierarchyTest.java
@@ -0,0 +1,188 @@
+package zmaster587.advancedRocketry.test.unit;
+
+import org.junit.Test;
+
+import net.minecraft.nbt.NBTTagCompound;
+
+import zmaster587.advancedRocketry.api.dimension.solar.StellarBody;
+import zmaster587.advancedRocketry.util.AstronomicalBodyHelper;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * What the star model can EXPRESS: a single star, a close pair, a wide pair, and a hierarchy three
+ * deep — each one placed, lit and round-tripped without a special case for its shape.
+ *
+ * The model could nest companions in storage long before it could mean anything by them. A
+ * companion was given its primary's id, so no {@code starId} could address it and it could own no
+ * world; its separation was an angle, so nothing could say where it was; and every companion was lit
+ * as though it stood exactly where its primary does. These pin the shape that replaced that, never
+ * the balance numbers: what is asserted is that a distance is a distance, that identity is per star,
+ * and that light falls off with the separation it is given.
+ */
+public class StellarHierarchyTest {
+
+ private static StellarBody star(String name, float size) {
+ StellarBody s = new StellarBody();
+ s.setName(name);
+ s.setSize(size);
+ s.setTemperature(100);
+ return s;
+ }
+
+ // ─── identity ──────────────────────────────────────────────────────────────
+
+ @Test
+ public void bindingACompanionLeavesItsIdentityAlone() {
+ // The whole reason a companion could own nothing: it was handed its primary's id, and a
+ // planet binds to its star by that number. Minting one is the registry's job; binding is not
+ // allowed to overwrite what the registry handed out.
+ StellarBody primary = star("A", 1f);
+ primary.setId(7);
+ StellarBody companion = star("B", 0.5f);
+ companion.setId(19);
+
+ primary.addSubStar(companion);
+
+ assertEquals("the primary keeps its id", 7, primary.getId());
+ assertEquals("and so does the companion", 19, companion.getId());
+ assertSame("which now knows what it orbits", primary, companion.getParentStar());
+ }
+
+ @Test
+ public void aCompanionAnswersForItsOwnWorldsAndNotItsPrimarys() {
+ StellarBody primary = star("A", 1f);
+ StellarBody companion = star("B", 0.5f);
+ primary.addSubStar(companion);
+
+ assertEquals("a companion with no worlds holds none", 0, companion.getNumPlanets());
+ assertEquals("and the primary's count is its own", 0, primary.getNumPlanets());
+ }
+
+ // ─── placement ─────────────────────────────────────────────────────────────
+
+ @Test
+ public void aCompanionStandsWhereItsOrbitSaysAndAPrimaryAtTheOrigin() {
+ StellarBody primary = star("A", 1f);
+ StellarBody companion = star("B", 0.5f);
+ companion.setOrbitalDistance(2_000); // 20 AU
+ companion.setBaseTheta(0d);
+ primary.addSubStar(companion);
+
+ assertEquals("a primary defines its system's origin", 0d,
+ primary.offsetFromSystemAu()[0], 0d);
+ assertEquals(20d, companion.offsetFromSystemAu()[0], 1e-9);
+ assertEquals(20d, companion.separationAuFrom(primary), 1e-9);
+ assertEquals("separation is symmetric", 20d, primary.separationAuFrom(companion), 1e-9);
+ }
+
+ @Test
+ public void aThreeStarHierarchyComposesRatherThanSpecialCases() {
+ // B orbits A at 20 AU; C orbits B at 5 AU on the same bearing. C is 25 AU from A, and the
+ // arithmetic that says so is the same one a pair uses.
+ StellarBody a = star("A", 1f);
+ StellarBody b = star("B", 0.8f);
+ StellarBody c = star("C", 0.3f);
+ b.setOrbitalDistance(2_000);
+ b.setBaseTheta(0d);
+ c.setOrbitalDistance(500);
+ c.setBaseTheta(0d);
+ a.addSubStar(b);
+ b.addSubStar(c);
+
+ assertEquals(25d, c.separationAuFrom(a), 1e-9);
+ assertEquals(5d, c.separationAuFrom(b), 1e-9);
+ assertEquals("every star of the system is reached from any of them",
+ 3, AstronomicalBodyHelper.systemOf(c).size());
+ }
+
+ @Test
+ public void unstatedCompanionPhasesAreSpreadRatherThanStacked() {
+ // Two companions on the same bearing would be one object as far as every consumer is
+ // concerned. Nothing here says WHICH angles they get — only that binding gives them
+ // different ones when nobody has said.
+ StellarBody primary = star("A", 1f);
+ StellarBody first = star("B", 0.5f);
+ StellarBody second = star("C", 0.5f);
+ primary.addSubStar(first);
+ primary.addSubStar(second);
+
+ assertNotEquals(first.getBaseTheta(), second.getBaseTheta(), 1e-9);
+ }
+
+ @Test
+ public void anAuthoredPhaseSurvivesBinding() {
+ StellarBody primary = star("A", 1f);
+ StellarBody companion = star("B", 0.5f);
+ companion.setBaseTheta(1.25d);
+ primary.addSubStar(companion);
+
+ assertEquals(1.25d, companion.getBaseTheta(), 0d);
+ }
+
+ // ─── the sky ───────────────────────────────────────────────────────────────
+
+ @Test
+ public void apparentSeparationIsARealAngleFromARealDistance() {
+ StellarBody primary = star("A", 1f);
+ StellarBody close = star("B", 0.5f);
+ close.setOrbitalDistance(5); // 0.05 AU
+ primary.addSubStar(close);
+
+ StellarBody other = star("C", 1f);
+ StellarBody wide = star("D", 0.5f);
+ wide.setOrbitalDistance(2_000); // 20 AU
+ other.addSubStar(wide);
+
+ float closeAngle = close.apparentSeparationDegrees(100);
+ float wideAngle = wide.apparentSeparationDegrees(100);
+
+ assertTrue("a close pair reads as two suns almost together, saw " + closeAngle,
+ closeAngle > 0f && closeAngle < 10f);
+ assertTrue("a wide companion is somewhere else in the sky entirely, saw " + wideAngle,
+ wideAngle > 60f);
+ assertEquals("a star nobody orbits has no separation from itself", 0f,
+ primary.apparentSeparationDegrees(100), 0f);
+ }
+
+ // ─── round trip ────────────────────────────────────────────────────────────
+
+ @Test
+ public void aHierarchyRoundTripsThroughNBTWithItsGeometry() {
+ StellarBody a = star("A", 1f);
+ a.setId(3);
+ StellarBody b = star("B", 0.8f);
+ b.setId(4);
+ b.setOrbitalDistance(2_000);
+ b.setBaseTheta(0.75d);
+ StellarBody c = star("C", 0.3f);
+ c.setId(5);
+ c.setOrbitalDistance(500);
+ c.setBaseTheta(2.5d);
+ a.addSubStar(b);
+ b.addSubStar(c);
+
+ NBTTagCompound nbt = new NBTTagCompound();
+ a.writeToNBT(nbt);
+ StellarBody read = new StellarBody();
+ read.readFromNBT(nbt);
+
+ assertEquals(1, read.getSubStars().size());
+ StellarBody readB = read.getSubStars().get(0);
+ assertEquals("a companion's own id survives", 4, readB.getId());
+ assertEquals(2_000, readB.getOrbitalDistance());
+ assertEquals(0.75d, readB.getBaseTheta(), 1e-9);
+ assertSame("and it still knows what it orbits", read, readB.getParentStar());
+
+ assertEquals(1, readB.getSubStars().size());
+ StellarBody readC = readB.getSubStars().get(0);
+ assertEquals(5, readC.getId());
+ assertEquals(500, readC.getOrbitalDistance());
+ assertEquals(2.5d, readC.getBaseTheta(), 1e-9);
+ assertEquals("the geometry survives to the third star", c.separationAuFrom(a),
+ readC.separationAuFrom(read), 1e-9);
+ }
+}
diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/SystemRetinueTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/SystemRetinueTest.java
index 7064dfb2b..cda845d35 100644
--- a/src/test/java/zmaster587/advancedRocketry/test/unit/SystemRetinueTest.java
+++ b/src/test/java/zmaster587/advancedRocketry/test/unit/SystemRetinueTest.java
@@ -12,9 +12,11 @@
import java.util.Optional;
import java.util.Set;
+import zmaster587.advancedRocketry.api.dimension.solar.StellarBody;
import zmaster587.advancedRocketry.space.GalacticCoord;
import zmaster587.advancedRocketry.universe.ClusteredGalaxyGenerator;
import zmaster587.advancedRocketry.universe.GalaxyGenConfig;
+import zmaster587.advancedRocketry.universe.PlanetDerivation;
import zmaster587.advancedRocketry.universe.PlanetTypes;
import zmaster587.advancedRocketry.universe.SystemBody;
import zmaster587.advancedRocketry.universe.SystemBodyKind;
@@ -47,11 +49,17 @@ private static GalacticCoord cell(long sx, long sy, long sz) {
return GalacticCoord.ofSectorLocal(sx, sy, sz, 0L, 0L, 0L);
}
+ /** The shipped spacing: a system laid out here is the system the game ships. */
+ private static final int SPACING = GalaxyGenConfig.DEFAULT_MIN_SPACING;
+
/**
- * A galaxy dense enough to sample and roomy enough to lay a system out in. The spacing has to leave
- * a real neighbourhood: a body's cell radius is bounded by {@code 3s/8}, so at {@code s=4} a system
- * has a single ring of cells to put a dozen bodies in.
+ * A spacing tight enough that a system's own clear space, not its star's zone, decides how far its
+ * outermost body may sit. It is where the collision risk bites, because every body is squeezed into
+ * far fewer distinct cells.
*/
+ private static final int CRAMPED_SPACING = 1_000;
+
+ /** A galaxy dense enough to sample: every cube occupied, so a small sweep finds many systems. */
private static ClusteredGalaxyGenerator gen(int minSpacing) {
return new ClusteredGalaxyGenerator(new GalaxyGenConfig(0.9d, minSpacing, 8, 0.0d, null));
}
@@ -82,7 +90,7 @@ public void noTwoRealBodiesOfOneSystemShareACell() {
// Measured the way SystemContent.auditOneRealBodyPerCell measures it — moons exempt, because a
// moon lives in its parent's cell by construction — so the generator and the audit cannot
// disagree silently about what the invariant says.
- int minSpacing = 64;
+ int minSpacing = SPACING;
ClusteredGalaxyGenerator g = gen(minSpacing);
int checked = 0;
for (GalacticCoord anchor : anchors(g, SEED, minSpacing, 3)) {
@@ -107,7 +115,7 @@ public void theInvariantHoldsEvenWhenTheNeighbourhoodIsCrampedForRoom() {
// The collision risk grows with the square of the body count, so the tightest spacing that still
// has more than one cell is where it bites. A cramped system is allowed to hold FEWER bodies;
// it is not allowed to hold two in one cell.
- int minSpacing = 8;
+ int minSpacing = CRAMPED_SPACING;
ClusteredGalaxyGenerator g = gen(minSpacing);
int checked = 0;
for (GalacticCoord anchor : anchors(g, SEED, minSpacing, 3)) {
@@ -124,6 +132,66 @@ public void theInvariantHoldsEvenWhenTheNeighbourhoodIsCrampedForRoom() {
assertTrue(checked > 5);
}
+ // ─── what a system loses when it does not fit ──────────────────────────────
+
+ @Test
+ public void atTheShippedScaleNoSystemLosesABodyAtAll() {
+ // The bound is a GUARD, not a mechanic anybody meets. Measured 2026-08-14: the widest zone
+ // any shipped star archetype can draw is 569 AU against a clear space of 5 000 — a factor of
+ // nearly nine. If this ever goes red, either the star table gained something far hotter or
+ // the spacing was cut by two orders, and both are worth knowing about deliberately.
+ ClusteredGalaxyGenerator g = gen(SPACING);
+ int checked = 0;
+ for (GalacticCoord anchor : anchors(g, SEED, SPACING, 2)) {
+ int wanted = ClusteredGalaxyGenerator.retinueSize(SEED, anchor);
+ int got = 0;
+ for (SystemBody b : g.bodiesFor(SEED, anchor)) {
+ if (b.kind() == SystemBodyKind.PLANET || b.kind() == SystemBodyKind.GAS_GIANT) {
+ got++;
+ }
+ }
+ assertEquals("system " + anchor.cellKey() + " lost a body it had room for", wanted, got);
+ checked++;
+ }
+ assertTrue(checked > 10);
+ }
+
+ @Test
+ public void aCrampedSystemDropsBodiesAndNeverMovesTheOnesItKeeps() {
+ // The distinction the whole placement seam exists for. A system squeezed by its neighbours
+ // holds FEWER worlds; it does not hold the same worlds at distances their own climate,
+ // insolation and year do not describe. So every body a cramped system keeps must stand at an
+ // orbit the star's own zone drew, unchanged — never at one scaled to fit the room.
+ ClusteredGalaxyGenerator g = gen(CRAMPED_SPACING);
+ int droppedSomewhere = 0;
+ int checked = 0;
+ for (GalacticCoord anchor : anchors(g, SEED, CRAMPED_SPACING, 2)) {
+ StellarBody star = g.systemAt(SEED, anchor).get().star();
+ int count = ClusteredGalaxyGenerator.retinueSize(SEED, anchor);
+ Set drawn = new HashSet<>();
+ for (int i = 0; i < count; i++) {
+ drawn.add(PlanetDerivation.orbitalDistanceOf(SEED, anchor, i, count, star));
+ }
+ int kept = 0;
+ for (SystemBody b : g.bodiesFor(SEED, anchor)) {
+ if (b.kind() != SystemBodyKind.PLANET && b.kind() != SystemBodyKind.GAS_GIANT) {
+ continue;
+ }
+ kept++;
+ assertTrue("a kept body stands at orbit " + b.orbitalDistance()
+ + ", which its star never drew — it was moved to fit",
+ drawn.contains(b.orbitalDistance()));
+ }
+ if (kept < count) {
+ droppedSomewhere++;
+ }
+ checked++;
+ }
+ assertTrue(checked > 10);
+ assertTrue("the cramped fixture must actually be cramped, or this proves nothing",
+ droppedSomewhere > 0);
+ }
+
// ─── E1: a long-tailed body count ──────────────────────────────────────────
@Test
@@ -169,7 +237,7 @@ public void theRetinueSizeIsDeterministic() {
public void everySystemEndsInABelt() {
// Load-bearing beyond this task: drifting out of jump range is only survivable because every
// system has something to mine without landing. "Usually" would be a soft-lock.
- int minSpacing = 64;
+ int minSpacing = SPACING;
ClusteredGalaxyGenerator g = gen(minSpacing);
int checked = 0;
for (GalacticCoord anchor : anchors(g, SEED, minSpacing, 3)) {
@@ -198,7 +266,7 @@ public void anInnerBeltAppearsOnlyWhereAGiantClearedOne() {
// A belt is material a giant's resonances stopped from accreting, so a second belt inside the
// system implies a giant. The converse is not asserted: a giant near the edge has no room for a
// gap inside it, and a cramped neighbourhood may have no free cell to put one in.
- int minSpacing = 64;
+ int minSpacing = SPACING;
ClusteredGalaxyGenerator g = gen(minSpacing);
int systemsWithInnerBelt = 0;
for (GalacticCoord anchor : anchors(g, SEED, minSpacing, 3)) {
@@ -227,7 +295,7 @@ public void anInnerBeltAppearsOnlyWhereAGiantClearedOne() {
public void moonsExistAndLiveInsideTheirParentsCell() {
// Without moons the whole outer system is look-only: nothing out there is landable, because the
// bodies big enough to be out there are the ones with no surface.
- int minSpacing = 64;
+ int minSpacing = SPACING;
ClusteredGalaxyGenerator g = gen(minSpacing);
int moons = 0;
int checked = 0;
@@ -258,7 +326,7 @@ public void moonsExistAndLiveInsideTheirParentsCell() {
public void aMoonIsSomewhereElseInsideItsCellThanItsParent() {
// A moon that never moved inside the cell would be at the cell centre, i.e. exactly where the
// planet is — one address, two bodies, and a descent that cannot say which it came for.
- int minSpacing = 64;
+ int minSpacing = SPACING;
ClusteredGalaxyGenerator g = gen(minSpacing);
boolean checkedAny = false;
for (GalacticCoord anchor : anchors(g, SEED, minSpacing, 3)) {
@@ -283,7 +351,7 @@ public void aSystemsCellLayoutFollowsItsOrbits() {
// The cell radius is derived from the orbit, so a body further from its star is further from the
// anchor cell. If the two ever came apart, the map would show a system laid out differently from
// the one the physics describes.
- int minSpacing = 128;
+ int minSpacing = SPACING;
ClusteredGalaxyGenerator g = gen(minSpacing);
int checked = 0;
for (GalacticCoord anchor : anchors(g, SEED, minSpacing, 2)) {
@@ -317,7 +385,7 @@ public void aSystemsCellLayoutFollowsItsOrbits() {
@Test
public void theWholeRetinueIsDeterministic() {
- int minSpacing = 64;
+ int minSpacing = SPACING;
ClusteredGalaxyGenerator g = gen(minSpacing);
int checked = 0;
for (GalacticCoord anchor : anchors(g, SEED, minSpacing, 2)) {
diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/UniverseRegistryTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/UniverseRegistryTest.java
index 5ac4ddd5d..611927cb3 100644
--- a/src/test/java/zmaster587/advancedRocketry/test/unit/UniverseRegistryTest.java
+++ b/src/test/java/zmaster587/advancedRocketry/test/unit/UniverseRegistryTest.java
@@ -41,6 +41,13 @@
*/
public class UniverseRegistryTest {
+ /**
+ * A sector far enough away to be a DIFFERENT system's territory. An anchor owns every cell of its
+ * super-cell, so "elsewhere" has to be stated in super-cells; a literal few thousand sectors is
+ * the same neighbourhood, and a fixture using one proves nothing about attribution.
+ */
+ private static final long ANOTHER_SUPER_CELL = 2L * GalaxyGenConfig.DEFAULT_MIN_SPACING;
+
private static StellarBody star(int id) {
StellarBody s = new StellarBody();
s.setId(id);
@@ -263,7 +270,7 @@ public void systemForCoordPrefersStoredOverGenerator() {
// A cell in a DIFFERENT super-cell falls through to the generator.
Optional farAway = reg.systemForCoord(
- GalacticCoord.ofSectorLocal(4_000, 4_000, 4_000, 0, 0, 0));
+ GalacticCoord.ofSectorLocal(ANOTHER_SUPER_CELL, ANOTHER_SUPER_CELL, ANOTHER_SUPER_CELL, 0, 0, 0));
assertTrue(farAway.isPresent());
assertEquals(777, farAway.get().starId());
}
@@ -557,7 +564,7 @@ public void aRecycledDimensionIdDoesNotInheritTheOldBodysName() {
UniverseRegistry reg = new UniverseRegistry();
reg.place(GalacticCoord.ORIGIN, 6001);
- reg.place(GalacticCoord.ofSectorLocal(4000, 0, 0, 0, 0, 0), 6002);
+ reg.place(GalacticCoord.ofSectorLocal(ANOTHER_SUPER_CELL, 0, 0, 0, 0, 0), 6002);
DimensionProperties original = bodyOfStar(sol, 6100, 150, 0.3);
Optional firstName = reg.coordForPlanet(original);
@@ -575,7 +582,7 @@ public void aRecycledDimensionIdDoesNotInheritTheOldBodysName() {
assertTrue("the new body's name must lie in ITS system's neighbourhood",
reg.anchorForCell(secondName.get()).isPresent());
assertEquals("...which is its own star's anchor",
- GalacticCoord.ofSectorLocal(4000, 0, 0, 0, 0, 0),
+ GalacticCoord.ofSectorLocal(ANOTHER_SUPER_CELL, 0, 0, 0, 0, 0),
reg.anchorForCell(secondName.get()).get());
}
@@ -615,7 +622,7 @@ public void aRecordedNameThatLeftItsSystemsBoxIsReDerivedRatherThanServed() {
// The star is re-placed a long way off — an XML edit, a re-authored layout. The recorded name
// is now nowhere near the system it belongs to.
- GalacticCoord newAnchor = GalacticCoord.ofSectorLocal(9000, 0, 0, 0, 0, 0);
+ GalacticCoord newAnchor = GalacticCoord.ofSectorLocal(3L * ANOTHER_SUPER_CELL, 0, 0, 0, 0, 0);
reg.place(newAnchor, 6004);
Optional served = reg.coordForPlanet(body);
@@ -734,7 +741,7 @@ public void theSkyFeedUnionsTheSystemWithTheObserversOwnCell() {
public void interstellarVoidIsFedNothing() {
UniverseRegistry reg = new UniverseRegistry();
reg.place(GalacticCoord.ORIGIN, 6008);
- GalacticCoord farAway = GalacticCoord.ofSectorLocal(500_000, 0, 0, 0, 0, 0);
+ GalacticCoord farAway = GalacticCoord.ofSectorLocal(ANOTHER_SUPER_CELL, 0, 0, 0, 0, 0);
assertFalse("the fixture's cell must belong to no system",
reg.anchorForCell(farAway).isPresent());
assertTrue("the space between stars is black", reg.skyBodiesAt(farAway).isEmpty());
From 663d46142c47e984e275e718e9a28bb80ec737ea Mon Sep 17 00:00:00 2001
From: StannisMod
Date: Fri, 14 Aug 2026 15:52:23 +0300
Subject: [PATCH 13/42] docs: rewrite the planetDefs reference around its
semantics
- units, the chart scale, and the file's read/rewrite lifecycle
- what minSpacing bounds and what it does not
- companion orbits, and changing a generator parameter mid-save
- a combinations section: what wins when two fields disagree
- retarget the texture pointer at this repository
---
README.md | 4 +-
docs/README_PLANETDEFS.md | 1840 +++++++++----------------------------
2 files changed, 457 insertions(+), 1387 deletions(-)
diff --git a/README.md b/README.md
index 9e5138c97..ac3fa9e5c 100644
--- a/README.md
+++ b/README.md
@@ -104,9 +104,11 @@ It is not released, and while it is in development:
The mod adds its own dimensions, worldgen and ore-processing chain, so it takes up room in a pack. Each major
system has a config switch, including worldgen, planet weather and the whole 3.0.0 space subsystem.
-Planets, stars and ores are configured in XML, with references and templates in [`docs/`](docs/):
+Planets, stars, the procedural galaxy and ores are configured in XML, with references and templates
+in [`docs/`](docs/):
- planetDefs — [reference](docs/README_PLANETDEFS.md) · [template](docs/TEMPLATE_planetdefs.xml)
+ — every element and attribute, its unit, and what wins when two of them disagree
- oreConfig — [reference](docs/README_ORECONFIG.md) · [template](docs/TEMPLATE_oreconfig.xml)
Coming from an older 2.x build: commands moved into subcommands, so **command scripts and quest-book command
diff --git a/docs/README_PLANETDEFS.md b/docs/README_PLANETDEFS.md
index f01b10e41..5afda1ad1 100644
--- a/docs/README_PLANETDEFS.md
+++ b/docs/README_PLANETDEFS.md
@@ -1,297 +1,282 @@
-# Advanced Rocketry `planetDefs.xml` Reference
+# `planetDefs.xml` — the universe catalogue
-This document explains how `planetDefs.xml` is structured and which tags and attributes are supported.
+Everything Advanced Rocketry lets a pack author say about stars, planets and the procedural galaxy.
+This file documents the format exhaustively: every element, every attribute, its unit, its default,
+what happens when it is missing or malformed, and — the part that costs people days — what happens
+when two of them are stated together.
-Place the file at:
-
-`config/advancedRocketry/planetDefs.xml`
-
-
-**Template** found here [`TEMPLATE_planetdefs.xml`](TEMPLATE_planetdefs.xml)
+---
+## 1. Where the file is, and when it is read and written
-This reference tries to document all fields that are loaded from planetdefs.
+| | path |
+|---|---|
+| **template** (what a pack ships) | `config/advancedRocketry/planetDefs.xml` |
+| **live copy** (what the game reads) | `/advRocketry/planetDefs.xml` |
----
+1. On world load the game looks for the **live copy**. If it is absent, the **template** is copied
+ there and that copy is loaded.
+2. The config option `resetPlanetsFromXML` (section `Planet` of `advancedRocketry.cfg`) forces the
+ copy to happen again, overwriting the live copy from the template. That is the only supported way
+ to push a template edit into an existing world. It **resets itself to `false` after one load**
+ unless `ResetOnlyOnce` is set to `false`, which is what a pack developer wants while iterating.
+3. **On every world save the live copy is REWRITTEN** from the in-memory model.
-## 1. Purpose
+Consequence of (3), and it surprises everyone exactly once:
-`planetDefs.xml` lets you define stars, planets, moons, and planet-specific configuration manually.
+- **Comments are lost.** The writer builds a new document; nothing in the file survives that the
+ reader did not turn into model state.
+- **Unknown elements and attributes are lost**, because they were never read (see §2).
+- **`numPlanets` / `numGasGiants` are written back as `0`.** Random planets are generated once, at
+ first load, and become ordinary `` entries. They are not regenerated on later loads.
+- **A companion star loses its `name`.** The writer does not emit `name` for a nested ``; it is
+ regenerated as `-`.
-Place the file as:
+So: edit the **template**, not the live copy, and keep the template under version control.
-`config/advancedRocketry/planetDefs.xml`
+---
-This document is intended as a reference-first replacement for the old XML readme.
+## 2. Parsing rules that apply everywhere
+
+- **The root element must be ``.** No root, or unparseable XML → the world fails to load with
+ a crash report naming the file. That is deliberate: a silently half-loaded catalogue is worse.
+- **Anything unrecognised is ignored silently.** A misspelled element or attribute produces no
+ warning at all. Check your spelling; the game will not.
+- **A malformed `` is skipped, not fatal.** The rest of the catalogue loads and the reason is
+ printed to the log. The guard sits at the top-level planet, so a malformed MOON takes its parent
+ planet and that planet's other moons down with it — not the whole file.
+- **A malformed number inside a recognised element is warned about and the field keeps its default**,
+ unless stated otherwise below.
+- **Booleans are `true` / `false`**, case-insensitive. Anything else reads as `false`.
+- **Element ORDER never matters.** Attribute order never matters.
+- **Colours** accept either three comma-separated floats in `0..1` (`0.5,0.5,1.0`) or one
+ `0x`-prefixed hex triple (`0xRRGGBB`). Anything else warns and keeps the default.
---
-## 2. Basic File Structure
+## 3. Units — read this before anything else
+
+| quantity | unit | notes |
+|---|---|---|
+| **orbital distance** | `100` = 1 AU | Same unit for a planet round its star and for a companion star round its primary. |
+| **orbital angle** | DEGREES | `orbitalTheta` on a planet and on a companion alike. |
+| **orbital inclination** | DEGREES | `orbitalPhi`. Tilts the orbit; it does not enlarge it. |
+| **star temperature** | `100` = Sol | Multiply by 58 for Kelvin. |
+| **star size** | solar radii | `1.0` = Sol. |
+| **planet mass** | Earth masses | |
+| **planet radius** | Earth radii | |
+| **surface gravity** | percent of Earth's | `100` = 1 g. Clamped to `0..400`. |
+| **atmosphere density** | `100` = 1 atm | Clamped to `0..1600`. |
+| **planet temperature** | KELVIN | Computed, not authored — see `avgTemperature` in §7. |
+| **rotational period** | ticks | `24000` = one Minecraft day. Must be `> 0`. |
+| **star map position** | arbitrary map units | `x` / `y` on ``; affects the star-selector GUI only. |
+| **galactic anchor** | cell indices | `"sectorX,sectorY,sectorZ"`. One cell is 4 000 000 blocks. |
+
+**The chart scale.** One orbital-distance unit is **5 983 914 blocks**, i.e. one AU is
+149 597 870 700 m at 250 m per block. This is the one law that turns an orbit into a place, and it is
+the same for authored and procedural systems. Every derived number — insolation, equilibrium
+temperature, orbital period, flight time — comes from the orbital distance, so a body's stated
+distance and where a ship actually finds it are the same statement.
-### Root structure
+---
-The root element is:
+## 4. Document structure
```xml
-```
+
+
+
-A galaxy contains one or more `` entries.
+ …
-A `` can contain:
-- one or more `` entries
-- one or more nested `` entries (sub-stars / multi-star systems)
-
-A `` can contain:
-- property tags such as ``, ``, etc.
-- nested `` entries, which are treated as moons / child bodies
-
-### 2.1 Basic examples
-
-```xml
-
-
-
- ...
-
-
-
-```
-```xml
-
-
-
- ...
-
-
+
+
+
+ …
+
+
```
----
-
-## 3. Rules and Conventions
-
-### 3.1 Nesting rules
-
-- A `` inside a `` defines a planet orbiting that star.
-- A `` inside another `` defines a moon / child body.
-- A `` inside another `` defines a sub-star.
-
-### 3.2 Parser behavior
-
-The loader is tolerant in some places and strict in others.
-
-Examples:
-- Some numeric fields are clamped
-- Some invalid values are ignored with warnings
-- Some fields use direct `Integer.parseInt(...)` without a `try/catch`; malformed values there may break loading
-
-### 3.3 Scope of this document
-
-This document intentionally excludes fields that are only exported/written but not meaningfully loaded from XML.
-
-Example:
-- `avgTemperature` is written by XML export code, but it is not a meaningful author-controlled XML input because temperature is recomputed after load
+Only ``, `` and `` are recognised directly under ``.
---
-## 4. Star Reference
-
-### 4.1 `` overview
-
-Defines a star system entry.
-
-A top-level `` may contain:
-- planets
-- sub-stars
-
-A nested `` is treated as a sub-star.
-
-### 4.2 `` attributes
-
-#### `name`
-Display name of the star.
-
-```xml
-
-```
-
-#### `temp`
-Star temperature integer.
-
-```xml
-
-```
-
-Notes:
-- Parsed as an integer
-- If malformed, the loader falls back to `100` for sub-star parsing
-
-#### `x`
-Galaxy map X position.
-
-```xml
-
-```
-
-#### `y`
-Galaxy map Y position.
-
-```xml
-
-```
-
-Notes:
-- Internally this is used as the star's Z/map Y position
-
-#### `size`
-Star size multiplier.
-
-```xml
-
-```
-
-Notes:
-- Parsed as float
-
-#### `numPlanets`
-Maximum number of randomly generated planets for the star.
-
-```xml
-
-```
-
-#### `numGasGiants`
-Maximum number of randomly generated gas giants for the star.
+## 5. `` — the procedural galaxy
-```xml
-
-```
-
-Notes:
-- These values apply to random planet generation for the star
-- Manually defined `` entries can still be added regardless
-- For a fully manual system with no extra random planets, use `numPlanets="0"` and `numGasGiants="0"`
-
-#### `blackHole`
-Marks the star as a black hole.
-
-```xml
-
-```
+Present → procedural systems exist alongside the authored ones. Absent → the universe holds only what
+this file names.
-Accepted values:
-- `true`
-- `false`
+| attribute | unit | default | meaning |
+|---|---|---|---|
+| `density` | 0..1 | `0.35` | Chance that a given cube of space holds a system, before the void mask. Clamped; `NaN` reads as `0`. |
+| `minSpacing` | cells | `40018890` | Edge of the cube that holds **at most one** system, i.e. how far apart stars stand. The default is 4.23 light years. Floors at 1. |
+| `clusterScale` | super-cells | `16` | Resolution of the coarse field that separates populated space from void. Floors at 1. |
+| `voidFraction` | 0..1 | `0.6` | Fraction of space that is empty. `1.0` yields an empty galaxy. Clamped; `NaN` reads as `0`. |
-#### `diskAngle`
-Black hole disk angle / star disk angle.
+### `` — the archetype table
-```xml
-
-```
-
-Notes:
-- Parsed as float
-
-#### `separation`
-Only meaningful on nested `` entries.
-
-```xml
-
-```
+Zero `` children → the built-in table stands. One or more → they **replace** it entirely.
-Notes:
-- Parsed as float
-- Used for sub-star separation in multi-star systems
+| attribute | unit | default | meaning |
+|---|---|---|---|
+| `temp` | `100` = Sol | `100` | Temperature, and therefore colour. |
+| `minSize` / `maxSize` | solar radii | `0.8` / `1.2` | Size range. `minSize` floors at `0.1`; `maxSize` is raised to `minSize` if smaller. |
+| `weight` | relative | `1` | Draw weight. Floors at `1`. Weights are summed in 64-bit, so extreme values do not collapse the distribution. |
-### 4.3 Star examples
+### What `minSpacing` does and does not do
-#### Single star
+**It moves the STARS apart and nothing else.** It does not decide how large a system is: a system's
+extent follows its outermost orbit. Raising it does not inflate a single planet's orbit; lowering it
+does not squash one.
-```xml
-
- ...
-
-```
+What it does bound is **how much room a system has**. Every system is guaranteed a clear space of
+**10 000 AU** around its star — no two stars ever stand closer than that — and its named bodies
+(planets, moons, belts) stay inside **5 000 AU**, half of that clear space, which is what keeps two
+systems' neighbourhoods from overlapping.
-#### Binary star
+**A system that does not fit loses BODIES, never scale.** A world drawn past its system's room is
+dropped; the worlds that remain stand exactly where their own orbits say. This is not a corner
+anybody meets at the shipped numbers: the widest zone any built-in star archetype can draw is 569 AU
+against 5 000 AU of room, a factor of nearly nine. It becomes reachable only if `minSpacing` is cut by
+more than two orders of magnitude — below roughly 170 000 cells systems start losing outer worlds,
+and below about 8 cells only the star survives.
-```xml
-
-
- ...
-
-```
+### Changing a `` parameter mid-save is UNDEFINED
-#### Black hole
+`density`, `minSpacing`, `clusterScale` and `voidFraction` are inputs to a **derived** universe:
+nothing about a procedural system is stored, so changing any of them relocates every star, every
+planet and every generated name. **You get a different universe, and anything a player recorded about
+the old one — coordinates, memory crystals, a route — points at nothing.**
-```xml
-
- ...
-
-```
+There is no migration and there cannot be one: there is no old universe on disk to migrate. If you
+change these, start a new world.
---
-## 5 Planet Reference
-
-### 5.1 `` overview
-
-Defines a planet or moon.
-
-- A `` directly inside a `` is a planet.
-- A `` inside another `` is a moon / child body.
-- A `` could also be defined as ``
- - GasGiants:
- - Has no surface to land on
- - Intended for Gas Collection or cosmetics
-
-### 5.2 `` attributes
-
-#### `name`
-Planet name.
-
-```xml
-
-```
-
-#### `DIMID`
-Explicit dimension ID.
-
-```xml
-
-```
-Note:
-- Case sensitive, canonical "DIMID"
-#### `dimMapping`
-Makes a planet out of a non-native dimension.
-
-```xml
-
-```
-The presence of the attribute is what matters.
-
-Notes:
-- This should be paired with a correct `DIMID`
-- AR will not enforce weather non-native dimension (2.2.3+)
-- As with note above not all entries might apply to other mods dimensions.
-
-#### 5.3 `customIcon`
-Planet icon basename.
-
-```xml
-
-```
+## 6. `` — the type table for procedural worlds
+
+Present → **replaces** the built-in preset table wholesale. Absent → the built-in table stands.
+Types are what a procedurally derived world is classified as, after its physics is computed; they are
+never applied to an authored ``.
+
+```xml
+
+
+
+
+
+
+
+
+
+ advancedrocketry:moondark;10,minecraft:ice_flats;30
+ 0
+ minecraft:water
+ …
+
+```
+
+| attribute on `` | default | meaning |
+|---|---|---|
+| `name` | `""` | Identifier, shown in scans. |
+| `weight` | `10` | Draw weight among the types that ADMIT a given world. |
+| `gasGiant` | `false` | This type has no surface. |
+| `allowsOxygen` | `false` | Worlds of this type may roll breathable air. Only ~18 % of those that may, do. |
+| `tidallyLockable` | `true` | Worlds of this type can keep one face to their star. |
+
+| child | attributes | default range | meaning |
+|---|---|---|---|
+| `` | `min`, `max` | `0..1600` | Atmosphere density band this type admits. |
+| `` | `min`, `max` | `0..5000` | Kelvin band. |
+| `` | `min`, `max` | `0..400` | Percent-of-Earth band. |
+| `` | — | — | Container for `` options; one is drawn by weight. |
+| `` | — | — | Biome palette, same format as a planet's (§7). |
+| `` | — | unset | Sea level for worlds of this type. |
+| `` | — | unset | Registry name of the liquid. |
+| `` | — | — | Ore table, same format as a planet's (§8). |
+
+A world must satisfy **all three** ranges to be admitted by a type. Every attribute has a default, so
+`` is valid and matches nearly everything — which makes it a very greedy entry.
+
+### `` — one terrain option
+
+| attribute | applies to | meaning |
+|---|---|---|
+| `source` | all | `NATIVE`, `MOD_WORLDTYPE` or `TEMPLATE`. Unknown names fall back to `NATIVE`. |
+| `worldType` | `MOD_WORLDTYPE` | The world-type name another mod registered. |
+| `path` | `TEMPLATE` | Template identifier. |
+| `genType` | `NATIVE` | Built-in generator variant. |
+| `options` | `MOD_WORLDTYPE` | Generator settings string, passed through verbatim — **not trimmed**, because whitespace can be significant to the receiving generator. |
+| `weight` | all | Draw weight among this type's options. Default `1`. |
+
+**A `MOD_WORLDTYPE` option whose mod is not installed is dropped BEFORE the draw**, and its weight is
+redistributed among the remaining options. A type all of whose options are unavailable falls back to
+`NATIVE`. This is why a type should always carry at least one `NATIVE` option.
+---
-## Built-in `customIcon` values
+## 7. `` and ``
+
+### `` attributes
+
+| attribute | unit | required | meaning |
+|---|---|---|---|
+| `name` | — | no | Display name. |
+| `temp` | `100` = Sol | no (default `100`) | Temperature; drives colour and luminosity. A malformed value warns and falls back to `100`. |
+| `size` | solar radii | no (default `1.0`) | Radius. |
+| `x`, `y` | map units | no | Position on the star-selector map. `y` is the map's Z. |
+| `galacticCoord` | `"sx,sy,sz"` | no | Explicit anchor cell. Malformed → warns and uses the origin. Absent → a deterministic fallback cell is assigned. |
+| `numPlanets` | count | **yes** | How many random planets to generate for this star at FIRST load. Missing → warning and none. |
+| `numGasGiants` | count | **yes** | The same for gas giants. |
+| `blackHole` | boolean | no | This star is a black hole: a quarter of the light its size and temperature would otherwise give. |
+| `diskAngle` | degrees | no (default `70`) | Accretion-disc tilt, render only. |
+
+`numPlanets` / `numGasGiants` fire **once**, at the first load of a world. They are written back as
+`0`, so the generated planets become ordinary entries and are not regenerated. Hand-written
+`` children are additional to them, not instead of them.
+
+### A nested `` is a COMPANION
+
+| attribute | unit | default | meaning |
+|---|---|---|---|
+| `name` | — | `-` | Display name. **Not written back** on save. |
+| `temp` | `100` = Sol | `100` | |
+| `size` | solar radii | `1.0` | |
+| `orbitalDistance` | `100` = 1 AU | `5` (0.05 AU) | How far this star orbits its primary. |
+| `orbitalTheta` | degrees | spread automatically | Its angle on that orbit. Companions with no stated angle are spread apart rather than stacked. |
+| `blackHole`, `diskAngle` | — | — | As above. |
+
+Companions nest: a companion may itself carry companions, and the geometry composes. Consequences
+that are easy to miss:
+
+- **A companion is a star with its own identity.** It gets its own star id, so a `` can be
+ bound to it and a world can orbit the companion rather than the primary.
+- **Every star of a system lights every world in it.** Illumination is the sum of the flux each star
+ delivers at its own distance, so a close pair nearly doubles a world's light and a companion 20 AU
+ out adds only a little. This feeds temperature, solar panels and every derived climate number.
+- **A companion's apparent place in the sky follows from its distance**, not from a fixed tilt: a
+ close pair reads as two suns almost together, a wide one puts its companion elsewhere in the sky.
+- `orbitalDistance` on a companion is the SAME unit as on a planet. It used to be an angle called
+ `separation`; that attribute no longer exists and is ignored if present.
+
+### `` attributes
+
+| attribute | meaning |
+|---|---|
+| `name` | Display name. |
+| `DIMID` | Explicit dimension id. Absent → the next free id is assigned. Malformed → **the whole planet is skipped**. |
+| `dimMapping` | Presence alone (any value, including empty) marks this as a dimension another mod owns; Advanced Rocketry decorates it instead of creating it. |
+| `customIcon` | Basename of the planet-selector texture. See the catalogue below. |
+
+### Built-in `customIcon` values
Built-in planet icon basenames:
`src/main/resources/assets/advancedrocketry/textures/planets/`
-### Standard icons
+#### Standard icons
@@ -358,7 +343,7 @@ Built-in planet icon basenames:
-### Additional normal-only textures
+#### Additional normal-only textures
@@ -381,11 +366,11 @@ Built-in planet icon basenames:
-### Special case
+#### Special case
- `customIcon="void"` is handled specially in the system map and renders the body at size `0`.
-### 5.3.1 Adding your own `customIcon`
+#### Adding your own `customIcon`
Resource pack should provide:
@@ -404,1218 +389,301 @@ Notes:
- The value is lowercased during lookup
- Custom icons are loaded as `.png` for the normal planet texture and `leo.jpg` for the LEO/orbit texture.
- The LEO texture is used for orbit views
-- Built-in examples can be found in the mod resources under:
- https://github.com/kaduvill/AdvancedRocketry/tree/1.12/src/main/resources/assets/advancedrocketry/textures/planets
-
-
----
-
-## 6. Planet Property Tags
-
-### 6.1 Visual and sky settings
-
-#### ``
-Planet fog color.
-
-Accepted formats:
-- comma-separated floats: `r,g,b`
-- hex prefixed with `0x`
-
-Examples:
-
-```xml
-0.5,0.2,1
-or
-0x87FFFF
-```
-
-Notes:
-- RGB float components are expected in the range `0` to `1`
-- Hex is parsed as an integer after removing the `0x` prefix
-
-#### ``
-Planet sky color.
-
-Accepted formats:
-- comma-separated floats: `r,g,b`
-- hex prefixed with `0x`
-
-Examples:
+- Every built-in texture lives in this repository under
+ [`src/main/resources/assets/advancedrocketry/textures/planets/`](../src/main/resources/assets/advancedrocketry/textures/planets/).
+
+A `` nested inside a `` is a **moon** of it. Moons nest arbitrarily deep. A moon's
+`orbitalDistance` is measured from its PARENT, not from the star.
+
+### `` child elements
+
+Physical:
+
+| element | unit | notes |
+|---|---|---|
+| `orbitalDistance` | `100` = 1 AU | Clamped to `1 .. Integer.MAX_VALUE`. |
+| `orbitalTheta` | degrees | Angle at time zero. Fractional degrees are kept. |
+| `orbitalPhi` | degrees | Inclination. Taken modulo 360. |
+| `retrograde` | boolean | Orbits the other way. |
+| `rotationalPeriod` | ticks | Must be `> 0`; a non-positive value warns and is ignored. |
+| `tidallyLocked` | boolean | Keeps one face to its star; overrides `rotationalPeriod` in effect. |
+| `mass` | Earth masses | See the precedence rule below. |
+| `radius` | Earth radii | See the precedence rule below. |
+| `gravitationalMultiplier` | percent of Earth | Clamped to `0..400`. See below. |
+| `atmosphereDensity` | `100` = 1 atm | Clamped to `0..1600`. |
+| `hasOxygen` | boolean | Default `true`. Only `false` is written back. |
+| `metallicity` | relative to Sol | Feeds ore richness. `1.0` is not written back. |
+| `avgTemperature` | Kelvin | **Written, never read.** The temperature is recomputed at load from the star, the orbital distance and the atmosphere. Editing it does nothing. |
-```xml
-0.3,0.6,1
-or
-0x4C99FF
-```
-
-#### ``
-Controls color override behavior for sky/fog rendering.
-
-```xml
-true
-```
-
-Accepted values:
-- `true`
-- `false`
-
-Notes:
-- Used by world provider sky/fog color calculation
-
-#### ``
-Overrides AR's custom sky renderer for that world.
-
-```xml
-true
-```
-
-Accepted values:
-- `true`
-- `false`
-
-Notes:
-- This tag only disables AR's custom planet sky for this planet
-- Also affected by the global client config option `planetSkyOverride`
- - If `planetSkyOverride=false` in the config, AR's custom planet sky is already disabled globally and this tag has no additional effect
-
-#### ``
-Controls planet decoration rendering override.
-
-```xml
-false
-```
-
-Accepted values:
-- `true`
-- `false`
-
-Notes:
-- Overrides whether decorators such as shadows / atmosphere-style planet rendering details should be shown
-
-### 6.2 Atmosphere, gravity, orbit, and rotation
-
-#### ``
-
-Atmosphere density / pressure value.
-
-Example:
-
- 100
-
-Meaning:
-- `100` is Earthlike.
-- Clamped to `[0 - 1600]`
-- Atmosphere pressure category is selected with strict `>` thresholds:
- - `0–25`: no atmosphere / vacuum
- - `26–75`: low atmosphere / low oxygen pressure
- - `76–200`: normal pressure (Breathable)
- - `201–800`: high pressure
- - `801–1600`: super-high pressure
-- Temperature can still override the result into hot or superheated atmosphere types.
-
-Notes:
-- World provider uses atmosphere density for rain/snow/ice behavior and cloud rendering.
-
-#### ``
-
-Used to disable `breathable` for normal pressure planets
-
-Example:
-
- true
-
-Accepted values:
-- `true`
-- `false`
-
-Default:
-- `true` if omitted.
-
-Meaning:
-- This tag is mainly useful for disabling oxygen on breathable planets.
-- If the planet has no atmosphere, this tag has no practical breathing effect.
-
-#### ``
-Gravity value, using `100 = Earthlike`.
-
-```xml
-100
-```
-
-Meaning:
-- `100` = `1.0`
-- `50` = `0.5`
-- `150` = `1.5`
-
-Loader clamp:
-- Min XML value: `0`
-- Max XML value: `400`
-
-Internal conversion:
-- Stored as `value / 100f`
-
-Notes:
-- World provider uses this value directly for planetary gravity queries
-
-#### ``
-Distance from the parent body.
-
-```xml
-100
-```
-
-Meaning:
-- For planets, this is distance from the star
-- For moons, this is distance from the parent planet
-
-Loader clamp:
-- Min: `1`
-- Max: `2147483647`
-
-Notes:
-- For planets orbiting stars, this affects temperature
-- For moons, code uses parent-star distance for solar temperature
-
-#### ``
-Starting angular displacement in degrees.
-
-```xml
-180
-```
-
-Notes:
-- Parsed as integer degrees
-- Converted internally to radians
-- The parser stores the value modulo `360`
-
-#### ``
-Orbital plane angle in degrees.
-
-```xml
-90
-```
-
-Notes:
-- Parsed as integer
-- Stored modulo `360`
-
-#### ``
-Whether the body orbits in retrograde.
-
-```xml
-true
-```
-
-Accepted values:
-- `true`
-- `false`
+Appearance:
-#### ``
-Length of the day/night cycle in ticks.
+| element | notes |
+|---|---|
+| `fogColor`, `skyColor`, `ringColor` | Colour, see §2. |
+| `hasRings` | boolean. |
+| `ringAngle` | degrees. |
+| `hasShading` | boolean; whether the world is decorated with shading. |
+| `hasColorOverride` | boolean. |
+| `skyRenderOverride` | boolean. |
+| `customIcon` | attribute, not element — see above. |
-```xml
-24000
-```
-
-Meaning:
-- `24000` ticks = 20 minutes
-
-Loader rule:
-- Must be greater than `0`
+World generation:
-Notes:
-- Used by `WorldProviderPlanet.calculateCelestialAngle()`
+| element | notes |
+|---|---|
+| `genType` | Built-in generator variant. Only written when non-zero. |
+| `terrainSource` | `NATIVE`, `MOD_WORLDTYPE` or `TEMPLATE`. Unknown → `NATIVE`. Only written when not `NATIVE`. |
+| `terrainWorldType` | Name of another mod's world type. |
+| `terrainTemplate` | Template identifier. |
+| `terrainGeneratorOptions` | Passed through verbatim, **not trimmed**. |
+| `seaLevel` | Block height. |
+| `orbitHeight` | Block height at which a rocket leaves this world. Only written when overridden. |
+| `oceanBlock` | Registry name. An unknown block warns and yields air. |
+| `fillerBlock` | `mod:block` or `mod:block:meta`. Fewer than two parts warns and is ignored. |
+| `forceRiverGeneration` | boolean. |
+| `biomeIds` | See the format below. |
+| `craterBiomeWeights` | See the format below. |
+| `generateCraters`, `generateCaves`, `generateVolcanos`, `generateStructures`, `generateGeodes` | boolean. An empty value leaves the default. **Each is also a global config switch, and the global `false` wins.** |
+| `craterFrequencyMultiplier`, `volcanoFrequencyMultiplier`, `geodeFrequencyMultiplier` | float. Only written when not `1` and when the matching feature is enabled. |
+| `oreGen` | See §8. |
+| `laserDrillOres` | See the format below. **Ignored entirely on a gas giant.** |
+| `geodeOres`, `craterOres` | Comma-separated ore-dictionary names. Unknown names are dropped silently. |
-#### ``
-Sea level value.
+Content and progression:
-```xml
-63
-```
+| element | notes |
+|---|---|
+| `GasGiant` | boolean, spelled with capitals. A gas giant has **no surface**: it cannot be landed on and is not offered as a descent target. |
+| `gas` | Fluid name; a harvestable gas. Repeatable. Read on any planet but written back only for a gas giant, so a `` on a rocky world is lost at the first save. Unknown fluid warns and is skipped. |
+| `isKnown` | boolean. **Writes into a GLOBAL list**, not into the planet: it marks this dimension as known to every player from the start. |
+| `artifact` | An item stack required to unlock travel here. Repeatable. |
+| `spawnable` | An entity that spawns here. See below. |
-Notes:
-- Runtime setter clamps to `0..255`
+Weather:
+| element | unit | notes |
+|---|---|---|
+| `rainStartLength`, `rainProlongationLength` | ticks | A malformed value throws and skips the whole planet — these are the only numeric fields without a `try`. |
+| `thunderStartLength`, `thunderProlongationLength` | ticks | Same. |
+| `rainMarker`, `thunderMarker` | ticks | Same. |
+| `acidicRain` | boolean | Rain damages an unprotected player. |
-#### ``
-Controls the `hasRivers` flag.
+### `` — mob spawns
```xml
-true
+minecraft:zombie
```
-Accepted values:
-- `true`
-- `false`
+The text content is a registry name (`minecraft:zombie`) or, failing that, a fully-qualified entity
+class name. Neither resolving → a warning, and the entry is skipped.
-Notes:
-- This sets `properties.hasRivers`
-- The final `hasRivers()` runtime behavior may also depend on atmosphere and temperature if this is not explicitly forced
-
-### 7.3 Rings and gas giants
-
-#### ``
-Whether the body has rings.
-
-```xml
-true
-```
+| attribute | default | notes |
+|---|---|---|
+| `weight` | `100` | Spawn weight. Floors at 1. |
+| `groupMin` | `1` | Floors at 1. |
+| `groupMax` | `1` | Floors at 1; raised to `groupMin` if smaller. |
+| `nbt` | — | JSON NBT applied to the spawned entity. Invalid JSON or NBT logs a loud configuration error and the entity spawns without it. |
-Accepted values:
-- `true`
-- `false`
+### Biome list formats
-#### ``
-Ring angle integer.
+`biomeIds` — comma-separated `biome` or `biome;weight`:
```xml
-70
+minecraft:desert;40,advancedrocketry:moondark;10
```
-Notes:
-- XML loader uses direct `Integer.parseInt(...)` here
-- Use a valid integer
-
-#### ``
-Ring color.
+- `biome` is a registry name (preferred) or a raw numeric id (legacy, and dependent on the installed
+ mod set).
+- `weight` defaults to `30`. A weight of `0` warns and reverts to `30`.
+- A malformed entry warns and is skipped; the rest of the list still applies.
+- **An empty or absent list is not an empty palette**: a planet with no biomes is given every biome
+ its climate admits.
-Accepted formats:
-- comma-separated floats: `r,g,b`
-- hex prefixed with `0x`
+`craterBiomeWeights` — the same shape, but the weight is a crater frequency and defaults to `100`,
+and a missing `;weight` term warns. Numeric ids are **not** accepted here; only registry names.
-```xml
-0.4,0.4,0.7
-```
+### `laserDrillOres` format
-#### ``
-Marks the body as a gas giant.
+Comma-separated entries, each `oreName` or `oreName;count` or `itemName;count;meta`:
```xml
-true
+oreIron;2,oreGold;1,minecraft:diamond;1;0
```
-Accepted values:
-- `true`
-- `false`
+An ore-dictionary name that exists but has no registered items — the providing mod is not installed —
+warns and is skipped. A name that is neither an ore-dictionary entry nor an item id warns and is
+skipped. The raw string is stored and written back verbatim, so entries for absent mods survive a
+round-trip.
-Notes:
-- Intended for use with gas giants and gas missions
-- Canonically saved/exported as `GasGiant`
+### `artifact` syntax
-#### ``
-Adds a harvestable gas/fluid name.
+An item stack a player must hold to be allowed to travel here. Format `item_or_block meta count`,
+space separated:
```xml
-hydrogen
-helium
+minecraft:diamond 0 1
```
-Notes:
-- The value must resolve through the fluid registry
-- Intended for use with gas giants and gas missions
-
-### 6.4 Biomes
+`meta` defaults to `0` and `count` to `1`. Repeat the element for several artifacts; an unresolvable
+item yields an empty stack and is skipped.
-#### ``
-Biome list for the planet. Overrides the automatic biome-selection
-
-Accepted entry formats:
-- numeric biome ID
-- biome resource location
-- weighted biome entry using `biome;weight`
+---
-Examples:
+## 8. `` — ore generation
```xml
-0,12
-minecraft:plains,minecraft:forest
-minecraft:plains;30,biomesoplenty:alps;15
+
+
+
```
-Notes:
-- If a weight is omitted or `0`, default weight is `30`
-- Resource locations are preferred over old numeric IDs
-- If `` is omitted, the planet falls back to automatic biome selection
- - Automatic biome selection is affected by global biome-related config and biome lists, including logic such as blacklist handling and `maxBiomesPerPlanet`
-- If `` is provided, the loader uses that explicit biome list instead of automatic biome selection
-
-#### ``
-Controls which biomes can be used as crater origin biomes, and how likely craters are to generate in each biome.
+Only `` children are read; anything else under `` is ignored.
-Accepted format:
-- Comma-separated entries
-- Each entry uses `biome;weight`
--
+| attribute | required | clamp | meaning |
+|---|---|---|---|
+| `block` | **yes** | — | Registry name. Missing → the entry is skipped with a warning. |
+| `meta` | no (default `0`) | — | Block metadata. Malformed → the entry is skipped. |
+| `minHeight` | **yes** | floors at 1 | Missing or malformed → the entry is skipped. |
+| `maxHeight` | **yes** | `minHeight..255` | Missing or malformed → the entry is skipped. |
+| `clumpSize` | **yes** | `1..255` | Blocks per vein. Missing or malformed → the entry is skipped. |
+| `chancePerChunk` | **yes** | `1..255` | Veins attempted per chunk. Missing or malformed → the entry is skipped. |
-Example:
+Every clamp is silent. A `clumpSize` of `1000` becomes `255` with no warning.
-```xml
-minecraft:desert;100,minecraft:mesa;60
-```
-
- Behavior:
+---
-- If `` is omitted or empty, craters may originate in any biome.
-- If present, only listed biomes are valid crater origin biomes.
-- The weight is a percentage-like chance from `0` to `100`.
- - `100` = crater origins in this biome are always allowed when the generator attempts one.
- - `50` = about half of crater origin attempts in this biome are allowed.
- - `1` = very rare crater origin attempts in this biome.
- - `0` = effectively disables crater origins in this biome.
-- The biome check is done at the crater origin chunk, not every block touched by the crater.
- - Large craters may still extend into neighboring biomes.
-- If frequency is omitted, the loader warns and defaults that biome weight to `100`.
-- Invalid biome resource locations are ignored with a warning.
+## 9. Combinations — what wins when two fields disagree
-Notes:
+**Gravity versus bulk.** A planet may state `gravitationalMultiplier`, or `mass` **and** `radius`, or
+all three.
-- The loader expects biome resource locations such as `minecraft:desert` or `biomesoplenty:volcanic_island`.
-- This setting controls where craters may originate; it does not change crater shape, size, block palette, or crater ores.
-- Crater generation must still be enabled by both `true` and the global `generateCraters` config option.
-- Actual crater generation also depends on atmosphere conditions.
+| stated | result |
+|---|---|
+| `gravitationalMultiplier` only | That gravity. No mass or radius; anything needing bulk falls back to gravity. |
+| `mass` + `radius` only | Gravity is **derived**: `g = M / R²`, clamped to `0.05 .. 4.0` g. |
+| all three | **The authored gravity wins.** Mass and radius are still stored and still used for orbital periods and for anything that needs a real bulk. |
-### 6.5 Generation type and worldgen switches
+The last row is the important one: adding `mass` and `radius` to a planet that already states a
+gravity cannot change how that planet plays. It only gives the model the numbers it was missing.
-#### ``
-Generation type integer.
+**Mass and radius are order-independent** but each is applied against the other's current value, so
+stating only one of them leaves the other at zero — and a zero radius means no bulk properties at all.
+State both or neither.
-```xml
-1
-```
+**Gas giant versus surface.** `true` makes the world surfaceless. It is then not
+a landing target however else it is configured, `laserDrillOres` on it is ignored, and only ``
+entries can be harvested from it.
-- `0` or omitted:
- - normal planet generation
-- `1`:
- - cave planet generation (based on vanilla nether)
-
-- `2`:
- - Asteroid-belt world
+**Tidal locking versus rotation.** `tidallyLocked` makes the world's rotation equal its orbit. A
+`rotationalPeriod` stated alongside it is stored but has no visible effect.
-#### ``
-Enable/disable crater generation.
+**`orbitalDistance` versus everything derived.** Insolation, equilibrium temperature, orbital period,
+climate and the physical distance a ship flies all come from this one number. `avgTemperature` is
+recomputed from it at every load — you cannot author a temperature that contradicts an orbit.
-```xml
-true
-```
+**Star temperature and size versus planet climate.** Changing a star's `temp` or `size` re-derives the
+climate of every world around it on the next load, because temperature is computed and not stored.
-Accepted values:
-- `true`
-- `false`
+**`DIMID` versus automatic ids.** Stating `DIMID` on some planets and not others is supported; the
+automatic allocator skips ids already taken. Two planets stating the SAME `DIMID` is not detected —
+the second silently replaces the first.
+**`dimMapping` versus everything physical.** A mapped dimension is generated by whoever owns it.
+Terrain elements on it are ignored; climate, gravity and atmosphere still apply.
-Notes:
-- This flag is also gated by the global config option `generateCraters`
- - If the global config is `false`, crater generation is disabled globally regardless of this XML value
- - If the global config is `true`, this tag can still disable craters for an individual planet
-- Actual crater generation also depends on atmospheric conditions
+**Global config switches versus per-planet flags.** `generateCraters`, `generateGeodes`,
+`generateVolcanos` and `generateStructures` exist both here and in the mod config. **The global
+`false` overrides a per-planet `true`.** The reverse is not true: a global `true` does not force a
+planet that declined.
-#### ``
-Enable/disable geode generation.
+**`` versus ``.** Types classify PROCEDURAL worlds only. They never modify an
+authored ``, however well its numbers match a type's ranges.
-```xml
-true
-```
+**`` versus authored stars.** They coexist. An authored star occupies its anchor cell and
+owns that whole neighbourhood; the procedural generator fills what is left. Two authored anchors in
+one neighbourhood is a configuration error and is reported.
-Accepted values:
-- `true`
-- `false`
+---
-Notes:
-- This flag is also gated by the global config option `generateGeodes`
- - If the global config is `false`, geode generation is disabled globally regardless of this XML value
- - If the global config is `true`, this tag can still disable geodes for an individual planet
+## 10. Worked minimal examples
-#### ``
-Enable/disable volcano generation.
+A single authored system, no procedural galaxy:
```xml
-true
+
+
+
+ 100
+ 0
+ 100
+ 100
+ true
+
+ 30
+ 16
+ 0
+ false
+
+
+
+
```
-Accepted values:
-- `true`
-- `false`
-
-Notes:
-- Canonical spelling is `generateVolcanos`
-- This flag is also gated by the global config option `generateVolcanos`
- - If the global config is `false`, volcano generation is disabled globally regardless of this XML value
- - If the global config is `true`, this tag can still disable volcanos for an individual planet
-
-#### ``
-Enable/disable structure generation.
+A wide binary whose companion carries a world of its own:
```xml
-true
+
+
+
+ 120
+ 1.0
+ 1.0
+
+
```
-Accepted values:
-- `true`
-- `false`
+`Alpha I` is lit by both stars, with `Beta`'s contribution falling off over its own 23 AU.
-Notes:
-- This flag is also gated by the global config option `generateVanillaStructures`
- - If the global config is `false`, vanilla/map-feature structures are disabled on all planets regardless of this XML value
- - If the global config is `true`, this tag can still disable structures for an individual planet
-- Structure generation also requires the planet to be habitable/breathable
-#### ``
-Enable/disable cave generation.
+A procedural galaxy with two archetypes and one type:
```xml
-true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
```
-Accepted values:
-- `true`
-- `false`
+---
-#### ``
-Crater frequency multiplier.
+## 11. Pitfalls
-```xml
-1.5
-```
-
-Behavior:
-
-- `1.0` = default
-- `2.0` = double
-- `0.5` = half
-- Values are clamped to `0.01` - `10.0`
-
-#### ``
-Volcano frequency multiplier.
-
-```xml
-0.5
-```
-
-Behavior:
-
-- `1.0` = default
-- `2.0` = double
-- `0.5` = half
-- Values are clamped to `0.01` - `10.0`
-
-#### ``
-Geode frequency multiplier.
-
-```xml
-2.0
-```
-
-Behavior:
-
-- `1.0` = default
-- `2.0` = double
-- `0.5` = half
-- Values are clamped to `0.01` - `10.0`
-
-### 6.6 Blocks, ores, and loot
-
-#### ``
-Per-planet custom ore generation.
-
-Example:
-
-```xml
-