diff --git a/README.md b/README.md index 4ce3df1ae..d64e49daa 100644 --- a/README.md +++ b/README.md @@ -191,8 +191,10 @@ in [`CONTRIBUTING.md`](./CONTRIBUTING.md). > [!CAUTION] > **This line will not load worlds created by the 2.x Advanced Rocketry fork.** The save format changed with -> no migration path. Procedural-universe parameters are the same story: they are inputs to a derived -> universe, so changing one relocates every star and every coordinate a player wrote down. Start a new world. +> no migration path. Procedural-universe parameters are a milder story: they are inputs to a derived +> universe, so changing one moves every system nobody has visited yet — the world therefore refuses to +> open under a changed configuration, and `/stellurgy universe upgrade` is the deliberate way through, +> freezing everything already explored. See the planetDefs reference before you touch them. ## For pack developers @@ -203,6 +205,7 @@ Stars, planets, planet types, the procedural galaxy and ores are configured in X 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/build.gradle b/build.gradle index b42611d9a..0abe039fe 100644 --- a/build.gradle +++ b/build.gradle @@ -315,6 +315,14 @@ def configureHeadlessTest = { Test t, String packageGlob -> }) // Test-only flag gating /artest probe commands and other test-only behaviour. t.systemProperty 'advancedrocketry.tests', 'true' + // Forward the golden-corpus rewrite flag into the forked test JVM. Regenerating the universe + // fixture is a deliberate act (it means a new schema version is being released), so it is opt-in + // per invocation rather than a property with a default: + // ./gradlew testUnit --rerun -Dadvancedrocketry.universe.corpus.write=true + if (System.getProperty('advancedrocketry.universe.corpus.write') != null) { + t.systemProperty 'advancedrocketry.universe.corpus.write', + System.getProperty('advancedrocketry.universe.corpus.write') + } t.testLogging { events 'failed', 'skipped', 'passed' exceptionFormat = 'full' diff --git a/docs/README_PLANETDEFS.md b/docs/README_PLANETDEFS.md index f01b10e41..58b63e078 100644 --- a/docs/README_PLANETDEFS.md +++ b/docs/README_PLANETDEFS.md @@ -1,297 +1,382 @@ -# 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"`, GALAXY-LOCAL (see §5). One cell is 32 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. - -```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 - -``` - -Accepted values: -- `true` -- `false` - -#### `diskAngle` -Black hole disk angle / star disk angle. - -```xml - -``` - -Notes: -- Parsed as float - -#### `separation` -Only meaningful on nested `` entries. - -```xml - -``` - -Notes: -- Parsed as float -- Used for sub-star separation in multi-star systems - -### 4.3 Star examples - -#### Single star - -```xml - - ... - -``` - -#### Binary star - -```xml - - - ... - -``` - -#### Black hole - -```xml - - ... - -``` +## 5. `` — the procedural galaxy + +Present → procedural systems exist alongside the authored ones. Absent → the universe holds only what +this file names. + +| attribute | unit | default | meaning | +|---|---|---|---| +| `density` | 0..1 | `0.35` | Chance that a given cube of space holds a system **in a sun-like part of a galaxy's disc** — the profile is normalised there, so this number describes the sky you actually stand under. Nearer the centre it rises (and saturates); further out and off the plane it falls; outside every galaxy it is zero. 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. | +| `galaxySpacing` | cells | `709554785444` | Edge of the cube that holds **at most one galaxy**. The default is 75 000 light years — twenty-five galaxy diameters. Floors at 1. | +| `galaxyDensity` | 0..1 | `0.5` | Fraction of those cubes that actually hold a galaxy. The rest is intergalactic void. Clamped; `NaN` reads as `0`. | + +### Where the stars are: galaxies, not a fog + +Space is laid out twice over, by the same scheme at two scales. `galaxySpacing`-cubes hold **at most +one galaxy each**, and a galaxy is a real object: a centre, a type, a radius, an orientation, a +central bulge and — if its type has them — spiral arms. Inside it, `minSpacing`-cubes hold at most +one system each, and whether a given cube holds one is `density` **scaled by the galaxy's own profile +at that point**. So the star field thins outwards, thins away from the disc's plane, and stops at the +galaxy's edge. + +A galaxy's **type decides its size**, never the other way round: dwarf spheroidals and dwarf +irregulars outnumber spirals and ellipticals by roughly two orders, so finding a spiral is an event. +The archetype table is `` below. + +**Clusters, one level down.** Inside a star cluster the lattice is finer by an integer factor, so a +cluster really is denser than the field around it rather than merely looking that way. Every galaxy +also has a nucleus at its own centre — the richest cluster of all, and not a special case. A +consequence worth knowing: **the 10 000 AU separation floor is a property of the lattice level, not a +global constant.** Inside a cluster stars stand closer than a wide binary, and a system there keeps +fewer outer bodies, by the same rule that applies everywhere else. + +**Nebulae come with the clusters, not separately.** A molecular cloud, the young cluster condensing +out of it and the ancient cluster that has blown it away are one object at three ages — so a cloud is +derived from its cluster and how much gas that cluster's age has left, and its look (dark, emitting, +reflecting) is that same sequence rather than three separate options. A cloud with no stars in it yet +is a cluster type that refines nothing. Ancient globulars correctly have no cloud at all. + +A nebula is **diffuse matter, not a body**: it has no cell name, it is not a destination, and it may +freely overlap whatever it lies across — the same rule as a system's comet cloud, where attribution +reads names rather than matter. **It has no effect on anything yet**: what a cloud does to a ship that +flies into it is a separate decision and none of its numbers has been settled. + +### Where authored content goes — `galaxy` and `galacticCoord` + +A ``'s `galacticCoord` is **galaxy-local**: an offset in cells from the DECLARATION ORIGIN of the +galaxy named by its `galaxy` attribute. + +| attribute | default | meaning | +|---|---|---| +| `galaxy` | `home` | `home`, or a lattice index `"gx,gy,gz"`. **Naming a galaxy forces that cell to hold one**, on every seed. | +| `galacticCoord` | *(absent → a deterministic fallback cell)* | `"sx,sy,sz"` — the offset from that galaxy's declaration origin. | + +For `home` the declaration origin is the **universe origin**, so a coordinate written before galaxies +existed means exactly what it always did. For any other galaxy it is that galaxy's centre. + +**The home galaxy always exists, and the origin sits out in its disc.** A galaxy fills about three +thousandths of a percent of its own lattice cell, so an absolute declaration would land in +intergalactic space with probability 99.997 %. The home galaxy is therefore seated *around* the origin +— not *on* it, because a galaxy's centre is its nucleus and that is the last address a shipped solar +system should have. The origin lands at a sun-like galactic radius, in the plane. + +**Anything within about 400 light years of the origin is valid on every seed.** That is what the +guaranteed minimum radius leaves once the origin has been moved off centre. Beyond it your system is +inside its galaxy on some seeds and in the void on others; you get a loud error in the log naming the +star, never a silent clamp. + +### `` — the galaxy archetype table + +Zero `` children → the built-in table stands. One or more → they **replace** it entirely. +Every attribute defaults to the stock spiral's value, so changing only how flat a disc is takes one +attribute. + +| attribute | unit | default | meaning | +|---|---|---|---| +| `name` | text | `Galaxy` | Shown in a galaxy's designation. | +| `profile` | `DISC` / `SPHEROID` | `DISC` | The shape stars are distributed in. A `SPHEROID` has no plane, so no arms. | +| `minRadius` / `maxRadius` | light years | `900` / `2200` | Radius band. A galaxy's radius is drawn INSIDE ITS TYPE'S band — size and type are one fact, not two. | +| `thickness` | fraction of the radius | `0.02` | Scale height: how flat it is. `0.02` is a real thin disc (a 30-light-year scale height at the stock radius); raise it to make leaving the disc a manoeuvre rather than a step. On a `SPHEROID` it is the flattening of the pole. | +| `arms` | count | `2` | Spiral arms, or `0` for a type that has none. | +| `rotationSpeed` | km/s | `220` | The rotation curve's asymptotic speed. | +| `coreFraction` | fraction of the radius | `0.08` | Where the rotation curve turns over. Near `1` the galaxy turns almost as a solid body; near `0` its curve is flat almost everywhere and it shears strongly. | +| `weight` | relative | `1` | Draw weight. | + +The stock table is roughly the real abundance ordering — dwarf spheroidals and dwarf irregulars +outnumber spirals and ellipticals by about two orders — so a spiral is something you find. + +### `` — the archetype table + +Zero `` children → the built-in table stands. One or more → they **replace** it entirely. + +| 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. | + +### What `minSpacing` does and does not do + +**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. + +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. + +**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. + +### Changing a `` parameter mid-save is a PROCEDURE, not an edit + +`density`, `minSpacing`, `galaxySpacing` and `galaxyDensity` 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 that nobody has looked at yet. + +**The world refuses to open under a changed configuration.** The save carries a fingerprint of the +`` it was generated under; on a mismatch the load stops and names both fingerprints, +rather than quietly handing the players a different sky. So the failure mode is a server that will +not start, never a route that silently stops leading anywhere. + +**There is a way through, and it keeps what has been explored.** In order: + +1. Restore the previous `` and start the world (§1: in an existing world a template edit + reaches the live copy only through `resetPlanetsFromXML`, which resets itself after one load + unless `ResetOnlyOnce` is `false`). +2. Run `/stellurgy universe upgrade confirm`. Every system anybody has already seen is frozen where + it stands, including the addresses on the memory crystals of players who are **online at that + moment**. +3. Stop the server, install the new configuration, and start again. The stamp is accepted once, and + only if the configuration actually moved. + +The result is a seam at the frontier of the explored: charted space keeps exactly what it held, +unexplored space is re-derived under the new parameters. + +**What the procedure cannot reach.** A crystal in a chest, in an unloaded chunk, or in the inventory +of an offline player is not readable at step 2, so the addresses on it are not frozen. After the +upgrade such an address still resolves — it is a lattice coordinate — but it names whatever the new +universe puts in that cell, which is usually not what the player wrote down. Bring the crystals that +matter to somebody online before running it. + +**Starting a new world is still the simpler answer** if nothing has been explored yet. --- -## 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, GALAXY-LOCAL — an offset from the declaration origin of the galaxy in `galaxy` (see §5). Malformed → warns and uses the origin. Absent → a deterministic fallback cell is assigned. | +| `galaxy` | `home` or `"gx,gy,gz"` | no | Which galaxy `galacticCoord` is measured from. Default `home`, whose declaration origin IS the universe origin. Naming any other forces that lattice cell to hold a galaxy. | +| `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 +443,7 @@ Built-in planet icon basenames:
-### Additional normal-only textures +#### Additional normal-only textures @@ -381,11 +466,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 +489,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 +- 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. | -### 6.1 Visual and sky settings +Appearance: -#### `` -Planet fog color. +| 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. | -Accepted formats: -- comma-separated floats: `r,g,b` -- hex prefixed with `0x` +World generation: -Examples: +| 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. | -```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: - -```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 +Content and progression: -#### `` +| 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. | -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 -``` +Weather: -Notes: -- Parsed as integer degrees -- Converted internally to radians -- The parser stores the value modulo `360` +| 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. | -#### `` -Orbital plane angle in degrees. +### `` — mob spawns ```xml -90 +minecraft:zombie ``` -Notes: -- Parsed as integer -- Stored modulo `360` - -#### `` -Whether the body orbits in retrograde. +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. -```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 -#### `` -Length of the day/night cycle in ticks. +`biomeIds` — comma-separated `biome` or `biome;weight`: ```xml -24000 +minecraft:desert;40,advancedrocketry:moondark;10 ``` -Meaning: -- `24000` ticks = 20 minutes - -Loader rule: -- Must be greater than `0` - -Notes: -- Used by `WorldProviderPlanet.calculateCelestialAngle()` - -#### `` -Sea level value. - -```xml -63 -``` +- `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. -Notes: -- Runtime setter clamps to `0..255` +`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. +### `laserDrillOres` format -#### `` -Controls the `hasRivers` flag. +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` - -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 -``` +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. -Accepted values: -- `true` -- `false` +### `artifact` syntax -#### `` -Ring angle integer. +An item stack a player must hold to be allowed to travel here. Format `item_or_block meta count`, +space separated: ```xml -70 +minecraft:diamond 0 1 ``` -Notes: -- XML loader uses direct `Integer.parseInt(...)` here -- Use a valid integer - -#### `` -Ring color. +`meta` defaults to `0` and `count` to `1`. Repeat the element for several artifacts; an unresolvable +item yields an empty stack and is skipped. -Accepted formats: -- comma-separated floats: `r,g,b` -- hex prefixed with `0x` - -```xml -0.4,0.4,0.7 -``` +--- -#### `` -Marks the body as a gas giant. +## 8. `` — ore generation ```xml -true + + + ``` -Accepted values: -- `true` -- `false` - -Notes: -- Intended for use with gas giants and gas missions -- Canonically saved/exported as `GasGiant` - -#### `` -Adds a harvestable gas/fluid name. - -```xml -hydrogen -helium -``` +Only `` children are read; anything else under `` is ignored. -Notes: -- The value must resolve through the fluid registry -- Intended for use with gas giants and gas missions +| 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. | -### 6.4 Biomes +Every clamp is silent. A `clumpSize` of `1000` becomes `255` with no warning. -#### `` -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` +## 9. Combinations — what wins when two fields disagree -Examples: +**Gravity versus bulk.** A planet may state `gravitationalMultiplier`, or `mass` **and** `radius`, or +all three. -```xml -0,12 -minecraft:plains,minecraft:forest -minecraft:plains;30,biomesoplenty:alps;15 -``` +| 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. | -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 +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. -#### `` -Controls which biomes can be used as crater origin biomes, and how likely craters are to generate in each biome. +**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. -Accepted format: -- Comma-separated entries -- Each entry uses `biome;weight` -- +**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. -Example: +**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. -```xml -minecraft:desert;100,minecraft:mesa;60 -``` +**`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. - Behavior: +**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. -- 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. +**`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. -Notes: +**`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. -- 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. +**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. -### 6.5 Generation type and worldgen switches +**`` versus ``.** Types classify PROCEDURAL worlds only. They never modify an +authored ``, however well its numbers match a type's ranges. -#### `` -Generation type integer. +**`` 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. -```xml -1 -``` +--- -- `0` or omitted: - - normal planet generation -- `1`: - - cave planet generation (based on vanilla nether) - -- `2`: - - Asteroid-belt world +## 10. Worked minimal examples -#### `` -Enable/disable crater generation. +A single authored system, no procedural galaxy: ```xml -true + + + + 100 + 0 + 100 + 100 + true + + 30 + 16 + 0 + false + + + + ``` -Accepted values: -- `true` -- `false` - - -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 - -#### `` -Enable/disable geode generation. +A wide binary whose companion carries a world of its own: ```xml -true + + + + 120 + 1.0 + 1.0 + + ``` -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 +`Alpha I` is lit by both stars, with `Beta`'s contribution falling off over its own 23 AU. -#### `` -Enable/disable volcano generation. +A procedural galaxy with two archetypes and one type: ```xml -true + + + + + + + + + + + + + + + ``` -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 +## 11. Pitfalls -#### `` -Enable/disable structure generation. - -```xml -true -``` - -Accepted values: -- `true` -- `false` - -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. - -```xml -true -``` - -Accepted values: -- `true` -- `false` - -#### `` -Crater frequency multiplier. - -```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 - - - - -``` - -Important: -- The loader reads ore data from `` attributes -- Do not use nested child tags inside `` -- Per-planet `` overrides the fallback ore mapping from `oreConfig.xml` - -Behavior: -- A non-empty per-planet `` gives that planet custom AR ore properties - - `oreConfig.xml` is only used if the planet does not define its own `` -- If a planet has ore properties from either per-planet `` or matching `oreConfig.xml`, AR denies these `OreGenEvent.GenerateMinable` types on that planet: - - `COAL` - `DIAMOND` - `EMERALD` - `GOLD` - `IRON` - `LAPIS` - `QUARTZ` - `REDSTONE` - `CUSTOM` -- Because AR’s own config-driven ore generator (`Copper`, `Tin`, `Rutile`, `Aluminum`, `Iridium`, `Dilithium`) uses `CUSTOM`, those ores are also suppressed on such planets -- In practice, this means per-planet ore properties replace AR’s normal config ore generation on that planet rather than adding to it -- An empty `` does not count; at least one valid `` entry is required for this behavior -- Mods that generate ores through other paths may still bypass this - -Precedence: -- Per-planet `` in `planetDefs.xml` has highest priority -- If `` is absent on that planet, AR falls back to matching entries from `oreConfig.xml` -- If either of those supplies ore properties for the planet, AR’s normal config-driven ore generation is suppressed on that planet -- If neither per-planet `` nor `oreConfig.xml` provides ore properties, AR falls back to its normal global config-driven ore generation -- `` also has a way of disabling normal oregen - - -##### `block` -Block registry name. Required. - -```xml -block="minecraft:iron_ore" -``` - -##### `meta` -Block metadata. Optional. - -```xml -meta="0" -``` - -##### `minHeight` -Minimum generation height. Required. - -```xml -minHeight="1" -``` - -##### `maxHeight` -Maximum generation height. Required. - -```xml -maxHeight="64" -``` - -##### `clumpSize` -Vein size. Required. - -```xml -clumpSize="8" -``` - -##### `chancePerChunk` -Attempts per chunk. Required. - -```xml -chancePerChunk="20" -``` - -Notes: -- Invalid ore entries are skipped with warnings -- `block` must resolve through `Block.getBlockFromName(...)` - -#### `` -Base terrain block override. - -Accepted formats: -- `modid:block` -- `modid:block:meta` - -Examples: - -```xml -minecraft:stone -or -minecraft:stone:3 -``` - -Notes: -- Only one filler block is stored; if multiple are present, the last valid one wins -- If omitted, terrain defaults to `minecraft:stone` -- If set, the planet’s solid terrain mass uses this block instead of stone -- Natural `minecraft:stone` variants preserve more normal biome-style behavior -- Non-stone filler blocks can suppress normal biome/ore generation -- `` does not disable AR custom ore generation from `` - -#### `` -Laser drill ore list. - -Accepted entry formats: -- OreDictionary name, optionally with count -- item registry name, optionally with count and damage - -Examples: - -```xml -oreIron;3,oreGold;1 -or -minecraft:diamond;1;0,minecraft:redstone;8;0 -``` - -Rules: -- Entries are comma-separated -- Each entry uses semicolon-separated parts - -For OreDictionary entries: -- `oreName` -- `oreName;count` - -For item entries: -- `modid:item` -- `modid:item;count` -- `modid:item;count;damage` - -Notes: -- Invalid ore names or item ids are ignored with warnings -- The raw string is preserved internally as `laserDrillOresRaw` -- This is not tested vs JEI-integration - -#### `` -Geode ore whitelist. - -```xml -oreDiamond,oreEmerald -``` - -Notes: -- Comma-separated -- Entries must exist in OreDictionary -- Invalid names are filtered out - -#### `` -Crater ore whitelist. - -```xml -oreIron,oreGold -``` - -Notes: -- Comma-separated -- Entries must exist in OreDictionary -- Invalid names are filtered out - -#### `` -Ocean block override. (sea block) - -```xml -minecraft:water -``` - -Notes: -- Value is a block resource location -- No metadata is supported here in the XML loader - - -This setting is a full terrain base-material override, not a decorative or secondary filler -#### `` -Required artifact entry. - -Accepted format: -- `item_or_block meta count` - -Examples: - -```xml -minecraft:diamond 0 1 -minecraft:stone 3 16 -``` - -Notes: -- The first token is resolved first as block, then as item -- `meta` defaults to `0` -- `count` defaults to `1` - -### 7.7 Spawn entries - -#### `` -Custom spawn entry. - -Example: - -```xml -minecraft:zombie -``` - -Loader behavior: - -- element text content: - - entity registry name, e.g. `minecraft:zombie` -- supported attributes: - - `weight` - - `groupMin` - - `nbt` - -##### `weight` -Spawn weight. - -```xml -weight="100" -``` - -##### `groupMin` -Minimum group size. - -```xml -groupMin="1" -``` - -##### `nbt` -NBT string passed to the spawn entry. - -```xml -nbt="{CustomName:\"Bob\"}" -``` - -Important parser note: -- The current loader has a bug: - - it reads `groupMin` correctly - - but it also mistakenly reads `groupMax` from the `groupMin` attribute -- As a result, `groupMax` is not actually loaded correctly by the current parser -- For current-code documentation purposes, `groupMax` should not be treated as a reliable working XML input - -Notes: -- If `groupMax` ends up below `groupMin`, it is corrected upward -- Entity lookup first tries registry name, then tries class name -- Invalid NBT can produce fatal configuration errors - -### 7.8 Discovery and progression - -#### `` -Marks the planet as initially known. - -```xml -true -``` - -Accepted values: -- `true` -- `false` - -Notes: -- If true, the planet ID is added to `ARConfiguration.getCurrentConfig().initiallyKnownPlanets` - -### 7.9 Custom weather - -These are used by `WorldProviderPlanet.updateWeather()` when the planet is using custom world info. - -#### `` -Base interval for starting rain. - -```xml -168000 -``` - -#### `` -Base interval for starting thunder. - -```xml -168000 -``` - -#### `` -Extension interval while rain is active. - -```xml -12000 -``` - -#### `` -Extension interval while thunder is active. - -```xml -12000 -``` - -#### `` -Rain mode control. - -```xml -0 -``` - -Meaningful values: -- `-1` = never rain -- `0` = normal cycle -- `1` = always rain - -#### `` -Thunder mode control. - -```xml -0 -``` - -Meaningful values: -- `-1` = never thunder -- `0` = normal cycle -- `1` = always thunder - -Important notes for all weather fields: -- The XML loader uses direct integer parsing here -- Use valid integers -- At runtime, world weather code treats non-positive intervals defensively, but the XML parser itself is not forgiving of malformed values - ---- - -## 7. Value Formats - -### 7.1 Color formats - -Supported by: -- `` -- `` -- `` - -Accepted forms: - -#### RGB floats -```xml -0.5,1,1 -``` - -#### Hex with `0x` -```xml -0x87FFFF -``` - -Notes: -- RGB float input is expected as three comma-separated components -- Hex is parsed after removing `0x` - -### 8.2 Boolean values - -Use: - -```xml -true -false -``` - -Tags using boolean-style values include: -- `` -- `` -- `` -- `` -- `` -- `` -- `` -- `` -- `` -- all `generate...` tags - -### 7.3 Resource-location-like values - -Examples: -- blocks: `minecraft:stone` -- items: `minecraft:diamond` -- biomes: `minecraft:plains` -- entities: `minecraft:zombie` - -Fluids for `` use fluid registry names, such as: -- `hydrogen` -- `oxygen` - -### 7.4 Numeric conventions - -- `100` atmosphere density = Earthlike atmosphere scale -- `100` gravitational multiplier = Earthlike gravity scale -- angles are provided in degrees in XML -- rotational period uses ticks -- sea level uses block Y coordinates - ---- - -## 8. Special Syntax Reference - -### 8.1 `biomeIds` syntax - -Allowed forms: -- `0` -- `minecraft:plains` -- `minecraft:plains;30` - -Combined example: - -```xml -minecraft:plains;30,minecraft:forest;20,12 -``` - -### 8.2 `craterBiomeWeights` syntax - -Allowed form: -- `biome;frequency` - -Example: - -```xml -minecraft:desert;100,minecraft:mesa;60 -``` - -### 8.3 `artifact` syntax - -Format: - -`item_or_block meta count` - -Example: - -```xml -minecraft:diamond 0 1 -``` - -Defaults: -- meta: `0` -- count: `1` - -### 8.4 `fillerBlock` syntax - -Accepted forms: - -```xml -minecraft:stone -or -minecraft:stone:3 -``` - -### 8.5 `spawnable` syntax - -Current reliable format: - -```xml -minecraft:zombie -``` - -With NBT: - -```xml -minecraft:skeleton -``` - -Current parser caveat: -- `groupMax` is not reliably read due to a loader bug - -### 8.6 `oreGen` syntax - -Use attribute-based `` entries: - -```xml - - - -``` - -Do not rely on nested child tags inside `` for loading behavior. - - - -## 9. Practical Examples - -### 9.1 Basic terrestrial planet - -```xml - - 0.7,0.8,1 - 0.4,0.6,1 - 100 - true - 100 - 100 - 0 - 24000 - -``` - -### 9.2 Planet with a moon - -```xml - - 100 - 100 - 100 - 0 - 24000 - - - 0 - false - 16 - 150 - 180 - 24000 - - -``` - -### 9.3 Gas giant with harvestable gases - -```xml - - true - 180 - 220 - 90 - 18000 - hydrogen - -``` - -### 9.4 Binary star system - -```xml - - - - 100 - 100 - 100 - 0 - 24000 - - -``` - -### 9.5 External dimension mapping - -```xml - - 100 - 100 - 140 - 45 - 24000 - -``` - -### 9.6 Planet with custom icon - -```xml - - 120 - 95 - 110 - 270 - 22000 - -``` - -### 9.7 Planet with custom ore generation - -```xml - - 30 - 90 - 80 - 120 - 24000 - - - - - - -``` - -### 9.8 Planet with custom weather - -```xml - - 130 - 100 - 95 - 60 - 24000 - - 6000 - 12000 - 9000 - 6000 - 0 - 0 - -``` - -### 9.9 Planet with custom spawn entries - -```xml - - 80 - 100 - 130 - 180 - 24000 - - minecraft:zombie - minecraft:skeleton - -``` - ---- - -## 10. Common Pitfalls - -### 10.1 `numPlanets`, not `numPlanet` -Attribute name is: - -```xml -numPlanets="..." -``` - -### 10.2 `groupMax` is currently not reliable -Current parser bug: -- `groupMax` is not read correctly -- `groupMin` is mistakenly used for both min and max group size - - -### 10.3 Some author-facing fields from old exports are not real XML inputs -Do not treat exported values such as `avgTemperature` as reliable author-controlled XML settings unless separately confirmed in code. - ---- - -## 11. Fields Intentionally Not Documented Here - -This document intentionally excludes fields that were not confirmed as meaningful current XML inputs. - -Examples: -- fields only written by export code -- fields not meaningfully loaded back -- fields whose behavior was not confirmed when writing this document - ---- - -## 12. Full Example - -```xml - - - - 0.7,0.8,1 - 0.4,0.6,1 - 100 - true - 100 - 100 - 0 - 0 - 24000 - 63 - minecraft:plains;30,minecraft:forest;20 - true - true - true - true - - - 0.9,0.9,0.9 - 0.1,0.1,0.1 - 0 - false - 16 - 150 - 180 - 24000 - true - - - - - true - 180 - 220 - 90 - 18000 - hydrogen - oxygen - true - 70 - 0.6,0.5,0.7 - - - -``` +- **`numPlanets`, not `numPlanet`.** An unrecognised attribute is ignored silently, and the star then + generates nothing — with a warning about a missing entry rather than about a misspelling. +- **Editing the live copy.** It is rewritten on the next save. Edit the template and use + `resetPlanetsFromXML`. +- **`avgTemperature` looks authorable and is not.** It is written by the exporter and recomputed on + load. The same goes for anything else that appears in an exported file but is absent from §7 here: + if the reader has no branch for it, writing it does nothing. +- **Two planets with the same `DIMID`.** Not detected; the second silently replaces the first. +- **A weight of `0`** in `biomeIds` warns and reverts to the default, because a zero-weight entry + would silently never be drawn. --- -## 13. Resources -App to help build universe. https://github.com/DaIsimsiz/planetDefs-Builder/releases +## 12. External tools -) +A community editor for building a catalogue visually: +. It predates the fields introduced by the +3.0.0 line — `mass`, `radius`, `metallicity`, `terrainSource`, `` and `` — so +check its output against §7 before shipping it. diff --git a/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java b/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java index c6728d3bb..34a37fc6d 100644 --- a/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java +++ b/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java @@ -1441,6 +1441,18 @@ public void onPlayerLogin(PlayerEvent.PlayerLoggedInEvent event) { PacketHandler.sendToPlayer(new PacketSyncKnownPlanets(station.getId(), station.getKnownPlanetList()), player); } } + + // An ALPHA world model is told to the player, on the world it applies to, every time he + // arrives. Not once and not in a changelog: what it warns about is that this world may have + // no way forward, and that is worth knowing before he invests another evening in it. + zmaster587.advancedRocketry.universe.UniverseRegistry.activeSchema().ifPresent(schema -> { + if (!schema.isStable()) { + player.sendMessage(new net.minecraft.util.text.TextComponentTranslation( + "msg.advancedrocketry.universe.alpha", schema.label()) + .setStyle(new net.minecraft.util.text.Style() + .setColor(net.minecraft.util.text.TextFormatting.GOLD))); + } + }); } } } diff --git a/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java b/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java index 97b837224..d7ced1d88 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java +++ b/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java @@ -286,20 +286,59 @@ public class ARConfiguration { public int terraformPlanetSpeed; @ConfigProperty public int planetDiscoveryChance; + /** + * The shipped telescope-survey defaults, named so that the code that REGISTERS them and the test + * that MEASURES what they cost cannot drift apart. A default whose consequences are stated + * somewhere other than where the default lives is a number nobody is checking. + * + *

Measured together, at the stock star table and star spacing: a full-depth pointing holds + * about 77 000 looks, reaches 1 768 light years, registers of the order of thirty systems, and + * takes roughly six hundred steps.

+ */ + public static final double DEFAULT_TELESCOPE_LIMITING_MAGNITUDE = 8d; + /** @see #DEFAULT_TELESCOPE_LIMITING_MAGNITUDE */ + public static final double DEFAULT_TELESCOPE_CONE_HALF_ANGLE_DEGREES = 1d; + /** + * How much BRIGHTER than the detection limit a system must be before an instrument can make out + * what is in it — 6.5 magnitudes, and the number is derived rather than chosen. + * + *

Seeing that a point of light is there and measuring what orbits it are not the same + * observation. Detection is conventionally called at a signal-to-noise of about 5 — enough to + * say "something is there". Characterisation is transit photometry and spectroscopy, and a + * usable spectrum wants an SNR around 100. Signal-to-noise grows as the square root of the + * photons collected, so the flux ratio between the two is {@code (100/5)² = 400}, and + * {@code 2.5·log10(400) = 6.5} magnitudes.

+ * + *

What it costs at the shipped aperture, measured: detection reaches 161 ly for a + * sun-like star and 1 359 ly for a blue giant; resolution reaches 8.1 ly and 68 ly. Against a + * mean star separation of 4.23 ly that means an early instrument resolves its nearest few + * neighbours and hands back coordinates for everything else — which is the progression the + * aperture ladder exists to sell.

+ */ + public static final double DEFAULT_TELESCOPE_RESOLVE_MARGIN_MAGNITUDES = 6.5d; + /** @see #DEFAULT_TELESCOPE_LIMITING_MAGNITUDE */ + public static final int DEFAULT_TELESCOPE_SCAN_MAX_CELLS = 200_000; + /** @see #DEFAULT_TELESCOPE_LIMITING_MAGNITUDE */ + public static final int DEFAULT_TELESCOPE_SCAN_BASE_TICKS = 20; + /** @see #DEFAULT_TELESCOPE_LIMITING_MAGNITUDE */ + public static final int DEFAULT_TELESCOPE_SCAN_CELLS_PER_STEP = 130; + @ConfigProperty - public int telescopeScanRangeSectors; + public double telescopeLimitingMagnitude; @ConfigProperty - public int telescopeScanHalfWidthSectors; + public double telescopeConeHalfAngleDegrees; @ConfigProperty - public int telescopeScanMaxSectors; + public double telescopeResolveMarginMagnitudes; @ConfigProperty - public int telescopeScanBaseTicks; + public int telescopeScanMaxCells; @ConfigProperty - public int telescopeScanTicksPerSector; + public int telescopeScanBaseTicks; @ConfigProperty public int telescopeScanCellsPerStep; @ConfigProperty - public int telescopePassiveRadiusSectors; + public int telescopePassiveRadiusSteps; + @ConfigProperty + public double telescopeObscuredAtMagnitudes; @ConfigProperty public int telescopeSurveyDataPerStep; @ConfigProperty @@ -535,14 +574,15 @@ public static void loadPreInit() { //Planet arConfig.planetsMustBeDiscovered = config.get(PLANET, "planetsMustBeDiscovered", false, "Planets must be discovered in the warp controller before being visible").getBoolean(); arConfig.planetDiscoveryChance = config.get(PLANET, "planetDiscoveryChance", 5, "Chance of planet discovery in the warp controller, chance is 1/n", 1, Integer.MAX_VALUE).getInt(); - arConfig.telescopeScanRangeSectors = config.get(PLANET, "telescopeScanRangeSectors", 24, "How far, in galactic sectors, an observatory's region scan can be aimed. This is the instrument's horizon: beyond it the sky is not resolvable, which is what keeps an endless universe from being read off a telescope. A scan aimed farther is clamped to this.", 1, Integer.MAX_VALUE).getInt(); - arConfig.telescopeScanHalfWidthSectors = config.get(PLANET, "telescopeScanHalfWidthSectors", 2, "Half-width, in sectors, of the region one survey sweeps. 0 means a single sector, 1 a 3x3x3 neighbourhood, 2 a 5x5x5, and so on. Narrowed automatically when the resulting region would exceed telescopeScanMaxSectors.", 0, Integer.MAX_VALUE).getInt(); - arConfig.telescopeScanMaxSectors = config.get(PLANET, "telescopeScanMaxSectors", 1000, "Hard ceiling on how many sectors one survey may cover. The width above is narrowed until the region fits under this. A sweep may be long, but never unbounded.", 1, Integer.MAX_VALUE).getInt(); - arConfig.telescopeScanBaseTicks = config.get(PLANET, "telescopeScanBaseTicks", 200, "Ticks one STEP of a survey takes before distance is counted - the cost of holding the instrument on a patch of sky at all. Only applies with planetsMustBeDiscovered on; without research, an observation is instant.", 0, Integer.MAX_VALUE).getInt(); - arConfig.telescopeScanTicksPerSector = config.get(PLANET, "telescopeScanTicksPerSector", 100, "Extra ticks per sector of distance, per step. This is what makes a far region a longer survey than a near one.", 0, Integer.MAX_VALUE).getInt(); - arConfig.telescopeScanCellsPerStep = config.get(PLANET, "telescopeScanCellsPerStep", 5, "How many cells of the region one step of a survey resolves. This is the bound that stops a sweep from enumerating everything at once.", 1, Integer.MAX_VALUE).getInt(); + arConfig.telescopeLimitingMagnitude = config.get(PLANET, "telescopeLimitingMagnitude", DEFAULT_TELESCOPE_LIMITING_MAGNITUDE, "How faint a star an observatory can still register, in APPARENT MAGNITUDE - the scale astronomy measures brightness on, where SMALLER IS BRIGHTER and five magnitudes is a factor of a hundred in received light. This is the instrument's aperture, and it is what its reach is derived FROM: a survey walks outwards only as far as the brightest star it could possibly see would still be above this limit, so a better aperture reaches farther by seeing more rather than by being told a bigger number. Reference points: 6 is roughly the naked eye, 8 (the default) reaches a sun-like star at about 160 light years and a blue giant at 1360, and each 5 magnitudes multiplies every one of those distances by ten. Dust counts against the same limit, so a cloud in the way shortens the reach in exactly the way distance does.", -30d, 40d).getDouble(); + arConfig.telescopeConeHalfAngleDegrees = config.get(PLANET, "telescopeConeHalfAngleDegrees", DEFAULT_TELESCOPE_CONE_HALF_ANGLE_DEGREES, "How wide a patch of sky one pointing covers, in DEGREES from the axis to the edge. A survey is a cone with its apex at the observatory, so this is its opening: narrow in degrees, and still enormous at the far end because the same angle subtends more space the farther out it is read. Widening it multiplies the work by the SQUARE, so a pointing twice as wide is four times the survey.", 0.001d, 89d).getDouble(); + arConfig.telescopeResolveMarginMagnitudes = config.get(PLANET, "telescopeResolveMarginMagnitudes", DEFAULT_TELESCOPE_RESOLVE_MARGIN_MAGNITUDES, "How much BRIGHTER than telescopeLimitingMagnitude a system must be before the instrument can make out what is IN it, in magnitudes. Seeing that a point of light is there and measuring what orbits it are not the same observation: detection is called at a signal-to-noise of about 5, while a usable spectrum wants about 100, and since signal-to-noise grows as the square root of the photons collected that is a flux ratio of 400 - i.e. 6.5 magnitudes. Everything the survey registers but cannot resolve is still written down as a POSITION, so a weak instrument hands back a list of places worth flying to and a better one tells you what is at them. Set it to 0 to make anything detectable also resolvable, which is how the survey behaved before the distinction existed.", 0d, 40d).getDouble(); + arConfig.telescopeScanMaxCells = config.get(PLANET, "telescopeScanMaxCells", DEFAULT_TELESCOPE_SCAN_MAX_CELLS, "Hard ceiling on how many LOOKS one survey may hold (one per star territory along the pointing, not one per cell of sky crossed). A pointing that would exceed it is SHORTENED until it fits, exactly as its width used to be narrowed - a sweep may be long, but never unbounded. At the shipped aperture and opening a full-depth pointing holds about 77 000 looks, so this leaves room to raise the aperture a little before the ceiling starts cutting the reach.", 1, Integer.MAX_VALUE).getInt(); + arConfig.telescopeScanBaseTicks = config.get(PLANET, "telescopeScanBaseTicks", DEFAULT_TELESCOPE_SCAN_BASE_TICKS, "Ticks one STEP of a survey takes. A pointing's cost in time is carried by how many steps it needs and not by how far it reaches, because a deeper pointing already holds proportionally more looks. Only applies with planetsMustBeDiscovered on; without research, an observation is instant.", 0, Integer.MAX_VALUE).getInt(); + arConfig.telescopeScanCellsPerStep = config.get(PLANET, "telescopeScanCellsPerStep", DEFAULT_TELESCOPE_SCAN_CELLS_PER_STEP, "How many looks one step of a survey resolves. This is the bound that stops a sweep from enumerating everything at once. With the shipped defaults a full-depth pointing is about 600 steps, i.e. roughly ten minutes of clear night.", 1, Integer.MAX_VALUE).getInt(); arConfig.telescopeSurveyDataPerStep = config.get(PLANET, "telescopeSurveyDataPerStep", 0, "Distance data one step of a survey consumes, drawn from the observatory's data buses the same way its asteroid scan draws. A step with too little data waits rather than resolving, so an unfed instrument stalls instead of working for free. Zero (the default) means a survey costs nothing - what it should cost is a balance question, not a mechanic one.", 0, Integer.MAX_VALUE).getInt(); - arConfig.telescopePassiveRadiusSectors = config.get(PLANET, "telescopePassiveRadiusSectors", 2, "How far, in sectors, the passive local radar reaches around the observatory's own cell. Passive is the mode that costs nothing and watches the neighbourhood; the directed survey is what looks far away.", 0, Integer.MAX_VALUE).getInt(); + arConfig.telescopeObscuredAtMagnitudes = config.get(PLANET, "telescopeObscuredAtMagnitudes", 5d, "How much dust a survey can see THROUGH, in magnitudes of visual extinction - the unit astronomy measures interstellar dust in. A nebula between the instrument and what it is looking at dims it; past this much, the survey can still tell that a system is there but can no longer make out its bodies, and writes the bare coordinate instead. The default is the real boundary at which faint objects behind a cloud disappear: ~1 magnitude is noticeable dimming, ~5 is where things start vanishing, ~10 is an opaque dark cloud. Raise it to see through thicker clouds; set it to 0 to turn concealment off entirely.", 0d, Double.MAX_VALUE).getDouble(); + arConfig.telescopePassiveRadiusSteps = config.get(PLANET, "telescopePassiveRadiusSteps", 1, "How far, in STAR TERRITORIES, the passive local radar reaches around the observatory's own. 0 is the system you are standing in and nothing else; 1 (the default) adds the twenty-six territories around it. Territories and not cells: one look already yields every body of the system that owns it, so a radius counted in cells never reached a neighbour at all - two cells was a fifth of the way to the innermost planet of the system the instrument was already standing in. Passive costs nothing; the pointing is what looks far away.", 0, Integer.MAX_VALUE).getInt(); DimensionManager.dimOffset = config.getInt("minDimension", PLANET, 2, -127, 8000, "Lowest dimension ID that can be used for planets."); arConfig.canPlayerRespawnInSpace = config.get(PLANET, "allowPlanetRespawn", false, "Allow bed respawn on planets with breathable air.").getBoolean(); arConfig.forcePlayerRespawnInSpace = config.get(PLANET, "forcePlanetRespawn", false, "Allow bed respawn on planets even without breathable air. Requires 'allowPlanetRespawn=true'.").getBoolean(); diff --git a/src/main/java/zmaster587/advancedRocketry/api/FreeFlightPhysics.java b/src/main/java/zmaster587/advancedRocketry/api/FreeFlightPhysics.java index 83870d3a8..10ec3a09a 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/FreeFlightPhysics.java +++ b/src/main/java/zmaster587/advancedRocketry/api/FreeFlightPhysics.java @@ -34,11 +34,18 @@ * body-frame velocity setpoint (see {@link #rampSetpoint}); * FA computes the thrust that tracks it, cancelling gravity. Zero * setpoint = hover. - *
  • {@link #step} — Flight Assist OFF: raw Newtonian. Translation - * channels are direct thrust while held; release = coast under - * gravity. The manual brake (Shift) lives here only.
  • + *
  • {@link #step} — Flight Assist OFF: raw Newtonian, and now literally so. + * Translation channels are direct thrust while held; release = coast under + * gravity. The manual brake (Shift) lives here only. There is no ceiling on + * speed — only on acceleration ({@link #MAX_THRUST_ACCEL}), so you go as fast as + * you are willing to burn for and must burn as long again to stop.
  • * * + *

    Where a speed bound is genuinely needed it belongs to the ENVIRONMENT rather than to the + * craft, and it is not imposed here: a vanilla entity's own movement resolves collision against + * the SWEPT box it is about to traverse ({@code Entity.move}), so a rocket cannot pass through + * terrain however fast it goes, and empty space has nothing to hit at all.

    + * *

    Player intent enters via a {@link FreeFlightInput} normalised to [-1, +1]. */ public final class FreeFlightPhysics { @@ -51,20 +58,64 @@ public final class FreeFlightPhysics { public static final double MAX_PITCH_RATE = 4.0; /** Per-tick roll (bank) delta (degrees) at full roll input. */ public static final double MAX_ROLL_RATE = 5.0; - /** Max scalar speed (blocks/tick) — hard cap. */ - public static final double MAX_SPEED = 3.0; + /** + * The ceiling (blocks/tick) on the Flight-Assist velocity SETPOINT — the fastest cruise a pilot + * can dial in with the assist on. + * + *

    It bounds what the assist can be ASKED for, and nothing else. It is not a law of motion and + * not a property of the craft: Flight Assist exists to hold the speed you asked for, so a ceiling + * on the asking is a comfort number and lives here; with the assist OFF there is no speed ceiling + * at all and a craft accelerates for as long as it burns (see {@link #translateNewtonian}).

    + * + *

    This used to be {@code MAX_SPEED}, clamped into BOTH laws, which made the documented + * "raw Newtonian" mode not Newtonian and put a rocket's top speed a factor of ~130 below first + * cosmic velocity — by its own numbers it could not reach orbit. The number itself is unchanged; + * only its reach is.

    + */ + public static final double FA_SETPOINT_MAX_SPEED = 3.0; /** Brake retention factor at full brake (0..1, lower = more aggressive). */ public static final double BRAKE_RETENTION = 0.85; /** Pitch clamp (degrees). */ public static final double PITCH_MAX = 85.0; /** * Arcade ceiling on per-tick thrust acceleration (blocks/tick²). Bounds an - * extremely high thrust-to-weight rocket so motion stays smooth; velocity is - * still bounded independently by {@link #MAX_SPEED}. Normal rockets sit far + * extremely high thrust-to-weight rocket so motion stays smooth. Normal rockets sit far * below this (e.g. TWR 2 → ~0.1), so the cap only bites on absurd builds. + * + *

    This is the only bound on free flight. Nothing caps velocity: a craft that keeps + * burning keeps gaining speed, and how long that takes is the whole cost. At this ceiling a + * turnover crossing of one system is hours rather than the impossibility a speed cap made it.

    */ public static final double MAX_THRUST_ACCEL = 0.5; + /** + * The speed a craft at FULL thrust settles at in a one-atmosphere sky, in blocks/tick — the + * number {@link #DRAG_PER_DENSITY} is derived from, and the one to argue about if this ever + * feels wrong. + * + *

    100 b/t is 2 km/s. It is deliberately generous: real hulls come apart far below it in dense + * air, and the point here is not to model aerodynamics but to stop an atmosphere from being a + * thing a craft passes through as if it were vacuum. Under the acceleration law a rocket can now + * arrive at a planet arbitrarily fast, and nothing charged it for that; an atmosphere charges it + * in the only currency this law has, which is TIME — shedding speed takes as long as building it + * did.

    + * + *

    NOT ratified as a balance number. It is derived, stated, and pinned by a test that reads it + * from here rather than restating it.

    + */ + public static final double ATMOSPHERIC_TERMINAL_SPEED = 100.0; + + /** + * Quadratic drag per unit of atmospheric density, in 1/blocks: {@code Δv = -k·ρ·v·|v|}. + * + *

    Derived, not chosen: at terminal velocity thrust equals drag, so + * {@code k = MAX_THRUST_ACCEL / ATMOSPHERIC_TERMINAL_SPEED²}. Both inputs are visible above, so + * changing either moves this the way physics says it should rather than the way a hand-tuned + * constant would.

    + */ + public static final double DRAG_PER_DENSITY = + MAX_THRUST_ACCEL / (ATMOSPHERIC_TERMINAL_SPEED * ATMOSPHERIC_TERMINAL_SPEED); + /** * Per-tick velocity retention used by the liftoff/hover assist to bleed * horizontal drift (0..1; ≈0.88 → settles in ~25–30 ticks). @@ -90,7 +141,7 @@ public final class FreeFlightPhysics { /** * Per-held-tick change of the velocity setpoint (blocks/tick per tick) at * full channel deflection: holding a key sweeps one axis from 0 to - * {@link #MAX_SPEED} in ~{@code MAX_SPEED/SETPOINT_RAMP} = 60 ticks (3 s). + * {@link #FA_SETPOINT_MAX_SPEED} in ~{@code FA_SETPOINT_MAX_SPEED/SETPOINT_RAMP} = 60 ticks (3 s). */ public static final double SETPOINT_RAMP = 0.05; @@ -409,15 +460,13 @@ public static Step faStep(double mx, double my, double mz, Quat q, cx *= s; cy *= s; cz *= s; } + // No velocity clamp: the ceiling lives on the SETPOINT this law is tracking + // (FA_SETPOINT_MAX_SPEED), so a craft that arrives here faster than the pilot asked for - + // carrying momentum from a Newtonian burn - is decelerated by the thrust budget like + // anything else, instead of having its velocity rewritten under it. double newMx = mx + cx; double newMy = my + cy - gravity; double newMz = mz + cz; - - double speed = Math.sqrt(newMx * newMx + newMy * newMy + newMz * newMz); - if (speed > MAX_SPEED) { - double s = MAX_SPEED / speed; - newMx *= s; newMy *= s; newMz *= s; - } return new Step(newMx, newMy, newMz, e[0], e[1], e[2], thrustApplied); } @@ -453,13 +502,44 @@ public static Step translateNewtonian(double mx, double my, double mz, Quat q, double retain = 1.0 - (1.0 - BRAKE_RETENTION) * brake; newMx *= retain; newMy *= retain; newMz *= retain; } + // NO speed cap. This law is Newtonian and now says so: thrust while held, coast on release, + // and the only bound is MAX_THRUST_ACCEL. Reaching an absurd speed is the pilot's own affair + // and costs him the time it takes to shed it again. + return new Step(newMx, newMy, newMz, e[0], e[1], e[2], thrustApplied); + } - double speed = Math.sqrt(newMx * newMx + newMy * newMy + newMz * newMz); - if (speed > MAX_SPEED) { - double s = MAX_SPEED / speed; - newMx *= s; newMy *= s; newMz *= s; + /** + * One tick of atmospheric drag on a world-frame velocity: {@code Δv = -k·ρ·v·|v|}, quadratic in + * speed and linear in density, applied along the velocity vector so it only ever slows a craft + * and never turns it. + * + *

    Applies to every flight law rather than to one of them: an atmosphere does not ask whether + * Flight Assist is on. It is the counterpart of removing the speed cap — the cap used to be the + * only thing standing between "go as fast as you like" and "arrive at a planet at any speed", + * and a bound that comes from where you are is a better one than a bound written into the law.

    + * + *

    Never overshoots into a reversal. A tick's drag is clamped to the speed itself, so a + * craft can be brought to rest but never pushed backwards by air — which an unclamped quadratic + * would do at high speed and low tick rate, and which reads as a hull bouncing off the sky.

    + * + * @param density atmospheric density as a fraction of one Earth atmosphere; {@code <= 0} is + * vacuum and returns the velocity untouched + * @return the new {@code {mx, my, mz}} + */ + public static double[] atmosphericDrag(double mx, double my, double mz, double density) { + if (!(density > 0.0)) { + return new double[]{mx, my, mz}; } - return new Step(newMx, newMy, newMz, e[0], e[1], e[2], thrustApplied); + double speed = Math.sqrt(mx * mx + my * my + mz * mz); + if (speed < 1e-9) { + return new double[]{mx, my, mz}; + } + double decel = DRAG_PER_DENSITY * density * speed * speed; + if (decel > speed) { + decel = speed; // to rest, never through it + } + double scale = (speed - decel) / speed; + return new double[]{mx * scale, my * scale, mz * scale}; } // -- Tier-2 ship translation command ----------------------------------- @@ -614,7 +694,9 @@ public static double[] shipControlAccel(double cx, double cy, double cz, *. Holding a translation key RAMPS the matching axis by * {@link #SETPOINT_RAMP} per tick; releasing leaves the setpoint where it * is; {@code input.cutActive} (X) zeroes the whole vector instantly. The - * result is clamped to {@link #MAX_SPEED} in magnitude. + * result is clamped to {@link #FA_SETPOINT_MAX_SPEED} in magnitude — the one place + * free flight has a speed ceiling, and it bounds what the assist may be asked to hold, + * never what the craft may reach. * * @return new setpoint as {forward, right, up} */ @@ -628,8 +710,8 @@ public static double[] rampSetpoint(double spFwd, double spRight, double spUp, double u = sane(spUp) + input.throttleVertical * SETPOINT_RAMP; double mag = Math.sqrt(f * f + r * r + u * u); - if (mag > MAX_SPEED) { - double s = MAX_SPEED / mag; + if (mag > FA_SETPOINT_MAX_SPEED) { + double s = FA_SETPOINT_MAX_SPEED / mag; f *= s; r *= s; u *= s; } return new double[] {f, r, u}; @@ -686,17 +768,11 @@ public static Step faStep(double mx, double my, double mz, cx *= s; cy *= s; cz *= s; } + // No velocity clamp — see the quaternion faStep: the ceiling is on the setpoint. double newMx = mx + cx; double newMy = my + cy - gravity; double newMz = mz + cz; - // Hard speed cap (always — safety). - double speed = Math.sqrt(newMx * newMx + newMy * newMy + newMz * newMz); - if (speed > MAX_SPEED) { - double s = MAX_SPEED / speed; - newMx *= s; newMy *= s; newMz *= s; - } - return new Step(newMx, newMy, newMz, yawDeg, pitchDeg, rollDeg, thrustApplied); } @@ -774,15 +850,7 @@ public static Step step(double mx, double my, double mz, newMz *= retain; } - // Hard speed cap (always — safety). - double speed = Math.sqrt(newMx * newMx + newMy * newMy + newMz * newMz); - if (speed > MAX_SPEED) { - double s = MAX_SPEED / speed; - newMx *= s; - newMy *= s; - newMz *= s; - } - + // NO speed cap — see translateNewtonian. return new Step(newMx, newMy, newMz, newYaw, newPitch, newRoll, thrustApplied); } diff --git a/src/main/java/zmaster587/advancedRocketry/api/PilotInputCadence.java b/src/main/java/zmaster587/advancedRocketry/api/PilotInputCadence.java new file mode 100644 index 000000000..e2ad87f1f --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/api/PilotInputCadence.java @@ -0,0 +1,77 @@ +package zmaster587.advancedRocketry.api; + +/** + * When a pilot's control packet must go out, given that the last one may not have survived. + * + *

    Why this is not simply "on change"

    + * + *

    Send-on-change is correct only if two things hold: delivery is lossless, and the value the server + * stored stays stored. The second does not hold. The server keeps a pilot's input in a field on the + * flight computer's TILE INSTANCE, and a tile instance is a perishable thing — a chunk reload or a + * re-registration replaces the object, and the field comes back null. The client, meanwhile, has + * nothing to notice: from where it sits the key is still down and the input has not changed, so under + * send-on-change it never speaks again and the craft flies on with no one at the controls.

    + * + *

    Measured as a symptom before it was understood: a held climb key lifted a ship for about 100 + * ticks and then the ship simply held altitude, with the key still down and the residual vertical + * velocity oscillating about zero — a craft being HELD, not one coasting. The probe path that + * re-sent its command every tick never showed it, which is the same fact from the other side.

    + * + *

    The rule

    + * + *

    A CHANGE is sent immediately, as before. A held non-idle input is re-sent every + * {@link #REPEAT_TICKS} ticks, so the cost of any single loss is bounded by that interval instead of + * lasting until the pilot happens to move a control. An IDLE input is never repeated: losing "no + * input" costs nothing, because the absence of input is what the server falls back to anyway.

    + * + *

    The phase is derived from the seat, not shared: a fixed {@code tick % N} would stack every pilot + * on a server into the same tick, which is how a keep-alive turns into a burst. Two seats therefore + * repeat on different ticks even when their pilots pressed at the same instant.

    + */ +public final class PilotInputCadence { + + /** + * How often a held input is re-asserted, in ticks — one second at 20 tps. + * + *

    Chosen in the units of the defect: this is the worst-case time a craft can fly with a + * command the server has forgotten. At 20 ticks the pilot may feel a stutter; the loss it + * replaces lasted until he released the key, which in the measured case was the rest of the + * flight. One packet per second per seated pilot is negligible beside the per-tick pose stream + * the same ship already sends.

    + */ + public static final int REPEAT_TICKS = 20; + + private PilotInputCadence() { } + + /** + * Whether this tick must put {@code input} on the wire. + * + * @param input what the pilot is commanding right now; {@code null} is never sent + * @param lastSent the last input actually sent, or {@code null} if none has been + * @param tick a monotonically increasing client tick counter + * @param seatPhase a per-seat phase offset (see the class doc); any stable integer derived from + * the seat's identity will do + */ + public static boolean shouldSend(FreeFlightInput input, FreeFlightInput lastSent, + long tick, int seatPhase) { + if (input == null) { + return false; + } + if (!input.equals(lastSent)) { + return true; + } + if (input.isIdle()) { + return false; + } + return Math.floorMod(tick - seatPhase, REPEAT_TICKS) == 0L; + } + + /** + * A stable phase in {@code [0, REPEAT_TICKS)} for a seat at {@code (x,y,z)}. Deliberately not a + * hash of the whole position object: two seats a block apart must land on different ticks, and + * the sum of the coordinates does exactly that while staying trivially reproducible in a test. + */ + public static int phaseOfSeat(int x, int y, int z) { + return Math.floorMod(x + y + z, REPEAT_TICKS); + } +} 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..7121ea656 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/dimension/solar/StellarBody.java +++ b/src/main/java/zmaster587/advancedRocketry/api/dimension/solar/StellarBody.java @@ -15,15 +15,47 @@ 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; + + /** + * 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; float[] color; int id; float size; + 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; @@ -34,7 +66,7 @@ public StellarBody() { planets = new HashMap<>(); size = 1f; subStars = new LinkedList<>(); - starSeperation = 5f; + orbitalDistance = DEFAULT_COMPANION_ORBIT; isBlackHole = false; diskAngle = 70; } @@ -43,14 +75,52 @@ 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.

    + */ + private int maxRetinueBodies; + 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; } + /** + * How many DERIVED worlds this authored system asks for — the pack's own {@code numPlanets} plus + * {@code numGasGiants}, carried past XML load so the universe layer can honour it. + * + *

    It used to be consumed at world creation by a random generator seeded on the wall clock, so + * two saves of one seed differed and the same defect had to be fixed in two world-making models. + * The number survives; the second model does not. Planets and giants are ONE count here because + * giant-ness is derived from a body's own physics — the procedural model does not take it as an + * instruction.

    + */ + public int getMaxRetinueBodies() { + return maxRetinueBodies; + } + + /** State how many derived worlds this system asks for; negative reads as none. */ + public void setMaxRetinueBodies(int count) { + this.maxRetinueBodies = Math.max(0, count); + } + + /** 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; } @@ -63,13 +133,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() { @@ -80,6 +205,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; } @@ -115,11 +261,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; } @@ -233,7 +382,11 @@ public void writeToNBT(NBTTagCompound nbt) { nbt.setShort("posX", posX); nbt.setShort("posZ", posZ); nbt.setFloat("size", size); - nbt.setFloat("seperation", starSeperation); + if (mass > MASS_UNSET) { + nbt.setFloat("mass", mass); + } + nbt.setInteger("companionOrbit", orbitalDistance); + nbt.setDouble("companionTheta", getBaseTheta()); nbt.setBoolean("isBlackHole", isBlackHole); nbt.setFloat("diskAngle", diskAngle); @@ -261,8 +414,11 @@ public void readFromNBT(NBTTagCompound nbt) { if (nbt.hasKey("size")) size = nbt.getFloat("size"); - if (nbt.hasKey("seperation")) - starSeperation = nbt.getFloat("seperation"); + mass = nbt.hasKey("mass") ? nbt.getFloat("mass") : MASS_UNSET; + + if (nbt.hasKey("companionOrbit")) + orbitalDistance = nbt.getInteger("companionOrbit"); + baseTheta = nbt.hasKey("companionTheta") ? nbt.getDouble("companionTheta") : THETA_UNSTATED; subStars.clear(); if (nbt.hasKey("subStars")) { @@ -277,8 +433,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/FreeFlightHudState.java b/src/main/java/zmaster587/advancedRocketry/client/FreeFlightHudState.java index 6ed7d0810..80b10a2e7 100644 --- a/src/main/java/zmaster587/advancedRocketry/client/FreeFlightHudState.java +++ b/src/main/java/zmaster587/advancedRocketry/client/FreeFlightHudState.java @@ -38,8 +38,16 @@ public final class FreeFlightHudState { public final double bodyForward, bodyRight, bodyUp; /** Flight-Assist setpoints (body frame, blocks/tick). Valid iff {@link #hasVelocity}. */ public final double faForward, faRight, faUp; - /** Full-scale deflection of the HUD's velocity bars (blocks/tick) - the craft's own top speed, so - * each backend's bars use their whole width instead of a rocket-sized fraction of it. */ + /** + * Full-scale deflection of the HUD's velocity bars (blocks/tick). + * + *

    It is a reference cruise speed, NOT a maximum: free flight has no top speed with the + * assist off, so a bar drawn against a fixed full scale pegs and then tells the pilot nothing for + * the rest of the burn. The scale therefore starts at the craft's cruise reference — so ordinary + * flying looks exactly as it always did — and GROWS to the fastest axis whenever the craft is + * quicker than that. The bars stay a readable picture of the velocity vector's shape at any + * speed; the exact numbers are in the text readout beside them.

    + */ public final double barScale; /** @@ -55,9 +63,14 @@ public final class FreeFlightHudState { /** The coarse jump phase ({@code ShipTransitManager.Phase} ordinal); 0 = not in flight. */ public final int transitPhase; + /** + * @param cruiseReference the speed the bars are scaled against while the craft is no faster than + * it (blocks/tick); above it the scale follows the craft — see + * {@link #barScale} + */ private FreeFlightHudState(int tier, boolean inFlight, boolean flightAssistOn, boolean hasVelocity, double bodyForward, double bodyRight, double bodyUp, - double faForward, double faRight, double faUp, double barScale, + double faForward, double faRight, double faUp, double cruiseReference, int driveState, float driveCharge, int spoolTicks, int transitPhase) { this.driveState = driveState; this.driveCharge = driveCharge; @@ -73,7 +86,20 @@ private FreeFlightHudState(int tier, boolean inFlight, boolean flightAssistOn, b this.faForward = faForward; this.faRight = faRight; this.faUp = faUp; - this.barScale = barScale; + // Grow the scale to whatever the craft is actually doing, per axis and per setpoint, so no + // bar can peg. Both are included because with the assist on the pilot can dial a setpoint the + // craft has not reached yet, and a notch outside the bar is worse than no notch. + double widest = cruiseReference; + if (hasVelocity) { + widest = Math.max(widest, Math.abs(bodyForward)); + widest = Math.max(widest, Math.abs(bodyRight)); + widest = Math.max(widest, Math.abs(bodyUp)); + widest = Math.max(widest, Math.abs(faForward)); + widest = Math.max(widest, Math.abs(faRight)); + widest = Math.max(widest, Math.abs(faUp)); + } + // A NaN velocity (an un-synced backend) must not take the scale to NaN and blank the bars. + this.barScale = (Double.isNaN(widest) || widest <= 0.0) ? cruiseReference : widest; } /** Speed magnitude (blocks/tick) from the body-frame velocity; 0 when velocity is unknown. */ @@ -104,7 +130,7 @@ public static FreeFlightHudState forView(EntityPlayer player, World world) { return new FreeFlightHudState(1, rocket.isInFlight(), rocket.isFlightAssistOn(), true, act[0], act[1], act[2], rocket.getFaSetpointForward(), rocket.getFaSetpointRight(), rocket.getFaSetpointUp(), - FreeFlightPhysics.MAX_SPEED, + FreeFlightPhysics.FA_SETPOINT_MAX_SPEED, 0, 0f, 0, 0); } // The link alone is NOT evidence that a ship exists — it is a build-time intention that diff --git a/src/main/java/zmaster587/advancedRocketry/client/KeyBindings.java b/src/main/java/zmaster587/advancedRocketry/client/KeyBindings.java index ec8b593a7..f5b2882b2 100644 --- a/src/main/java/zmaster587/advancedRocketry/client/KeyBindings.java +++ b/src/main/java/zmaster587/advancedRocketry/client/KeyBindings.java @@ -17,6 +17,7 @@ import zmaster587.advancedRocketry.api.Constants; import zmaster587.advancedRocketry.api.EntityRocketBase; import zmaster587.advancedRocketry.api.FreeFlightInput; +import zmaster587.advancedRocketry.api.PilotInputCadence; import zmaster587.advancedRocketry.api.FreeFlightPhysics; import zmaster587.advancedRocketry.api.RocketFlightMode; import zmaster587.advancedRocketry.command.test.TestProbeCommandRegistration; @@ -135,6 +136,11 @@ public static float flightCursorY(float partialTicks) { /** PACKET_PILOT_INPUT packets this client actually dispatched to the seat. */ public static volatile int shipInputSendCount; + /** Client ticks of ship control, the clock {@link PilotInputCadence} counts its repeat + * interval on. Not a world time: it must keep counting while the world's own clock is + * whatever a loading screen left it at. */ + private static long shipInputTick; + public static boolean isCameraPinnedThisFlight() { return cameraPinValid; } @@ -716,7 +722,13 @@ private boolean handleShipPilotInput(Minecraft mc, EntityPlayerSP player) { hudPitchRate = pitch; FreeFlightInput input = new FreeFlightInput(fwd, vert, strafe, yaw, pitch, roll, brake, cut); - if (!input.equals(lastSentShipInput)) { + // A change goes out at once; a HELD non-idle input is also re-asserted on its seat's own + // phase. The server keeps this input on a tile INSTANCE, and an instance does not outlive a + // chunk reload — so under send-on-change alone a craft flies on with a command the server has + // forgotten and a pilot who has no way to know. See PilotInputCadence for the measurement. + shipInputTick++; + if (PilotInputCadence.shouldSend(input, lastSentShipInput, shipInputTick, + PilotInputCadence.phaseOfSeat(seatPos.getX(), seatPos.getY(), seatPos.getZ()))) { seat.pendingInput = input; PacketHandler.sendToServer(new PacketMachine(seat, TilePilotSeat.PACKET_PILOT_INPUT)); shipInputSendCount++; diff --git a/src/main/java/zmaster587/advancedRocketry/client/render/entity/RenderPlanetUIEntity.java b/src/main/java/zmaster587/advancedRocketry/client/render/entity/RenderPlanetUIEntity.java index 65ec1cd57..5e00a82cc 100644 --- a/src/main/java/zmaster587/advancedRocketry/client/render/entity/RenderPlanetUIEntity.java +++ b/src/main/java/zmaster587/advancedRocketry/client/render/entity/RenderPlanetUIEntity.java @@ -55,7 +55,10 @@ public void doRender(EntityUIPlanet entity, double x, double y, double z, if (properties == null) return; - float sizeScale = Math.max(properties.gravitationalMultiplier * properties.gravitationalMultiplier * entity.getScale(), .5f); + // Scaled by the body's RADIUS, not by gravity squared: gravity is derived from mass and radius, + // so sizing by g² sizes by mass²/radius⁴ and draws a dense small world larger than a big light + // one. A radius is what a drawn size is. + float sizeScale = Math.max((float) Math.max(properties.getRadius(), 0.5d) * entity.getScale(), .5f); GL11.glPushMatrix(); GL11.glTranslatef((float) x, (float) y + sizeScale * 0.03f, (float) z); @@ -187,7 +190,9 @@ public void doRender(EntityUIPlanet entity, double x, double y, double z, //Draw Mass indicator Minecraft.getMinecraft().renderEngine.bindTexture(planetUIFG); GlStateManager.color(1, 1, 1, 0.8f); - renderMassIndicator(buffer, Math.min(properties.gravitationalMultiplier / 2f, 1f)); + // The MASS indicator reads the mass. It read gravity, which is a different quantity and + // has been separately stored since mass became a primary property. + renderMassIndicator(buffer, (float) Math.min(properties.getMass() / 2d, 1d)); //Draw background GlStateManager.color(1, 1, 1, 1); diff --git a/src/main/java/zmaster587/advancedRocketry/client/render/planet/ApparentSize.java b/src/main/java/zmaster587/advancedRocketry/client/render/planet/ApparentSize.java index 2a919c34c..ffb098585 100644 --- a/src/main/java/zmaster587/advancedRocketry/client/render/planet/ApparentSize.java +++ b/src/main/java/zmaster587/advancedRocketry/client/render/planet/ApparentSize.java @@ -1,52 +1,85 @@ package zmaster587.advancedRocketry.client.render.planet; /** - * How big a fed body is drawn in the cell sky, given how far away it is. + * How big a fed body is drawn in the cell sky, given how far away it is and how big it is. * - *

    The rule is one sentence: strictly decreasing in distance, clamped at both ends. Both - * halves are contract, not polish. The fed range runs from a few thousand blocks (a moon in the - * observer's own cell) to ~109 (the far side of a system's neighbourhood), so an unclamped - * inverse law draws the star at a fraction of a pixel and the near body across the whole sky. And the - * renderer already drops a body whose direction vector is shorter than 10-6, i.e. a body - * vanishes exactly when it is closest — a maximum is what stops that being the only cue.

    + *

    The rule is one sentence: strictly increasing in the body's angular size, clamped at both + * ends. All three parts are contract, not polish.

    * - *

    The mapping is logarithmic because the range spans six decades: a linear one would put every - * body in a system at the minimum and leave the whole scale to be spent inside one cell. Which - * function it is, and the four numbers below, are {@code tunable} — what is contract is that it falls - * with distance and cannot leave {@code [MIN_HALF_SIZE, MAX_HALF_SIZE]}.

    + *

    Why the argument is a RATIO and not a distance. Until 2026-08-16 this took a distance + * alone, so every body at the same range drew the same disc: a moon and a gas giant beside each other + * were indistinguishable, and the only cue a sky gave was "near" versus "far". The honest quantity is + * the angle a body subtends, {@code r/d} — that is what makes a giant outdraw a moon at the same + * range and what makes flying closer grow a world.

    + * + *

    Why the compression stays. The fed range runs from a few thousand blocks (a moon in the + * observer's own cell) to ~109 (the far side of a system's neighbourhood), and radii span + * from a small moon to a star, so an unclamped inverse law draws the star at a fraction of a pixel and + * the near body across the whole sky. The renderer also drops a body whose direction vector is shorter + * than 10-6, i.e. a body vanishes exactly when it is closest — a maximum is what stops that + * being the only cue. So the ratio replaces the distance as the thing being compressed; it does not + * replace the compression. An unclamped angular size is correct and unreadable, and this + * renderer chose readable once, on purpose, with the reason written down.

    + * + *

    The mapping is logarithmic because the range spans many decades. Which function it is, and the + * four numbers below, are {@code tunable} — what is contract is that it RISES with {@code r/d} and + * cannot leave {@code [MIN_HALF_SIZE, MAX_HALF_SIZE]}.

    * *

    Pure arithmetic — no GL, no client state — so the rule can be checked without a client.

    */ public final class ApparentSize { - /** Half-size (in sky units) of a body at or beyond {@link #FAR_BLOCKS}. Never zero. {@code tunable}. */ + /** Half-size (in sky units) of a body at or below {@link #FAR_RATIO}. Never zero. {@code tunable}. */ public static final float MIN_HALF_SIZE = 1.5F; - /** Half-size of a body at or inside {@link #NEAR_BLOCKS}. {@code tunable}. */ + /** Half-size of a body at or above {@link #NEAR_RATIO}. {@code tunable}. */ public static final float MAX_HALF_SIZE = 16.0F; - /** At or below this distance a body is drawn at {@link #MAX_HALF_SIZE}. {@code tunable}. */ - public static final double NEAR_BLOCKS = 2_000d; - /** At or beyond this distance a body is drawn at {@link #MIN_HALF_SIZE}. {@code tunable}. */ - public static final double FAR_BLOCKS = 1.0e9; - private static final double LOG_NEAR = Math.log(NEAR_BLOCKS); - private static final double LOG_SPAN = Math.log(FAR_BLOCKS) - LOG_NEAR; + /** + * The angular size ({@code radius / distance}) at or above which a body is drawn at + * {@link #MAX_HALF_SIZE} — 0.1 rad, about 11 degrees of sky. + * + *

    It replaces a NEAR_BLOCKS of 2 000, which was a distance and therefore meant something + * different for every body: 2 000 blocks is deep inside an Earth (25 512 blocks of radius on the + * shipped chart metric) and a long way outside a small moon. {@code tunable}.

    + */ + public static final double NEAR_RATIO = 0.1d; + /** + * The angular size at or below which a body is drawn at {@link #MIN_HALF_SIZE} — 10-6 + * rad, roughly an Earth seen from a tenth of a light-hour. Below this a body is a point either + * way, and the floor is what keeps it visible at all. {@code tunable}. + */ + public static final double FAR_RATIO = 1.0e-6d; + + private static final double LOG_FAR = Math.log(FAR_RATIO); + private static final double LOG_SPAN = Math.log(NEAR_RATIO) - LOG_FAR; private ApparentSize() { } /** - * The half-size to draw a body at {@code distanceBlocks}. A non-finite or non-positive distance - * is the nearest thing there is, so it takes the maximum rather than becoming invisible. + * The half-size to draw a body of {@code radiusBlocks} seen from {@code distanceBlocks}. + * + *

    A body with no radius of its own ({@code radiusBlocks <= 0} — a belt, a station slot) is not + * a sphere and has no angular size; it takes {@link #MIN_HALF_SIZE}, the marker size, rather than + * being guessed at. A non-finite or non-positive DISTANCE is the nearest thing there is, so it + * takes the maximum rather than becoming invisible.

    */ - public static float halfSizeFor(double distanceBlocks) { - if (Double.isNaN(distanceBlocks) || distanceBlocks <= NEAR_BLOCKS) { + public static float halfSizeFor(double radiusBlocks, double distanceBlocks) { + if (Double.isNaN(radiusBlocks) || radiusBlocks <= 0d) { + return MIN_HALF_SIZE; + } + if (Double.isNaN(distanceBlocks) || distanceBlocks <= 0d) { + return MAX_HALF_SIZE; + } + double ratio = radiusBlocks / distanceBlocks; + if (ratio >= NEAR_RATIO) { return MAX_HALF_SIZE; } - if (distanceBlocks >= FAR_BLOCKS) { + if (ratio <= FAR_RATIO) { return MIN_HALF_SIZE; } - double t = (Math.log(distanceBlocks) - LOG_NEAR) / LOG_SPAN; - return (float) (MAX_HALF_SIZE + (MIN_HALF_SIZE - MAX_HALF_SIZE) * t); + double t = (Math.log(ratio) - LOG_FAR) / LOG_SPAN; + return (float) (MIN_HALF_SIZE + (MAX_HALF_SIZE - MIN_HALF_SIZE) * t); } /** diff --git a/src/main/java/zmaster587/advancedRocketry/client/render/planet/BoundarySky.java b/src/main/java/zmaster587/advancedRocketry/client/render/planet/BoundarySky.java index 1b9fcd270..68dd8254c 100644 --- a/src/main/java/zmaster587/advancedRocketry/client/render/planet/BoundarySky.java +++ b/src/main/java/zmaster587/advancedRocketry/client/render/planet/BoundarySky.java @@ -18,6 +18,7 @@ import zmaster587.advancedRocketry.dimension.DimensionProperties; import zmaster587.advancedRocketry.network.PacketSystemBodiesSync; import zmaster587.advancedRocketry.space.HyperspaceWorld; +import zmaster587.advancedRocketry.universe.Nebula; import zmaster587.advancedRocketry.universe.SystemBodyKind; import java.util.List; @@ -64,6 +65,13 @@ public class BoundarySky extends IRenderHandler { private static final float STAR_ALPHA = 0.9F; + /** Sky-frame radius the nebulae are emitted on. Outside the starfield: a cloud is the backdrop. */ + private static final float NEBULA_SKY_RADIUS = 105.0F; + /** Points around one cloud's rim. A cloud is soft, so it needs far fewer than a hard circle. */ + private static final int NEBULA_SEGMENTS = 24; + /** How bright the densest cloud may draw at its core. Haze, never a light source. */ + private static final float NEBULA_MAX_ALPHA = 0.45F; + /** * How many body labels the last frame actually drew. A counter rather than a flag: the contract * is that the toggle removes the label ENTIRELY, and "zero drawn while bodies were fed" is the @@ -82,6 +90,13 @@ public class BoundarySky extends IRenderHandler { */ public static volatile int boundariesDrawnLastFrame; + /** + * How many nebulae the last frame drew. Same shape and same reason as the two counters above: a + * cloud is haze with no edge, so "is one on the screen" is a question pixels answer badly and the + * renderer answers exactly. Read it beside {@link #skyFramesDrawn}, never alone. + */ + public static volatile int nebulaeDrawnLastFrame; + /** * Frames on which this sky renderer ran AT ALL, counted before any branch inside it. * @@ -124,9 +139,10 @@ public void render(float partialTicks, WorldClient world, Minecraft mc) { GlStateManager.disableTexture2D(); - // Stars first: the billboards are meant to sit in front of them. - GlStateManager.color(1.0F, 1.0F, 1.0F, STAR_ALPHA); - GL11.glCallList(this.glStarList); + // The backdrop: the clouds and the starfield, in the one order that is right for both. The + // billboards below are meant to sit in front of all of it. + nebulaeDrawnLastFrame = drawBackdrop( + PacketSystemBodiesSync.nebulaeForDim(world.provider.getDimension())); // In hyperspace this same provider serves the transit lanes, and the two things below are // both wrong there: the ring marks a descent boundary in a world nothing descends to, and @@ -172,6 +188,139 @@ public void render(float partialTicks, WorldClient world, Minecraft mc) { restoreState(); } + /** + * Draw the backdrop of this cell's sky — the clouds and the starfield — and return how many clouds + * were emitted. + * + *

    The starfield is drawn here and exactly once, between the two cloud passes, because + * where it belongs is the whole point of the ordering and splitting it across two methods is how + * a sky comes to have no stars in it (or two sets of them).

    + * + *

    A dark cloud goes AFTER the stars and the other two before them, and that is not a + * flourish: the three appearances are one age sequence, and a molecular cloud is visible precisely + * because it BLOTS OUT what is behind it. Drawn behind the starfield like the other two it would + * paint near-black on black and render as nothing at all — one of the three appearances silently + * missing, which reads as a bug and is indistinguishable from one.

    + */ + private int drawBackdrop(List clouds) { + int drawn = 0; + BufferBuilder buffer = Tessellator.getInstance().getBuffer(); + // Culling OFF while the fans are emitted. They sit on a sphere the camera is INSIDE, which is + // the one case where a winding mistake is silent — the class note above records what that + // costs. A cloud has no facing to get wrong, so the honest fix is to stop asking. + if (clouds != null && !clouds.isEmpty()) { + GlStateManager.disableCull(); + for (PacketSystemBodiesSync.RenderNebula cloud : clouds) { + if (!isDark(cloud)) { + drawn += drawNebula(buffer, cloud) ? 1 : 0; + } + } + GlStateManager.enableCull(); + } + + GlStateManager.color(1.0F, 1.0F, 1.0F, STAR_ALPHA); + GL11.glCallList(this.glStarList); + + if (clouds != null && !clouds.isEmpty()) { + GlStateManager.disableCull(); + for (PacketSystemBodiesSync.RenderNebula cloud : clouds) { + if (isDark(cloud)) { + drawn += drawNebula(buffer, cloud) ? 1 : 0; + } + } + GlStateManager.enableCull(); + } + return drawn; + } + + /** Whether this cloud is the young, thick, star-forming kind — the one that hides what is behind it. */ + private static boolean isDark(PacketSystemBodiesSync.RenderNebula cloud) { + return cloud.appearanceOrdinal == Nebula.Appearance.DARK.ordinal(); + } + + /** + * One cloud: a fan on the sky sphere about its bearing, opaque at the core and fading to nothing + * at the rim. Returns whether anything was emitted. + * + *

    The falloff is in the VERTEX COLOURS rather than in a texture, because a nebula's edge is a + * Gaussian with no edge — {@code Nebula.densityAt} says so — and an alpha that reaches zero at the + * rim is what makes the primitive's own boundary invisible. A textured quad would draw a square of + * haze with four corners in it.

    + * + *

    Sampled as {@code cosθ·n + sinθ·(cosφ·u + sinφ·v)}, the same construction the atmosphere + * boundary uses and for the same reason: on the sphere there is no singularity, so a viewer INSIDE + * a cloud (θ = 90°) gets a hemisphere of haze rather than a divide-by-zero.

    + */ + private boolean drawNebula(BufferBuilder buffer, PacketSystemBodiesSync.RenderNebula cloud) { + double nx = cloud.dirX; + double ny = cloud.dirY; + double nz = cloud.dirZ; + double len = Math.sqrt(nx * nx + ny * ny + nz * nz); + if (len < 1.0E-6D || cloud.angularRadius <= 0.0F || cloud.opacity <= 0.0F) { + return false; + } + nx /= len; + ny /= len; + nz /= len; + + // Any axis n is not parallel to spans the perpendicular plane with it; take the one it is + // LEAST aligned with, so a cloud lying along a world axis does not degenerate. + double hx = 0.0D, hy = 0.0D, hz = 0.0D; + double ax = Math.abs(nx), ay = Math.abs(ny), az = Math.abs(nz); + if (ax <= ay && ax <= az) { + hx = 1.0D; + } else if (ay <= az) { + hy = 1.0D; + } else { + hz = 1.0D; + } + double ux = ny * hz - nz * hy, uy = nz * hx - nx * hz, uz = nx * hy - ny * hx; + double ul = Math.sqrt(ux * ux + uy * uy + uz * uz); + if (ul < 1.0E-9D) { + return false; + } + ux /= ul; uy /= ul; uz /= ul; + double vx = ny * uz - nz * uy, vy = nz * ux - nx * uz, vz = nx * uy - ny * ux; + + float[] tint = tintOf(cloud); + float alpha = Math.min(NEBULA_MAX_ALPHA, cloud.opacity * NEBULA_MAX_ALPHA); + double theta = Math.min(Math.PI / 2.0D, cloud.angularRadius); + double ct = Math.cos(theta), st = Math.sin(theta); + + buffer.begin(GL11.GL_TRIANGLE_FAN, DefaultVertexFormats.POSITION_COLOR); + buffer.pos(nx * NEBULA_SKY_RADIUS, ny * NEBULA_SKY_RADIUS, nz * NEBULA_SKY_RADIUS) + .color(tint[0], tint[1], tint[2], alpha).endVertex(); + for (int i = 0; i <= NEBULA_SEGMENTS; i++) { + double phi = (Math.PI * 2.0D * i) / NEBULA_SEGMENTS; + double cp = Math.cos(phi), sp = Math.sin(phi); + buffer.pos((ct * nx + st * (cp * ux + sp * vx)) * NEBULA_SKY_RADIUS, + (ct * ny + st * (cp * uy + sp * vy)) * NEBULA_SKY_RADIUS, + (ct * nz + st * (cp * uz + sp * vz)) * NEBULA_SKY_RADIUS) + .color(tint[0], tint[1], tint[2], 0.0F).endVertex(); + } + Tessellator.getInstance().draw(); + return true; + } + + /** + * What a cloud is coloured, by its age. Not a palette choice: the sequence is physical — cold + * molecular gas is nearly black, gas ionised by the stars inside it emits in hydrogen red, and + * what is left once the gas is blown clear is dust reflecting the blue it scatters best. + */ + private static float[] tintOf(PacketSystemBodiesSync.RenderNebula cloud) { + Nebula.Appearance[] looks = Nebula.Appearance.values(); + Nebula.Appearance look = cloud.appearanceOrdinal >= 0 && cloud.appearanceOrdinal < looks.length + ? looks[cloud.appearanceOrdinal] : Nebula.Appearance.REFLECTION; + switch (look) { + case DARK: + return new float[] {0.04F, 0.03F, 0.06F}; + case EMISSION: + return new float[] {0.85F, 0.25F, 0.35F}; + default: + return new float[] {0.35F, 0.50F, 0.90F}; + } + } + /** * Draw {@code body}'s atmosphere boundary: the circle on the sky where its shell meets the * viewer's line of sight. Returns whether anything was emitted. @@ -276,10 +425,11 @@ private boolean drawBody(BufferBuilder buffer, PacketSystemBodiesSync.RenderBody float yaw = (float) Math.toDegrees(Math.atan2(nx, nz)); float pitch = (float) Math.toDegrees(Math.asin(Math.max(-1.0F, Math.min(1.0F, ny)))); - // The vector's LENGTH is the true distance to the body at the broadcast tick, so apparent - // size follows it. A fixed size made a moon at 3 km and one at 59 km indistinguishable, and - // left "the planet is crawling away" a thing the sky could not show at all. - float half = ApparentSize.halfSizeFor(len); + // Apparent size follows the ANGLE the body subtends — its own radius over the true distance + // at the broadcast tick. Distance alone made a moon at 3 km and one at 59 km + // indistinguishable; radius alone would not move as a ship approaches. Both, and a giant + // beside a moon finally looks like one. + float half = ApparentSize.halfSizeFor(body.radiusBlocks, len); // The STRICT dimension lookup: the lenient one answers an unknown dimension with the // OVERWORLD's properties, so the star -- which has no dimension of its own -- was drawn 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..4fa453f95 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); @@ -573,7 +573,7 @@ public void render(float partialTicks, WorldClient world, Minecraft mc) { shadowColorTmp[2] = f3; renderPlanet(buffer, parentProperties, planetOrbitalDistance, multiplier, rotation, false, parentHasRings, - (float) Math.pow(parentProperties.getGravitationalMultiplier(), 0.4), shadowColorTmp, 1); + skyRadiusEarths(parentProperties), shadowColorTmp, 1); xrotangle = 0; GL11.glPopMatrix(); } @@ -599,7 +599,7 @@ public void render(float partialTicks, WorldClient world, Minecraft mc) { shadowColorTmp[2] = f3; renderPlanet(buffer, moons, moons.getParentOrbitalDistance(), multiplier, rotation, moons.hasAtmosphere(), - moons.hasRings, (float) Math.pow(moons.gravitationalMultiplier, 0.4), shadowColorTmp, 1); + moons.hasRings, skyRadiusEarths(moons), shadowColorTmp, 1); GL11.glPopMatrix(); } } @@ -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); @@ -681,8 +681,12 @@ protected EnumFacing getRotationAxis(DimensionProperties properties, BlockPos po return EnumFacing.EAST; } - protected void renderPlanet(BufferBuilder buffer, DimensionProperties properties, float planetOrbitalDistance, float alphaMultiplier, double shadowAngle, boolean hasAtmosphere, boolean hasRing, float gravitationalMultiplier, float[] shadowColorMultiplier, float alphaMultiplier2) { - renderPlanet2(buffer, properties, 20f * AstronomicalBodyHelper.getBodySizeMultiplier(planetOrbitalDistance) * gravitationalMultiplier, alphaMultiplier, shadowAngle, hasRing, shadowColorMultiplier, alphaMultiplier2); + protected void renderPlanet(BufferBuilder buffer, DimensionProperties properties, float separationToObserver, float alphaMultiplier, double shadowAngle, boolean hasAtmosphere, boolean hasRing, float radiusEarths, float[] shadowColorMultiplier, float alphaMultiplier2) { + // Size is the body's OWN radius over the distance to it. It used to be its surface GRAVITY + // over that distance, which drew two worlds of equal size at different sizes and two worlds + // of different size at the same one whenever their densities happened to agree. The scale + // constant is unchanged, so a body of one Earth radius draws exactly as it always did. + renderPlanet2(buffer, properties, 20f * AstronomicalBodyHelper.getBodySizeMultiplier(separationToObserver) * radiusEarths, alphaMultiplier, shadowAngle, hasRing, shadowColorMultiplier, alphaMultiplier2); } protected void renderPlanet2(BufferBuilder buffer, DimensionProperties properties, float size, float alphaMultiplier, double shadowAngle, boolean hasRing, float[] shadowColorMultiplier, float alphaMultiplier2) { @@ -865,4 +869,21 @@ protected void drawStar(BufferBuilder buffer, StellarBody sun, DimensionProperti Tessellator.getInstance().draw(); } } + + /** + * The radius a SKY draws this body at, in Earth radii — its stated radius, or one Earth radius + * when nobody has stated one. + * + *

    The fallback is the same reading the orbital maths already takes ({@code getMoonOrbitalPeriod}: + * a body with no stated bulk is a body assumed to be one Earth radius across), and it is what keeps + * every authored planet looking exactly as it did before bodies had sizes. The alternative — + * treating an unset radius as zero — would make every un-edited world vanish from every sky.

    + */ + protected static float skyRadiusEarths(DimensionProperties properties) { + if (properties == null) { + return 1f; + } + double r = properties.getRadius(); + return r > 0d ? (float) r : 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..105f4a4b1 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); @@ -904,7 +904,7 @@ else if (afloat != null && (planetPositionTheta < 105 || planetPositionTheta > 2 shadowColorMultiplier = new float[]{shadowColorMultiplier[0] * (1 - multiplier) + f1 * multiplier, shadowColorMultiplier[1] * (1 - multiplier) + f2 * multiplier, shadowColorMultiplier[2] * (1 - multiplier) + f3 * multiplier}; } //System.out.println("draw moon (renderplanet"); - renderPlanet(buffer, parentProperties, planetOrbitalDistance, multiplier, rotation, false, parentHasRings, (float) Math.pow(parentProperties.getGravitationalMultiplier(), 0.4), shadowColorMultiplier, alpha2); + renderPlanet(buffer, parentProperties, planetOrbitalDistance, multiplier, rotation, false, parentHasRings, skyRadiusEarths(parentProperties), shadowColorMultiplier, alpha2); xrotangle = 0; GL11.glPopMatrix(); } @@ -939,7 +939,7 @@ else if (afloat != null && (planetPositionTheta < 105 || planetPositionTheta > 2 shadowColorMultiplier = afloat; shadowColorMultiplier = new float[]{shadowColorMultiplier[0] * (1 - multiplier) + f1 * multiplier, shadowColorMultiplier[1] * (1 - multiplier) + f2 * multiplier, shadowColorMultiplier[2] * (1 - multiplier) + f3 * multiplier}; } - renderPlanet(buffer, moons, moons.getParentOrbitalDistance(), multiplier, rotation, moons.hasAtmosphere(), moons.hasRings, (float) Math.pow(moons.gravitationalMultiplier, 0.4), shadowColorMultiplier, alpha2); + renderPlanet(buffer, moons, moons.getParentOrbitalDistance(), multiplier, rotation, moons.hasAtmosphere(), moons.hasRings, skyRadiusEarths(moons), shadowColorMultiplier, alpha2); GL11.glPopMatrix(); } } @@ -1101,8 +1101,12 @@ protected EnumFacing getRotationAxis(DimensionProperties properties, BlockPos po return EnumFacing.EAST; } - protected void renderPlanet(BufferBuilder buffer, DimensionProperties properties, float planetOrbitalDistance, float alphaMultiplier, double shadowAngle, boolean hasAtmosphere, boolean hasRing, float gravitationalMultiplier, float[] shadowColorMultiplier, float alphaMultiplier2) { - renderPlanet2(buffer, properties, 20f * AstronomicalBodyHelper.getBodySizeMultiplier(planetOrbitalDistance) * gravitationalMultiplier, alphaMultiplier, shadowAngle, hasRing, shadowColorMultiplier, alphaMultiplier2); + protected void renderPlanet(BufferBuilder buffer, DimensionProperties properties, float separationToObserver, float alphaMultiplier, double shadowAngle, boolean hasAtmosphere, boolean hasRing, float radiusEarths, float[] shadowColorMultiplier, float alphaMultiplier2) { + // Size is the body's OWN radius over the distance to it. It used to be its surface GRAVITY + // over that distance, which drew two worlds of equal size at different sizes and two worlds + // of different size at the same one whenever their densities happened to agree. The scale + // constant is unchanged, so a body of one Earth radius draws exactly as it always did. + renderPlanet2(buffer, properties, 20f * AstronomicalBodyHelper.getBodySizeMultiplier(separationToObserver) * radiusEarths, alphaMultiplier, shadowAngle, hasRing, shadowColorMultiplier, alphaMultiplier2); } protected void renderPlanet2(BufferBuilder buffer, DimensionProperties properties, float size, float alphaMultiplier, double shadowAngle, boolean hasRing, float[] shadowColorMultiplier, float alphaMultiplier2) { @@ -1146,7 +1150,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(); @@ -1373,4 +1377,21 @@ protected void drawStar(BufferBuilder buffer, StellarBody sun, DimensionProperti Tessellator.getInstance().draw(); } } + + /** + * The radius a SKY draws this body at, in Earth radii — its stated radius, or one Earth radius + * when nobody has stated one. + * + *

    The fallback is the same reading the orbital maths already takes ({@code getMoonOrbitalPeriod}: + * a body with no stated bulk is a body assumed to be one Earth radius across), and it is what keeps + * every authored planet looking exactly as it did before bodies had sizes. The alternative — + * treating an unset radius as zero — would make every un-edited world vanish from every sky.

    + */ + protected static float skyRadiusEarths(DimensionProperties properties) { + if (properties == null) { + return 1f; + } + double r = properties.getRadius(); + return r > 0d ? (float) r : 1f; + } } 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/command/ARCommandRoot.java b/src/main/java/zmaster587/advancedRocketry/command/ARCommandRoot.java index e1e3aa3c9..d37798df9 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/ARCommandRoot.java +++ b/src/main/java/zmaster587/advancedRocketry/command/ARCommandRoot.java @@ -15,6 +15,7 @@ import zmaster587.advancedRocketry.command.sub.station.StationCommand; import zmaster587.advancedRocketry.command.sub.teleport.FetchCommand; import zmaster587.advancedRocketry.command.sub.teleport.GoToCommand; +import zmaster587.advancedRocketry.command.sub.universe.UniverseCommand; import javax.annotation.Nullable; import java.util.ArrayList; @@ -28,6 +29,7 @@ public ARCommandRoot() { aliases.add("advancedrocketry"); aliases.add("advrocketry"); aliases.add("ar"); + aliases.add("stellurgy"); addSubcommand(new WeatherCommand()); addSubcommand(new AddSealantCommand()); @@ -40,6 +42,7 @@ public ARCommandRoot() { addSubcommand(new StationCommand()); addSubcommand(new GoToCommand()); addSubcommand(new FillDataCommand()); + addSubcommand(new UniverseCommand()); addSubcommand(new DevCommand()); addSubcommand(new CommandTreeHelp(this)); diff --git a/src/main/java/zmaster587/advancedRocketry/command/sub/planet/PlanetCommand.java b/src/main/java/zmaster587/advancedRocketry/command/sub/planet/PlanetCommand.java index 022ad95ec..2795c6f48 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/sub/planet/PlanetCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/sub/planet/PlanetCommand.java @@ -8,8 +8,8 @@ public class PlanetCommand extends CommandTreeBase { public PlanetCommand() { addSubcommand(new PlanetResetCommand()); addSubcommand(new PlanetListCommand()); - addSubcommand(new PlanetDeleteCommand()); addSubcommand(new PlanetGenerateCommand()); + addSubcommand(new PlanetDeleteCommand()); addSubcommand(new PlanetSetCommand()); addSubcommand(new PlanetGetCommand()); addSubcommand(new PlanetWeatherCommand()); diff --git a/src/main/java/zmaster587/advancedRocketry/command/sub/planet/PlanetGenerateCommand.java b/src/main/java/zmaster587/advancedRocketry/command/sub/planet/PlanetGenerateCommand.java index 4c17da038..bd4d2218c 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/sub/planet/PlanetGenerateCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/sub/planet/PlanetGenerateCommand.java @@ -5,15 +5,35 @@ import net.minecraft.server.MinecraftServer; import net.minecraft.util.math.BlockPos; import net.minecraft.util.text.TextComponentTranslation; +import zmaster587.advancedRocketry.api.Constants; +import zmaster587.advancedRocketry.api.dimension.solar.StellarBody; import zmaster587.advancedRocketry.command.sub.ARCommand; import zmaster587.advancedRocketry.dimension.DimensionManager; import zmaster587.advancedRocketry.dimension.DimensionProperties; +import zmaster587.advancedRocketry.space.GalacticCoord; +import zmaster587.advancedRocketry.universe.BodyProfile; +import zmaster587.advancedRocketry.universe.PlanetDerivation; import javax.annotation.Nullable; import java.util.Collections; import java.util.List; +/** + * {@code /ar planet generate [moon] } — mint one world in a system. + * + *

    It derives, it does not roll. This command used to front the legacy random generator: + * three "randomness" arguments fed {@code new Random(System.currentTimeMillis())}, so the same command + * on the same world produced a different planet every time and the mod carried two world-making + * models that answered the same question differently. The randomness arguments are gone with the + * model behind them, and the world now comes from the ONE derivation everything else uses + * ({@link PlanetDerivation}), keyed on the star and on how many worlds it already has — so running + * this twice on a fresh world of the same seed gives the same two planets, in the same order.

    + * + *

    What survives unchanged: the name is the operator's, the {@code moon} form parents the new world + * on an existing planet, and exactly one dimension is registered per invocation.

    + */ public class PlanetGenerateCommand extends ARCommand { + @Override public String getName() { return "generate"; @@ -26,102 +46,75 @@ public String getUsage(ICommandSender sender) { @Override public void execute(MinecraftServer server, ICommandSender sender, String[] args) throws CommandException { - if (args.length < 1 || args.length > 10) { + if (args.length < 2 || args.length > 3) { throw wrongUsage(sender); } - int starId = parseInt(args[0]); - // Offset beginning after the id - int modOffset = 1; - boolean moon = false; - boolean gas = false; - if (args.length > modOffset && args[modOffset].equalsIgnoreCase("moon")) { - modOffset++; - moon = true; - if (!DimensionManager.getInstance().isDimensionCreated(starId)) { - throw invalidValue("Planet with id", starId); - } - } else if (DimensionManager.getInstance().getStar(starId) == null) { - throw invalidValue("Star with id", starId); + int id = parseInt(args[0]); + int offset = 1; + boolean moon = args.length == 3 && args[offset].equalsIgnoreCase("moon"); + if (moon) { + offset++; } - - if (args.length > modOffset && args[modOffset].equalsIgnoreCase("gas")) { - modOffset++; - gas = true; + if (offset >= args.length) { + throw wrongUsage(sender); } + String name = args[offset]; - // First 3 args are randomness, last 3 args are base value - boolean randArgs = args.length == modOffset + 1 + 3; - boolean fullArgs = args.length == modOffset + 1 + 6; - if (randArgs || fullArgs) { - int planetId = starId; - if (moon) { - starId = DimensionManager.getInstance().getDimensionProperties(planetId).getStarId(); - // The moon branch skips the non-moon star-existence guard (see the - // else-if above), then feeds this re-derived starId to generateRandom - // (which dereferences getStar) and to getStar(...).removePlanet below - // — both NPE if the parent planet's star id resolves to no star. - // Fail with a clean command error instead, mirroring the non-moon guard. - if (DimensionManager.getInstance().getStar(starId) == null) { - throw invalidValue("Star with id", starId); - } + int starId; + DimensionProperties parent = null; + if (moon) { + parent = DimensionManager.getInstance().getDimensionProperties(id); + if (parent == null || !DimensionManager.getInstance().isDimensionCreated(id)) { + throw invalidValue("Planet with id", id); } - DimensionProperties props; - int argsOffset = modOffset; - if (gas) { - if (randArgs) { - // Defaults are from DimensionManager#generateRandomPlanets() - props = DimensionManager.getInstance().generateRandomGasGiant(starId, args[argsOffset++], - 150, 180, 125, - parseInt(args[argsOffset++]), parseInt(args[argsOffset++]), parseInt(args[argsOffset])); - } else { - // Method params are flipped... - String name = args[argsOffset++]; - int atmosphereFactor = parseInt(args[argsOffset++]); - int distanceFactor = parseInt(args[argsOffset++]); - int gravityFactor = parseInt(args[argsOffset++]); - props = DimensionManager.getInstance().generateRandomGasGiant(starId, name, - parseInt(args[argsOffset++]), parseInt(args[argsOffset++]), parseInt(args[argsOffset]), - atmosphereFactor, distanceFactor, gravityFactor); - } - } else { - if (randArgs) { - props = DimensionManager.getInstance().generateRandom(starId, args[argsOffset++], - parseInt(args[argsOffset++]), parseInt(args[argsOffset++]), parseInt(args[argsOffset])); - } else { - // Method params are flipped... - String name = args[argsOffset++]; - int atmosphereFactor = parseInt(args[argsOffset++]); - int distanceFactor = parseInt(args[argsOffset++]); - int gravityFactor = parseInt(args[argsOffset++]); - props = DimensionManager.getInstance().generateRandom(starId, name, - parseInt(args[argsOffset++]), parseInt(args[argsOffset++]), parseInt(args[argsOffset]), - atmosphereFactor, distanceFactor, gravityFactor); - } + starId = parent.getStarId(); + // The parent's star must exist before anything is derived from it: the derivation reads + // the star's own physics, and a planet whose star id resolves to nothing would otherwise + // fail deep inside it rather than here, where the operator can read why. + if (DimensionManager.getInstance().getStar(starId) == null) { + throw invalidValue("Star with id", starId); } - if (props == null) { - throw new CommandException("commands.advancedrocketry.planet.generate.invalid", args[modOffset]); - } else { - sender.sendMessage(new TextComponentTranslation("commands.advancedrocketry.planet.generate.success", args[modOffset])); + } else { + starId = id; + if (DimensionManager.getInstance().getStar(starId) == null) { + throw invalidValue("Star with id", starId); } + } - // If [moon] specified, the generated dim should be a moon orbiting planetId instead of a planet orbiting starId. - if (moon) { - props.setParentPlanet(DimensionManager.getInstance().getDimensionProperties(planetId)); - DimensionManager.getInstance().getStar(starId).removePlanet(props); - } - } else { - throw wrongUsage(sender); + StellarBody star = DimensionManager.getInstance().getStar(starId); + // The index is how many worlds this star already holds, so a second call derives a DIFFERENT + // world rather than the same one again — and the sequence is reproducible on a fresh world. + int index = star.getNumPlanets(); + GalacticCoord anchor = GalacticCoord.ofSectorLocal(starId, 0L, 0L, 0L, 0L, 0L); + zmaster587.advancedRocketry.universe.IBodyDerivation derivation = + zmaster587.advancedRocketry.universe.UniverseRegistry.getGenerator().derivation(); + int orbit = derivation.orbitalDistanceOf(server.getWorld(0).getSeed(), anchor, index, + Math.max(1, index + 1), star); + BodyProfile profile = derivation.derive(server.getWorld(0).getSeed(), anchor, anchor, + index, star, moon, orbit); + + int dimId = DimensionManager.getInstance().getNextFreeDim(2); + DimensionProperties props = new DimensionProperties(dimId); + props.setName(name); + props.setStar(star); + props.orbitalDist = orbit; + props.setBulk(profile.massEarths(), profile.radiusEarths()); + props.gravitationalMultiplier = profile.gravityPercent() / 100f; + props.setAtmosphereDensityDirect(profile.pressure()); + props.setAverageTemp(profile.temperatureKelvin()); + props.initDefaultAttributes(); + if (moon) { + props.setParentPlanet(parent); } + if (!DimensionManager.getInstance().registerDim(props, true)) { + throw new CommandException("commands.advancedrocketry.planet.generate.invalid", name); + } + sender.sendMessage(new TextComponentTranslation("commands.advancedrocketry.planet.generate.success", name)); } @Override - public List getTabCompletions(MinecraftServer server, ICommandSender sender, String[] args, @Nullable BlockPos targetPos) { - if (args.length == 2) { - return getListOfStringsMatchingLastWord(args, "moon", "gas"); - } - if (args.length == 3) { - return Collections.singletonList("gas"); - } + public List getTabCompletions(MinecraftServer server, ICommandSender sender, String[] args, + @Nullable BlockPos targetPos) { return Collections.emptyList(); } } diff --git a/src/main/java/zmaster587/advancedRocketry/command/sub/universe/UniverseCommand.java b/src/main/java/zmaster587/advancedRocketry/command/sub/universe/UniverseCommand.java new file mode 100644 index 000000000..f37f57056 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/command/sub/universe/UniverseCommand.java @@ -0,0 +1,28 @@ +package zmaster587.advancedRocketry.command.sub.universe; + +import net.minecraft.command.ICommandSender; +import net.minecraftforge.server.command.CommandTreeBase; +import net.minecraftforge.server.command.CommandTreeHelp; + +/** + * Operator commands for the world model a save was generated under: what it is, and how to move a + * world onto a newer one deliberately. + */ +public class UniverseCommand extends CommandTreeBase { + + public UniverseCommand() { + addSubcommand(new UniverseStatusCommand()); + addSubcommand(new UniverseUpgradeCommand()); + addSubcommand(new CommandTreeHelp(this)); + } + + @Override + public String getName() { + return "universe"; + } + + @Override + public String getUsage(ICommandSender sender) { + return "commands.advancedrocketry.universe.usage"; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/command/sub/universe/UniverseStatusCommand.java b/src/main/java/zmaster587/advancedRocketry/command/sub/universe/UniverseStatusCommand.java new file mode 100644 index 000000000..e224420d9 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/command/sub/universe/UniverseStatusCommand.java @@ -0,0 +1,76 @@ +package zmaster587.advancedRocketry.command.sub.universe; + +import java.util.List; + +import net.minecraft.command.CommandException; +import net.minecraft.command.ICommandSender; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.text.TextComponentTranslation; +import zmaster587.advancedRocketry.command.sub.ARCommand; +import zmaster587.advancedRocketry.universe.GalaxyGenConfig; +import zmaster587.advancedRocketry.universe.UniverseRegistry; +import zmaster587.advancedRocketry.universe.UniverseSchemas; + +/** + * What world model this save runs on, what the pack currently states, and how much of the universe has + * already been frozen by being seen. + * + *

    Read-only, and the first thing to run when a load has been refused: it names both sides of the + * comparison that refused it.

    + */ +public class UniverseStatusCommand extends ARCommand { + + @Override + public String getName() { + return "status"; + } + + @Override + public String getUsage(ICommandSender sender) { + return "commands.advancedrocketry.universe.status.usage"; + } + + @Override + public void execute(MinecraftServer server, ICommandSender sender, String[] args) + throws CommandException { + if (args.length > 0) { + throw wrongUsage(sender); + } + UniverseRegistry registry = UniverseRegistry.get(server); + if (registry == null) { + throw new CommandException("commands.advancedrocketry.universe.unavailable"); + } + GalaxyGenConfig pack = UniverseRegistry.packGalaxyConfig(); + String packFingerprint = UniverseRegistry.fingerprintOf(pack); + + sender.sendMessage(new TextComponentTranslation( + "commands.advancedrocketry.universe.status.schema", + registry.schemaVersion(), UniverseSchemas.CURRENT)); + UniverseRegistry.activeSchema().ifPresent(schema -> sender.sendMessage( + new TextComponentTranslation(schema.isStable() + ? "commands.advancedrocketry.universe.status.stable" + : "commands.advancedrocketry.universe.status.alpha", schema.label()))); + sender.sendMessage(new TextComponentTranslation( + "commands.advancedrocketry.universe.status.config", + registry.configFingerprint(), packFingerprint)); + sender.sendMessage(new TextComponentTranslation( + registry.configFingerprint().equals(packFingerprint) + ? "commands.advancedrocketry.universe.status.agrees" + : "commands.advancedrocketry.universe.status.differs")); + sender.sendMessage(new TextComponentTranslation( + "commands.advancedrocketry.universe.status.frozen", registry.pinnedSystemCount())); + sender.sendMessage(new TextComponentTranslation( + "commands.advancedrocketry.universe.status.released", + UniverseSchemas.released().toString())); + if (registry.isUpgradeArmed()) { + sender.sendMessage(new TextComponentTranslation( + "commands.advancedrocketry.universe.status.armed")); + } + } + + @Override + public List getTabCompletions(MinecraftServer server, ICommandSender sender, String[] args, + net.minecraft.util.math.BlockPos targetPos) { + return java.util.Collections.emptyList(); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/command/sub/universe/UniverseUpgradeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/sub/universe/UniverseUpgradeCommand.java new file mode 100644 index 000000000..baee72bc1 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/command/sub/universe/UniverseUpgradeCommand.java @@ -0,0 +1,132 @@ +package zmaster587.advancedRocketry.command.sub.universe; + +import java.util.List; + +import net.minecraft.command.CommandException; +import net.minecraft.command.ICommandSender; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.item.ItemStack; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.text.TextComponentTranslation; +import zmaster587.advancedRocketry.command.sub.ARCommand; +import zmaster587.advancedRocketry.item.ItemMemoryCrystal; +import zmaster587.advancedRocketry.navigation.CrystalEntry; +import zmaster587.advancedRocketry.universe.GalaxyGenConfig; +import zmaster587.advancedRocketry.universe.UniverseRegistry; +import zmaster587.advancedRocketry.universe.UniverseSchema; + +/** + * Move this world onto the model the pack and this build now state — deliberately, and only after + * everything already seen has been frozen where it stands. + * + *

    What it does, in order. Every address anybody has written down is pinned first: the systems + * already in the override store are immutable by construction, and every address on a memory crystal is + * pinned here. Only then is the new stamp written. The result is a seam at the frontier of the + * explored — charted space keeps its contents, unexplored space is re-derived under the new model — + * and that seam is the player's own choice, which is why this is a command and not a migration that + * runs itself at load. + * + *

    What it cannot reach, and says so. A crystal in a chest, in an unloaded chunk, or in the + * inventory of a player who is offline is not readable from here. Bring the crystals that matter to + * players who are online before running it. + * + *

    What arrives without content. Mechanics a newer model introduces do not retrofit into space + * that is already frozen: a world upgraded halfway through a campaign keeps its charted systems exactly + * as they were, and meets the new ones only further out. That belongs in a changelog, not in a fix. + */ +public class UniverseUpgradeCommand extends ARCommand { + + private static final String CONFIRM = "confirm"; + + @Override + public String getName() { + return "upgrade"; + } + + @Override + public String getUsage(ICommandSender sender) { + return "commands.advancedrocketry.universe.upgrade.usage"; + } + + @Override + public void execute(MinecraftServer server, ICommandSender sender, String[] args) + throws CommandException { + if (args.length > 1 || (args.length == 1 && !CONFIRM.equalsIgnoreCase(args[0]))) { + throw wrongUsage(sender); + } + UniverseRegistry registry = UniverseRegistry.get(server); + if (registry == null) { + throw new CommandException("commands.advancedrocketry.universe.unavailable"); + } + GalaxyGenConfig pack = UniverseRegistry.packGalaxyConfig(); + String target = UniverseRegistry.fingerprintOf(pack); + + if (args.length == 0) { + sender.sendMessage(new TextComponentTranslation( + "commands.advancedrocketry.universe.upgrade.preview", + registry.configFingerprint(), target, registry.pinnedSystemCount())); + sender.sendMessage(new TextComponentTranslation( + "commands.advancedrocketry.universe.upgrade.reach", + server.getPlayerList().getCurrentPlayerCount())); + sender.sendMessage(new TextComponentTranslation( + "commands.advancedrocketry.universe.upgrade.confirm")); + return; + } + + int crystals = 0; + int addresses = 0; + int frozen = 0; + for (EntityPlayerMP player : server.getPlayerList().getPlayers()) { + for (ItemStack stack : carried(player)) { + if (!ItemMemoryCrystal.isCrystal(stack)) { + continue; + } + crystals++; + for (CrystalEntry entry : ItemMemoryCrystal.memoryOf(stack).list()) { + addresses++; + if (registry.pinSystem(entry.coord())) { + frozen++; + } + } + } + } + + int wasVersion = registry.schemaVersion(); + UniverseSchema schema = registry.adoptSchema(pack); + // A schema version can be moved here and now: this build carries the new one, so the world can + // start deriving under it immediately rather than after a restart. + UniverseRegistry.setGenerator(schema.generator(pack)); + // A CONFIGURATION change cannot be seen from inside a server that is running — a changed + // stops the load before this command can be typed. So the permission is left here + // for that load to spend. + registry.armUpgrade(); + + sender.sendMessage(new TextComponentTranslation( + "commands.advancedrocketry.universe.upgrade.done", + crystals, addresses, frozen, wasVersion, schema.version(), target)); + sender.sendMessage(new TextComponentTranslation( + "commands.advancedrocketry.universe.upgrade.armed")); + sender.sendMessage(new TextComponentTranslation( + "commands.advancedrocketry.universe.upgrade.seam")); + } + + /** Every stack a player has on him — held, worn, and in his ender chest. */ + private static Iterable carried(EntityPlayerMP player) { + List all = new java.util.ArrayList<>(); + all.addAll(player.inventory.mainInventory); + all.addAll(player.inventory.offHandInventory); + all.addAll(player.inventory.armorInventory); + for (int i = 0; i < player.getInventoryEnderChest().getSizeInventory(); i++) { + all.add(player.getInventoryEnderChest().getStackInSlot(i)); + } + return all; + } + + @Override + public List getTabCompletions(MinecraftServer server, ICommandSender sender, String[] args, + net.minecraft.util.math.BlockPos targetPos) { + return args.length == 1 + ? getListOfStringsMatchingLastWord(args, CONFIRM) + : java.util.Collections.emptyList(); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/command/sub/universe/package-info.java b/src/main/java/zmaster587/advancedRocketry/command/sub/universe/package-info.java new file mode 100644 index 000000000..610e6c1d8 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/command/sub/universe/package-info.java @@ -0,0 +1,5 @@ +/** + * Operator commands for the world model a save was generated under — reporting it, and moving a world + * onto a newer one deliberately. + */ +package zmaster587.advancedRocketry.command.sub.universe; diff --git a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java index da3905fc0..42a218f96 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java @@ -1365,24 +1365,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(); @@ -1399,6 +1433,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; } @@ -2575,15 +2610,21 @@ private void handleTelescope(MinecraftServer server, ICommandSender sender, Stri } /** - * The scan half of a telescope reply. The sector COUNT ships with the corners it was computed + * The scan half of a telescope reply. The cell COUNT ships with the corners it was computed * from, and the deadline with the clock it is measured against, so a stuck number says which * component is stuck. {@code side} is stated because every field here is the server's answer. + * + *

    The instrument's HORIZON ships in both of its forms — the configured length and the number + * of steps it buys at this world's star spacing — because a reach that means nothing is a defect + * that reads as an empty sky, and no count alone can be checked against a telescope.

    */ private String telescopeScanFields(zmaster587.advancedRocketry.tile.multiblock.TileObservatory scope, zmaster587.advancedRocketry.universe.RegionScan scan, net.minecraft.world.WorldServer world) { long now = world.getTotalWorldTime(); zmaster587.advancedRocketry.space.GalacticCoord origin = scope.scanOrigin(); + zmaster587.advancedRocketry.universe.RegionScan.Tuning tuning = + zmaster587.advancedRocketry.universe.RegionScan.Tuning.fromConfig(); StringBuilder out = new StringBuilder(); out.append(",\"side\":\"server\",\"now\":").append(now) .append(",\"origin\":").append(origin == null ? "null" : "\"" + origin.cellKey() + "\"") @@ -2595,7 +2636,18 @@ private String telescopeScanFields(zmaster587.advancedRocketry.tile.multiblock.T // running scan is already looking at, below. .append(",\"aim\":").append(scope.scanDirectionIndex()) .append(",\"aimDistance\":").append(scope.getScanDistance()) - .append(",\"passive\":").append(scope.isPassive()); + .append(",\"aimLy\":").append(scope.getAimLightYears()) + .append(",\"reachLy\":").append(tuning.maxRangeLightYears()) + .append(",\"reachSteps\":").append(tuning.maxRangeSteps()) + // What ONE step of that aim is worth in cells — the instrument's own stride, readable + // while it is idle, so a fixture can be placed where the next look will actually land. + .append(",\"stepCells\":").append(tuning.strideCells()) + .append(",\"passive\":").append(scope.isPassive()) + // The aperture and the opening: what the instrument can SEE, which is what its reach + // above is derived from, and how wide a patch one pointing covers. + .append(",\"limitMagnitude\":").append(tuning.limitMagnitude()) + .append(",\"halfAngleDeg\":").append(Math.toDegrees(tuning.halfAngleRadians())) + .append(",\"wholeSystem\":").append(scope.isCharacterisingWholeSystem()); if (scan != null) { // The cell counts ship beside the region they are counted over, and the next deadline // beside the clock it is measured against: a sweep that will not advance must be able to @@ -2605,13 +2657,29 @@ private String telescopeScanFields(zmaster587.advancedRocketry.tile.multiblock.T .append("\",\"cells\":").append(scan.totalCells()) .append(",\"cellsDone\":").append(scan.cellsDone()) .append(",\"cellsPerStep\":").append(scan.cellsPerStep()) - .append(",\"distance\":").append(scan.distanceSectors()) + // The reach in BOTH forms, and the stride that relates them: a survey that + // resolves nothing must be able to say whether it is looking at the wrong scale. + .append(",\"distance\":").append(scan.distanceCells()) + .append(",\"distanceLy\":").append(scan.distanceLightYears()) + .append(",\"stride\":").append(scan.strideCells()) .append(",\"start\":").append(scan.startTick()) .append(",\"stepDeadline\":").append(scan.stepDeadline()) .append(",\"ticksPerStep\":").append(scan.ticksPerStep()) .append(",\"estimatedTicks\":").append(scan.estimatedTicks()) .append(",\"progress\":").append(scan.progress()) - .append(",\"stepDue\":").append(scan.stepDue(now)); + .append(",\"stepDue\":").append(scan.stepDue(now)) + // Which SHAPE the survey is: a pointing has an apex and an opening, a local radar + // has neither, and a test that cannot tell them apart cannot tell why a sweep + // covered what it covered. + .append(",\"pointing\":").append(scan.isPointing()) + .append(",\"shells\":").append(scan.cone() == null ? 0 : scan.cone().shells()) + // WHERE it is aimed, which the corners no longer say: a cone's bounding box is + // the apex plus its reach on every axis, so re-aiming the same instrument leaves + // min/max untouched. The direction is the aim. + .append(",\"dir\":\"").append(scan.cone() == null ? "" + : String.format(java.util.Locale.ROOT, "%.4f_%.4f_%.4f", + scan.cone().dirX(), scan.cone().dirY(), scan.cone().dirZ())) + .append("\""); } return out.toString(); } @@ -2638,7 +2706,7 @@ private zmaster587.advancedRocketry.tile.multiblock.TileObservatory observatoryA private void handleDrive(MinecraftServer server, ICommandSender sender, String[] args) { if (args.length < 5) { - send(sender, "{\"error\":\"usage: drive build|info|charge|arm|press|hull ...\"}"); + send(sender, "{\"error\":\"usage: drive build|info|charge|push|arm|press|hull ...\"}"); return; } String verb = args[0]; @@ -2687,12 +2755,35 @@ private void handleDrive(MinecraftServer server, ICommandSender sender, String[] for (zmaster587.advancedRocketry.tile.hyperdrive.TileJumpCapacitor capacitor : drive.capacitors()) { if (full) { - capacitor.fill(now); + capacitor.fill(); } else { - capacitor.discharge(capacitor.chargeAt(now), now); + capacitor.discharge(capacitor.charge()); } } - send(sender, "{\"ok\":true,\"charge\":" + drive.capacitorCharge(now) + "}"); + send(sender, "{\"ok\":true,\"charge\":" + drive.capacitorCharge() + "}"); + return; + } + if ("push".equalsIgnoreCase(verb)) { + // Energy pushed in THROUGH THE FORGE ENERGY CAPABILITY, which is what an adjacent reactor, + // solar array or cable does. Deliberately not `fill()`: that seam sets the level directly + // and would leave a test unable to tell a wired bank from one that manufactures its own + // charge — which is the exact defect this verb exists to be able to observe. + long amount = args.length > 5 ? parseLongOr(args[5], 0L) : 0L; + long accepted = 0L; + int ports = 0; + for (zmaster587.advancedRocketry.tile.hyperdrive.TileJumpCapacitor capacitor + : drive.capacitors()) { + net.minecraftforge.energy.IEnergyStorage port = capacitor.getCapability( + net.minecraftforge.energy.CapabilityEnergy.ENERGY, null); + if (port == null) { + continue; + } + ports++; + accepted += port.receiveEnergy( + (int) Math.min(Integer.MAX_VALUE, Math.max(0L, amount)), false); + } + send(sender, "{\"ok\":true,\"ports\":" + ports + ",\"accepted\":" + accepted + + ",\"charge\":" + drive.capacitorCharge() + "}"); return; } if ("arm".equalsIgnoreCase(verb)) { @@ -2736,8 +2827,8 @@ private void handleDrive(MinecraftServer server, ICommandSender sender, String[] info.put("burstCost", stats.burstCost()); info.put("capacitors", drive.capacitors().size()); info.put("capacity", drive.capacitorCapacity()); - info.put("charge", drive.capacitorCharge(now)); - info.put("cooldownTicks", drive.cooldownTicks(now)); + info.put("charge", drive.capacitorCharge()); + info.put("cooldownTicks", drive.cooldownTicks()); info.put("emitters", drive.emitters().size()); info.put("dampeners", drive.dampeners().size()); info.put("poweredDampeners", drive.poweredDampenerPositions().size()); @@ -3147,7 +3238,170 @@ public long getWorldTimeUniversal(int id) { } } + /** + * {@code space nebulae } — the CLOUD half of a cell's sky, as the server would send + * it: how many are seated in reach, how many survive the render filter, and each one's bearing, + * apparent size, appearance and thickness. + * + *

    {@code seated} beside {@code drawn} is the point of the reply. The feed drops clouds too small + * to be a landmark and caps what is left, so "the sky shows two" and "there are two out there" are + * different facts and a test that could not tell them apart would read a working LOD filter as a + * missing cloud.

    + */ + private void handleSpaceNebulae(MinecraftServer server, ICommandSender sender, String[] args) { + 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( + parseLongOr(args[1], 0L), parseLongOr(args[2], 0L), parseLongOr(args[3], 0L), + 0L, 0L, 0L); + zmaster587.advancedRocketry.universe.IGalaxyGenerator gen = + zmaster587.advancedRocketry.universe.UniverseRegistry.getGenerator(); + long seed = reg.worldSeed(); + java.util.List drawn = + zmaster587.advancedRocketry.space.SkyNebulaeProducer.around(gen, seed, cell); + int seated = zmaster587.advancedRocketry.space.SkyNebulaeProducer.countAround(gen, seed, cell); + StringBuilder out = new StringBuilder("{\"ok\":true,\"cell\":\""); + out.append(cell.cellKey()).append("\",\"seed\":").append(seed) + .append(",\"seated\":").append(seated) + .append(",\"drawn\":").append(drawn.size()).append(",\"nebulae\":["); + int n = 0; + for (zmaster587.advancedRocketry.network.PacketSystemBodiesSync.RenderNebula cloud : drawn) { + if (n++ > 0) { + out.append(','); + } + out.append("{\"dirX\":").append(cloud.dirX).append(",\"dirY\":").append(cloud.dirY) + .append(",\"dirZ\":").append(cloud.dirZ) + .append(",\"angularRadius\":").append(cloud.angularRadius) + .append(",\"appearance\":").append(cloud.appearanceOrdinal) + .append(",\"opacity\":").append(cloud.opacity).append('}'); + } + out.append("]}"); + send(sender, out.toString()); + } + + /** + * {@code space nebula-find } — walk out along +X from the origin looking for a cell + * whose sky holds a cloud, and report the first one. + * + *

    An arrangement helper, and it exists because a cloud's position is a fact about the SEED. A + * test that hard-coded a cell would be pinned to one world's generation and would fail as an + * accusation against the renderer the first time the seed changed; this asks the generator where + * to stand instead. Bounded by {@code steps}, and reports {@code found:false} rather than + * searching forever.

    + */ + private void handleSpaceNebulaFind(MinecraftServer server, ICommandSender sender, String[] args) { + zmaster587.advancedRocketry.universe.UniverseRegistry reg = + zmaster587.advancedRocketry.universe.UniverseRegistry.get(server); + if (reg == null) { + send(sender, "{\"error\":\"registry unavailable\"}"); + return; + } + int steps = Math.max(1, Math.min(4096, parseIntOr(args[1], 64))); + long stride = Math.max(1L, parseLongOr(args[2], 1L)); + zmaster587.advancedRocketry.universe.IGalaxyGenerator gen = + zmaster587.advancedRocketry.universe.UniverseRegistry.getGenerator(); + long seed = reg.worldSeed(); + for (int i = 0; i < steps; i++) { + zmaster587.advancedRocketry.space.GalacticCoord cell = + zmaster587.advancedRocketry.space.GalacticCoord.ofSectorLocal( + (long) i * stride, 0L, 0L, 0L, 0L, 0L); + java.util.List drawn = + zmaster587.advancedRocketry.space.SkyNebulaeProducer.around(gen, seed, cell); + if (drawn.isEmpty()) { + continue; + } + // WHERE the cloud is, not just that one is visible. A caller measuring a sight line + // THROUGH a cloud needs its centre and its size, and computing them from the render + // record is impossible by design — that record carries a direction and an angle and + // deliberately no position. Taken from the generator's own objects instead. + zmaster587.advancedRocketry.universe.Nebula biggest = null; + for (zmaster587.advancedRocketry.universe.Nebula n : gen.nebulaeAround(seed, cell, + zmaster587.advancedRocketry.space.SkyNebulaeProducer.SKY_REACH_LY)) { + if (biggest == null || n.radiusLy() > biggest.radiusLy()) { + biggest = n; + } + } + StringBuilder out = new StringBuilder("{\"ok\":true,\"found\":true,\"cell\":\""); + out.append(cell.cellKey()).append("\",\"sectorX\":").append(cell.sectorX()) + .append(",\"drawn\":").append(drawn.size()) + .append(",\"largest\":").append(drawn.get(0).angularRadius) + .append(",\"steps\":").append(i); + if (biggest != null) { + out.append(",\"centreX\":") + .append(zmaster587.advancedRocketry.universe.UniverseScale + .cellsAt(biggest.centreXLy())) + .append(",\"centreY\":") + .append(zmaster587.advancedRocketry.universe.UniverseScale + .cellsAt(biggest.centreYLy())) + .append(",\"centreZ\":") + .append(zmaster587.advancedRocketry.universe.UniverseScale + .cellsAt(biggest.centreZLy())) + .append(",\"radiusCells\":") + .append(zmaster587.advancedRocketry.universe.UniverseScale + .cellsForLightYears(biggest.radiusLy())) + .append(",\"radiusLy\":").append(biggest.radiusLy()) + .append(",\"peakDensity\":").append(biggest.peakDensity()); + } + out.append('}'); + send(sender, out.toString()); + return; + } + send(sender, "{\"ok\":true,\"found\":false,\"searched\":" + steps + ",\"stride\":" + stride + "}"); + } + + /** + * {@code space extinction } — how much the dust between two cells + * dims what is behind it, in magnitudes, plus the raw column it was converted from. + * + *

    Both numbers, because they answer different questions: the COLUMN says how much matter the + * line crossed (a fact about the generator) and the MAGNITUDES say what an observer loses (a fact + * about the calibration). A test that saw only one could not tell a generator that seats no + * clouds from a calibration that reads them as transparent.

    + */ + private void handleSpaceExtinction(MinecraftServer server, ICommandSender sender, String[] args) { + zmaster587.advancedRocketry.universe.UniverseRegistry reg = + zmaster587.advancedRocketry.universe.UniverseRegistry.get(server); + if (reg == null) { + send(sender, "{\"error\":\"registry unavailable\"}"); + return; + } + zmaster587.advancedRocketry.space.GalacticCoord from = + zmaster587.advancedRocketry.space.GalacticCoord.ofSectorLocal( + parseLongOr(args[1], 0L), parseLongOr(args[2], 0L), parseLongOr(args[3], 0L), + 0L, 0L, 0L); + zmaster587.advancedRocketry.space.GalacticCoord to = + zmaster587.advancedRocketry.space.GalacticCoord.ofSectorLocal( + parseLongOr(args[4], 0L), parseLongOr(args[5], 0L), parseLongOr(args[6], 0L), + 0L, 0L, 0L); + double column = zmaster587.advancedRocketry.universe.UniverseRegistry.getGenerator() + .columnDensityBetween(reg.worldSeed(), from, to); + double magnitudes = reg.extinctionBetween(from, to); + send(sender, "{\"ok\":true,\"from\":\"" + from.cellKey() + "\",\"to\":\"" + to.cellKey() + + "\",\"column\":" + column + ",\"magnitudes\":" + magnitudes + + ",\"obscured\":" + zmaster587.advancedRocketry.universe.TelescopeScan + .isObscured(reg, from, to) + + ",\"threshold\":" + zmaster587.advancedRocketry.api.ARConfiguration + .getCurrentConfig().telescopeObscuredAtMagnitudes + "}"); + } + private void handleSpace(MinecraftServer server, ICommandSender sender, String[] args) { + if (args.length >= 7 && "extinction".equalsIgnoreCase(args[0])) { + handleSpaceExtinction(server, sender, args); + return; + } + if (args.length >= 4 && "nebulae".equalsIgnoreCase(args[0])) { + handleSpaceNebulae(server, sender, args); + return; + } + if (args.length >= 3 && "nebula-find".equalsIgnoreCase(args[0])) { + handleSpaceNebulaFind(server, sender, args); + return; + } // --- PRODUCTION-wiring probes. Unlike every other verb here these deliberately touch the real // SpaceSubsystem rather than a probe-local stack, so a restart test can prove the shipped // server-start / world-save path actually persists and restores. They are only useful when @@ -3218,10 +3472,22 @@ private void handleSpace(MinecraftServer server, ICommandSender sender, String[] // "bearing", not "dir": the feed below already emits a "dir" per // body, measured from the CELL's observer for the sky, and a reader // matching on the substring could not tell the two apart. + // Taken as a sector delta plus an offset delta, never as the + // difference of two whole-block absolutes: those cannot express the + // coordinates the sector grid can name. .append(",\"bearing\":[") - .append(bodyAt.absoluteX() - e.coord.absoluteX()).append(',') - .append(bodyAt.absoluteY() - e.coord.absoluteY()).append(',') - .append(bodyAt.absoluteZ() - e.coord.absoluteZ()).append(']') + .append(zmaster587.advancedRocketry.space.AbsolutePos + .ofCellName(bodyAt).minus( + zmaster587.advancedRocketry.space.AbsolutePos + .ofCellName(e.coord)).dx()).append(',') + .append(zmaster587.advancedRocketry.space.AbsolutePos + .ofCellName(bodyAt).minus( + zmaster587.advancedRocketry.space.AbsolutePos + .ofCellName(e.coord)).dy()).append(',') + .append(zmaster587.advancedRocketry.space.AbsolutePos + .ofCellName(bodyAt).minus( + zmaster587.advancedRocketry.space.AbsolutePos + .ofCellName(e.coord)).dz()).append(']') .append(",\"distance\":") .append((long) Math.sqrt(e.coord.staticFrameDistanceSqTo(bodyAt))) // "distance" is to the body's CENTRE — what the descent trigger @@ -3806,6 +4072,14 @@ private void handleSpace(MinecraftServer server, ICommandSender sender, String[] // the ship is the one caller that never has to guess. java.util.UUID pilotedShip = zmaster587.advancedRocketry.integration.vs.VSIntegration.assembleTier2Ship(w, anchor); + // SETTLE the ship in this stack's own ledger, the way the entry on-ramp would have. Without + // it the fixture is a ship that is nowhere: production never has a craft sitting in a cell + // with no ledger row, and anything that asks the ledger where this ship IS - a short jump, + // a seam carry, a descent - correctly refuses to act on a ship it cannot place. Written on + // THIS stack's ledger, not the attached subsystem's: the two are different objects here. + if (transitDurableId != null) { + transitStack.ledger.settle(transitDurableId, transitOrigin); + } // Assembly is ASYNC (queued on the physics thread), so the seat + ship world pos are NOT queryable // yet. The caller polls `vs ship-count-all`/`load-ships`/`ship-count` for the ship, then reads the // post-assembly pilot-seat subspace pos + ship world pos via `vs find-seat id `. @@ -3815,12 +4089,19 @@ private void handleSpace(MinecraftServer server, ICommandSender sender, String[] + ",\"durableId\":\"" + (transitDurableId == null ? "" : transitDurableId) + "\"}"); return; } - // transit-begin [speedBlocksPerTick]: start the jump (arrival - // retries until the async hyperspace ship is crossable, so a large speed is fine). The - // optional speed lets a test SIZE the park: the setup cells sit one sector (4M blocks) - // apart, so the default 5M crosses in a single tick, while e.g. 100k parks the ship for - // ~40 probe-driven ticks — enough for a mid-transit stimulus (a relog) to land inside it. - if (args.length >= 5 && "transit-begin".equalsIgnoreCase(args[0])) { + // transit-begin : start the jump. + // + // The speed is REQUIRED, and it used to default to 5M. That default was harmless while there + // was one mechanism and it only sized the park; it stopped being harmless the moment the + // computed duration began choosing between hyperspace and a direct cell-to-cell crossing. + // At the setup's one-sector spacing (4M blocks) 5M crosses in a single tick, so the default + // silently picked the direct path for every caller that did not think about it — including + // every test written to exercise hyperspace. A caller now says which flight it wants. + // + // The arithmetic a caller needs: ticks = ceil(4M / speed), and a jump of at most + // ShipTransitManager.DIRECT_CROSSING_MAX_TICKS ticks is performed as one crossing. So + // speed >= 25_000 is a direct hop and speed <= 20_000 is a real flight with a park in it. + if (args.length >= 6 && "transit-begin".equalsIgnoreCase(args[0])) { if (transitTm == null) { send(sender, "{\"error\":\"transit not set up\"}"); return; @@ -3828,8 +4109,27 @@ private void handleSpace(MinecraftServer server, ICommandSender sender, String[] int originDim = parseIntOr(args[1], Integer.MIN_VALUE); net.minecraft.util.math.BlockPos anchor = new net.minecraft.util.math.BlockPos( parseIntOr(args[2], 0), parseIntOr(args[3], 0), parseIntOr(args[4], 0)); - long speed = args.length >= 6 - ? Math.max(1L, Long.parseLong(args[5])) : 5_000_000L; + // The caller names the BUILD pad, which is where the ship was assembled FROM — after + // assembly its blocks live in a subspace shipyard and the pad is empty air. Production's + // caller (JumpTrigger) never has this problem: it is the flight computer, so it passes its + // own live position. Resolve the same thing here, by IDENTITY rather than by proximity, so + // a departure that reads the ship's pose off this anchor reads a real block. + net.minecraft.world.WorldServer originWorld = + net.minecraftforge.common.DimensionManager.getWorld(originDim); + boolean anchorRelocated = false; + if (transitDurableId != null && originWorld != null) { + for (net.minecraft.tileentity.TileEntity te + : new java.util.ArrayList<>(originWorld.loadedTileEntityList)) { + if (te instanceof zmaster587.advancedRocketry.tile.TileAdvancedFlightComputer + && transitDurableId.equals(((zmaster587.advancedRocketry.tile + .TileAdvancedFlightComputer) te).shipIdOrNull())) { + anchor = te.getPos(); + anchorRelocated = true; + break; + } + } + } + long speed = Math.max(1L, Long.parseLong(args[5])); // Depart under the fixture's own DURABLE id, so the crossing resolves the ship it was told // about instead of whatever craft is nearest an anchor every scenario here reuses. The // synthetic "t" remains for fixtures that assembled nothing to name. @@ -3846,6 +4146,9 @@ private void handleSpace(MinecraftServer server, ICommandSender sender, String[] // about a client in the wrong dimension. send(sender, "{\"ok\":true,\"began\":" + began + ",\"shipId\":\"" + departingShip + "\",\"crew\":" + transitTm.crewCountOf(departingShip) + + ",\"anchorRelocated\":" + anchorRelocated + + ",\"anchorX\":" + anchor.getX() + ",\"anchorY\":" + anchor.getY() + + ",\"anchorZ\":" + anchor.getZ() + ",\"inTransit\":" + transitTm.inTransitCount() + "}"); return; } @@ -3856,7 +4159,26 @@ private void handleSpace(MinecraftServer server, ICommandSender sender, String[] send(sender, "{\"error\":\"transit not set up\"}"); return; } - transitTm.tick(); + // transit-tick [count] — advance the jump `count` server ticks in ONE round trip. + // + // The count is not a convenience: a flight is only a flight if it is longer than + // ShipTransitManager.DIRECT_CROSSING_MAX_TICKS, so every test of the hyperspace path now + // has to drive at least that many ticks, and one probe call per tick makes a 200-tick + // flight 200 round trips. It repeats the SAME tick — it does not change what a tick does. + int ticksToRun = args.length >= 2 ? Math.max(1, Math.min(2000, parseIntOr(args[1], 1))) : 1; + for (int t = 0; t < ticksToRun; t++) { + transitTm.tick(); + if (transitStack != null) { + transitStack.cellCrossings.tick(); + } + } + // Both mechanisms are advanced above. A jump short enough is performed as a single + // cell-to-cell crossing rather than flown, and its settle is driven by the crossing + // controller, not by the transit map. Ticking only one of them would make "advance the + // jump" mean different things depending on which mechanism the speed selected — and the + // arrival acceptance is meant to be SHARED between them, not written twice. + int crossing = transitStack != null && transitDurableId != null + && transitStack.cellCrossings.isCarrying(transitDurableId) ? 1 : 0; int inTransit = transitTm.inTransitCount(); int targetDim = -1; if (inTransit == 0 && transitMgr.isLoaded(transitTarget)) { @@ -3899,6 +4221,11 @@ private void handleSpace(MinecraftServer server, ICommandSender sender, String[] // the shared parking world. A crew-side test compares the CLIENT's dimension against these // rather than hardcoding an id that is minted per boot. send(sender, "{\"ok\":true,\"inTransit\":" + inTransit + ",\"targetDim\":" + targetDim + // Which mechanism is actually running, emitted in every state so "neither" is a + // pair of zeros rather than a missing field: `inTransit` is the hyperspace flight, + // `crossing` is the direct cell-to-cell settle. A test that wants to know WHICH + // one its speed selected reads these instead of inferring it from timing. + + ",\"crossing\":" + crossing + ",\"poseX\":" + (long) pose[0] + ",\"poseY\":" + (long) pose[1] + ",\"poseZ\":" + (long) pose[2] + ",\"shipY\":" + shipY + ",\"poseDist\":" + poseDist @@ -4296,6 +4623,70 @@ private void handleSpace(MinecraftServer server, ICommandSender sender, String[] send(sender, "{\"ok\":true,\"started\":" + started + ",\"pending\":" + d.descendingCount() + "}"); return; } + // seam-carry : drive the PRODUCTION cell-seam carry for the settled ship in that slot + // world — the counterpart of descent-begin, and for the same reason. The trigger lives in the + // flight computer's own tick, which a headless slot world does not run (no player, no ticking + // chunks there), so an e2e that waited for it would be measuring chunk-ticking rather than the + // crossing. WHEN a carry fires is pinned deterministically by CellSeamTest; this verb exists so + // the crossing itself — materialize, cut, paste, settle, ledger handoff — can be exercised on a + // real ship. The ship's LIVE pose is used, never the ledger's: past the face the ledger's copy + // is saturated, so a lookup from it would miss the ship by the whole overshoot. + if (args.length >= 2 && "seam-carry".equalsIgnoreCase(args[0])) { + zmaster587.advancedRocketry.space.CellCrossingController seamCtl = + zmaster587.advancedRocketry.space.SpaceSubsystem.cellCrossings(); + zmaster587.advancedRocketry.space.ShipLedger seamLedger = + zmaster587.advancedRocketry.space.SpaceSubsystem.ledger(); + if (seamCtl == null || seamLedger == null) { + send(sender, "{\"error\":\"space subsystem not registered\"}"); + return; + } + int slotDim = parseIntOr(args[1], Integer.MIN_VALUE); + net.minecraft.world.WorldServer slotWorld = + net.minecraftforge.common.DimensionManager.getWorld(slotDim); + if (slotWorld == null) { + send(sender, "{\"error\":\"slot world not loaded\",\"slotDim\":" + slotDim + "}"); + return; + } + for (java.util.Map.Entry e + : seamLedger.snapshot().entrySet()) { + zmaster587.advancedRocketry.space.ShipLedger.Entry entry = e.getValue(); + if (entry.state != zmaster587.advancedRocketry.space.ShipLedger.State.SETTLED + || slotDimOfCell(entry.coord) != slotDim) { + continue; + } + double[] ledgerPose = + zmaster587.advancedRocketry.space.CellWorldMapper.poseWorldOf(entry.coord); + double[] live = zmaster587.advancedRocketry.integration.vs.VSIntegration + .nearestShipState(slotWorld, ledgerPose[0], ledgerPose[1], ledgerPose[2], + zmaster587.advancedRocketry.space.GalacticCoord.CELL); + if (live == null) { + send(sender, "{\"ok\":true,\"started\":false,\"reason\":\"no loaded ship near the " + + "ledger pose\",\"shipId\":\"" + e.getKey() + "\"}"); + return; + } + net.minecraft.util.math.BlockPos afc = zmaster587.advancedRocketry.integration.vs + .VSIntegration.flightComputerAt(slotWorld, live[0], live[1], live[2]); + if (afc == null) { + send(sender, "{\"ok\":true,\"started\":false,\"reason\":\"ship carries no flight " + + "computer\",\"shipId\":\"" + e.getKey() + "\"}"); + return; + } + boolean wouldCarry = zmaster587.advancedRocketry.space.CellSeam + .shouldCarry(live[0], live[1], live[2]); + boolean started = seamCtl.requestCarry(slotDim, afc, e.getKey(), entry.coord, + new double[]{live[0], live[1], live[2]}); + send(sender, "{\"ok\":true,\"started\":" + started + + ",\"wouldCarry\":" + wouldCarry + + ",\"shipId\":\"" + e.getKey() + "\"" + + ",\"fromCell\":\"" + entry.coord.cellKey() + "\"" + + ",\"pose\":[" + live[0] + "," + live[1] + "," + live[2] + "]" + + ",\"afc\":[" + afc.getX() + "," + afc.getY() + "," + afc.getZ() + "]}"); + return; + } + send(sender, "{\"ok\":true,\"started\":false,\"reason\":\"no settled ship in this slot\"" + + ",\"slotDim\":" + slotDim + "}"); + return; + } // descent-status: the in-flight descent count (settle progress). if (args.length >= 1 && "descent-status".equalsIgnoreCase(args[0])) { zmaster587.advancedRocketry.space.DescentController d = @@ -4478,11 +4869,18 @@ 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. + // The 11th argument is the body's RADIUS in Earth radii; without it the body has none, + // which is a real state (a belt is not a sphere) and is what the sky draws as a marker. + // A test that wants a body drawn at a size has to say what size, because the renderer + // stopped guessing one from distance. + double poiRadiusEarths = args.length >= 11 ? parseDoubleOr(args[10], 0d) : 0d; zmaster587.advancedRocketry.universe.SystemBody body = - new zmaster587.advancedRocketry.universe.SystemBody(coord, kind, dimId, starId); + zmaster587.advancedRocketry.universe.SystemBody.fixedAt(coord, kind, dimId, starId) + .withRadius(poiRadiusEarths); reg.addPoi(body); send(sender, "{\"ok\":true,\"cellKey\":\"" + coord.cellKey() + "\",\"descendTarget\":" - + body.isDescendTarget() + "}"); + + body.isDescendTarget() + ",\"radiusEarths\":" + body.radiusEarths() + "}"); return; } // cell-info [dimId]: what the universe registry says is AT one cell, and by which @@ -4507,8 +4905,9 @@ private void handleSpace(MinecraftServer server, ICommandSender sender, String[] zmaster587.advancedRocketry.space.AbsolutePos origin = zmaster587.advancedRocketry.space.SpaceSubsystem.cellFrameOriginAt(name, clock); send(sender, "{\"ok\":true,\"cellKey\":\"" + name.cellKey() + "\",\"clock\":" + clock - + ",\"originX\":" + origin.x() + ",\"originY\":" + origin.y() - + ",\"originZ\":" + origin.z() + "}"); + + ",\"originSector\":[" + origin.sectorX() + "," + origin.sectorY() + "," + + origin.sectorZ() + "],\"originOffset\":[" + origin.localX() + "," + + origin.localY() + "," + origin.localZ() + "]}"); return; } // forget-name : drop the RECORDED cell name of a dimension, so the next query has to @@ -4691,6 +5090,179 @@ 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. + // + // The GALAXY lattice keeps its shipped parameters. A caller near the origin is inside the home + // galaxy's core, where the profile is at its densest, so alone says how full the sky + // is — which is the one thing a test about procedural systems is actually asking for. + if (args.length >= 3 && "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); + long seed = args.length >= 4 ? parseLongOr(args[3], 0L) : reg.worldSeed(); + zmaster587.advancedRocketry.universe.GalaxyGenConfig genDefaults = + zmaster587.advancedRocketry.universe.GalaxyGenConfig.defaults(); + zmaster587.advancedRocketry.universe.UniverseRegistry.setGenerator( + new zmaster587.advancedRocketry.universe.ClusteredGalaxyGenerator( + new zmaster587.advancedRocketry.universe.GalaxyGenConfig(minSpacing, density, + genDefaults.galaxySpacing, genDefaults.galaxyDensity, null, 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 body a ship could land on 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. + // + // THE SWEEP IS BY SUPER-CELL, NEVER BY CELL. It used to walk raw cells around the origin, which + // worked only while a system's extent was a fraction of the star spacing. A body now stands + // where its own orbit puts it — one AU is about 150 cells — so a body is hundreds to thousands + // of cells from its star, and a box of a few cells around the origin contains nothing whatever + // the galaxy holds. Each probe asks the registry for the WHOLE system its super-cell belongs to. + 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); + long s = Math.max(1L, zmaster587.advancedRocketry.universe.UniverseRegistry.getGenerator() + .minSpacingCells()); + 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 probe = + zmaster587.advancedRocketry.space.GalacticCoord.ofSectorLocal( + x * s, y * s, z * s, 0L, 0L, 0L); + for (zmaster587.advancedRocketry.universe.SystemBody b + : reg.systemBodiesAt(probe)) { + if (b.kind().canDescend() + && b.dimId() == zmaster587.advancedRocketry.api.Constants.INVALID_PLANET) { + // The BODY's own cell, not the probe's: that is the address every + // follow-up verb (cell-info, derived, realize) is aimed at. + zmaster587.advancedRocketry.space.GalacticCoord cell = b.name(); + send(sender, "{\"ok\":true,\"sx\":" + cell.sectorX() + ",\"sy\":" + + cell.sectorY() + ",\"sz\":" + cell.sectorZ() + + ",\"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.UniverseRegistry.getGenerator() + .derivation().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.getAverageTemp() + + ",\"oxygen\":" + props.hasOxygen + ",\"locked\":" + props.isTidallyLocked() + + ",\"metallicity\":" + props.getMetallicity() + ",\"gasGiant\":" + + props.isGasGiant() + ",\"terrainSource\":\"" + props.getTerrainSource() + + "\",\"descendTarget\":" + descendTarget + ",\"starId\":" + props.getStarId() + "}"); + return; + } // find-afc [shipId]: 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 @@ -5240,6 +5812,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)); // World spawn exactly as the SERVER holds it — the same expression // vanilla packs into SPacketSpawnPosition at PlayerList:1044. A @@ -5431,7 +6016,7 @@ private void handlePlanet(ICommandSender sender, String[] args) { info.put("thunderStartLength", props.getThunderStartLength()); info.put("rainMarker", props.getRainMarker()); info.put("thunderMarker", props.getThunderMarker()); - info.put("averageTemperature", props.averageTemperature); + info.put("averageTemperature", props.getAverageTemp()); info.put("genType", props.getGenType()); IBlockState ocean = props.getOceanBlock(); // null is meaningful — vanilla water fallback — so emit explicitly. @@ -5455,59 +6040,17 @@ private void handlePlanet(ICommandSender sender, String[] args) { + ",\"kelvin\":" + kelvin + "}"); return; } - props.averageTemperature = kelvin; + props.setAverageTemp(kelvin); Map out = new LinkedHashMap<>(); out.put("ok", true); out.put("dim", dim); - out.put("averageTemperature", props.averageTemperature); + out.put("averageTemperature", props.getAverageTemp()); out.put("hasOxygen", props.hasOxygen); out.put("atmosphereDensity", props.getAtmosphereDensity()); out.put("atmosphere", props.getAtmosphere().getUnlocalizedName()); send(sender, jsonMap(out)); return; } - if (args.length >= 2 && "moon-generate-catch".equalsIgnoreCase(args[0])) { - // /artest planet moon-generate-catch - // - // Repro for C072: run the REAL PlanetGenerateCommand moon path against - // a planet whose star id resolves to no star, and report what it - // throws. Temporarily orphans the planet's star (setStar to an id with - // no StellarBody), invokes execute(...), and restores the original star - // in a finally. Pre-fix the command NPEs (getStar dereferenced inside - // generateRandom); post-fix a star-existence guard on the moon branch - // throws a clean CommandException before any generation. No dimension - // is registered in either case (the throw precedes registerDim), so the - // registered-dim count must be unchanged both pre and post. - int planetDim = parseIntOr(args[1], Integer.MIN_VALUE); - DimensionProperties props = DimensionManager.getInstance().getDimensionProperties(planetDim); - if (props == null) { - send(sender, "{\"error\":\"unknown planet\",\"dim\":" + planetDim + "}"); - return; - } - // Find a star id genuinely absent from the star table. - int bogusStar = 0x40000000; - while (DimensionManager.getInstance().getStar(bogusStar) != null) bogusStar++; - int origStar = props.getStarId(); - int dimsBefore = DimensionManager.getInstance().getRegisteredDimensions().length; - String thrown = "null"; - try { - props.setStar(bogusStar); - new zmaster587.advancedRocketry.command.sub.planet.PlanetGenerateCommand().execute( - sender.getServer(), sender, - new String[]{String.valueOf(planetDim), "moon", "C072Moon", "10", "10", "10"}); - } catch (Throwable t) { - thrown = t.getClass().getSimpleName(); - } finally { - props.setStar(origStar); - } - int dimsAfter = DimensionManager.getInstance().getRegisteredDimensions().length; - send(sender, "{\"ok\":true,\"planetDim\":" + planetDim - + ",\"bogusStar\":" + bogusStar - + ",\"thrown\":\"" + thrown + "\"" - + ",\"dimsBefore\":" + dimsBefore - + ",\"dimsAfter\":" + dimsAfter + "}"); - return; - } send(sender, "{\"error\":\"unknown planet subcommand\"}"); } @@ -10758,14 +11301,16 @@ private void handleMachineTickUntil(MinecraftServer server, ICommandSender sende // The telescope's reach and what a look costs in time, all read at scan START, // so flipping them at runtime is enough to exercise a short scan in a test // without waiting out a production-length observation. - "telescopeScanRangeSectors", - "telescopeScanHalfWidthSectors", - "telescopeScanMaxSectors", + "telescopeLimitingMagnitude", + "telescopeConeHalfAngleDegrees", + "telescopeScanMaxCells", "telescopeScanBaseTicks", - "telescopeScanTicksPerSector", "telescopeScanCellsPerStep", - "telescopePassiveRadiusSectors", + "telescopePassiveRadiusSteps", "telescopeSurveyDataPerStep", + // How much dust a survey sees through, in magnitudes. Flippable at runtime so a + // test can drive BOTH sides of concealment against one generated cloud. + "telescopeObscuredAtMagnitudes", // The research master switch. A survey is instant without it and paced by the // time curve with it, so both halves of boundary B need it flippable at runtime. "planetsMustBeDiscovered")); @@ -11121,7 +11666,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 @@ -11133,6 +11678,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)) { @@ -11157,6 +11703,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. @@ -17534,6 +18081,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 @@ -20071,16 +20674,35 @@ private void handleChunk(MinecraftServer server, ICommandSender sender, String[] send(sender, "{\"error\":\"unknown chunk subcommand\"}"); } - // Server tick-wait probe ------------------------------------------- + // Server clock probes ---------------------------------------------- + // + // Companion to the chunk-anchor probe. Once the rocket's chunk is force-loaded, a test needs the + // server's natural tick loop to run N times so EntityRocket.onUpdate is invoked in its production + // context (rather than driving it synthetically via /artest rocket tick). // - // companion to the chunk-anchor probe. Once the - // rocket's chunk is force-loaded, we need to let the server's - // natural tick loop run N times so EntityRocket.onUpdate is invoked - // in its production context (rather than driving it synthetically - // via /artest rocket tick). This probe polls - // world.getTotalWorldTime() until the configured number of ticks - // has elapsed, sleeping 50ms between polls. + // A command handler CANNOT provide that wait, and the reason is structural rather than incidental: + // console commands are drained on the server thread, which is the one thread that advances + // world time, so any handler that blocks waiting for the clock is blocking the clock. `tick-count` + // is therefore the instant read, and the waiting belongs to the TEST thread, which is free while + // the server ticks. Both verbs report `onServerThread` so the claim is measured on every call + // rather than asserted in a comment — an earlier comment here asserted the opposite and was + // believed for months. private void handleServer(MinecraftServer server, ICommandSender sender, String[] args) { + // /artest server tick-count — one instant read of the world's own clock. This is the + // observable a test-side wait is built from: read, sleep in the TEST jvm, read again. + if (args.length >= 2 && "tick-count".equalsIgnoreCase(args[0])) { + int dim = parseIntOr(args[1], Integer.MIN_VALUE); + net.minecraft.world.WorldServer world = server.getWorld(dim); + if (world == null) { + send(sender, "{\"error\":\"world not loaded\",\"dim\":" + dim + "}"); + return; + } + send(sender, "{\"ok\":true,\"dim\":" + dim + + ",\"tick\":" + world.getTotalWorldTime() + + ",\"worldTime\":" + world.getWorldInfo().getWorldTime() + + ",\"onServerThread\":" + server.isCallingFromMinecraftThread() + "}"); + return; + } if (args.length >= 3 && "wait".equalsIgnoreCase(args[0])) { int dim = parseIntOr(args[1], Integer.MIN_VALUE); int ticksToWait = parseIntOr(args[2], 0); @@ -20107,22 +20729,37 @@ private void handleServer(MinecraftServer server, ICommandSender sender, String[ catch (InterruptedException ie) { Thread.currentThread().interrupt(); break; } } long end = world.getTotalWorldTime(); + // SAY whether the clock actually moved. Measured 2026-08-17: it does not — this handler + // runs ON the server thread, the one thread that advances world time, so the poll above + // can only ever watch a stopped clock and then give up on its wall budget. Every caller + // that read this as "N ticks have now happened" was reading a sleep. The verb keeps + // working (it does burn wall time, which some callers only ever wanted) but it may not + // report that silently: `advanced` is the field a test must look at, and the hint says + // what to do instead. + boolean advanced = end > start; send(sender, "{\"ok\":true,\"dim\":" + dim + ",\"startTick\":" + start + ",\"endTick\":" + end + ",\"elapsedTicks\":" + (end - start) + ",\"requested\":" + ticksToWait + + ",\"advanced\":" + advanced + + ",\"onServerThread\":" + server.isCallingFromMinecraftThread() + + (advanced ? "" : ",\"hint\":\"the clock did not move: this handler runs on the " + + "server thread and cannot let it tick - use 'server tick-count ' " + + "and wait from the test side instead\"") + ",\"wallMs\":" + (System.currentTimeMillis() - wallStart) + "}"); return; } // Block the server's TICK LOOP for a while, the way a real overloaded server does. // - // Probe handlers do not run on the server thread (the wait verb above polls the world clock - // from a command thread and would deadlock otherwise), so the block has to be scheduled ONTO - // that thread. Vanilla then logs its own "Can't keep up! ... skipping N tick(s)" and resumes, - // which is the whole point: a per-tick threshold anywhere in the codebase means something - // different across a tick that really took three seconds, and until now nothing in the harness - // could produce one. Bounded to 10 s so it can never approach the harness's command timeout. + // The block is scheduled onto the server thread rather than run inline. That is belt and + // braces, not necessity: handlers ALREADY run on the server thread (measured 2026-08-17 — the + // wait verb above cannot see the overworld clock move), and `addScheduledTask` invoked from + // that thread runs its runnable immediately, so this path stalls the loop either way. + // Vanilla then logs its own "Can't keep up! ... skipping N tick(s)" and resumes, which is the + // whole point: a per-tick threshold anywhere in the codebase means something different across + // a tick that really took three seconds, and until now nothing in the harness could produce + // one. Bounded to 10 s so it can never approach the harness's command timeout. if (args.length >= 2 && "stall".equalsIgnoreCase(args[0])) { long ms = parseIntOr(args[1], 0); if (ms <= 0L || ms > 10_000L) { @@ -20180,8 +20817,8 @@ public void run() { } return; } - send(sender, "{\"error\":\"usage: /artest server wait | save-dimensions\"}"); - send(sender, "{\"error\":\"usage: /artest server wait | /artest server stall \"}"); + send(sender, "{\"error\":\"usage: /artest server tick-count | wait " + + "| stall | save-dimensions\"}"); } /** True if the {@code .class} resource is reachable via the diff --git a/src/main/java/zmaster587/advancedRocketry/dimension/DimensionManager.java b/src/main/java/zmaster587/advancedRocketry/dimension/DimensionManager.java index 74621d29a..890cabf74 100644 --- a/src/main/java/zmaster587/advancedRocketry/dimension/DimensionManager.java +++ b/src/main/java/zmaster587/advancedRocketry/dimension/DimensionManager.java @@ -87,7 +87,7 @@ public DimensionManager() { overworldProperties = new DimensionProperties(0); overworldProperties.setAtmosphereDensityDirect(100); //Temperature in Kelvin, 286 is 13 Degrees C - overworldProperties.averageTemperature = 286; + overworldProperties.setAverageTemp(286); overworldProperties.gravitationalMultiplier = 1f; overworldProperties.orbitalDist = 100; overworldProperties.skyColor = new float[]{1f, 1f, 1f}; @@ -97,7 +97,7 @@ public DimensionManager() { defaultSpaceDimensionProperties = new DimensionProperties(SpaceObjectManager.WARPDIMID, false); defaultSpaceDimensionProperties.setAtmosphereDensityDirect(0); - defaultSpaceDimensionProperties.averageTemperature = 0; + defaultSpaceDimensionProperties.setAverageTemp(0); defaultSpaceDimensionProperties.gravitationalMultiplier = 0.1f; defaultSpaceDimensionProperties.orbitalDist = 100; defaultSpaceDimensionProperties.skyColor = new float[]{0f, 0f, 0f}; @@ -253,147 +253,6 @@ public int getNextFreeStarId() { return -1; } - public DimensionProperties generateRandom(int starId, int atmosphereFactor, int distanceFactor, int gravityFactor) { - return generateRandom(starId, 100, 100, 100, atmosphereFactor, distanceFactor, gravityFactor); - } - - public DimensionProperties generateRandom(int starId, String name, int atmosphereFactor, int distanceFactor, int gravityFactor) { - return generateRandom(starId, name, 100, 100, 100, atmosphereFactor, distanceFactor, gravityFactor); - } - - /** - * Creates and registers a planet with the given properties, Xfactor is the amount of variance from the supplied base property; ie: base - (factor/2) <= generated property value <= base - (factor/2) - * - * @param name name of the planet - * @param baseAtmosphere - * @param baseDistance - * @param baseGravity - * @param atmosphereFactor - * @param distanceFactor - * @param gravityFactor - * @return the new dimension properties created for this planet - */ - public DimensionProperties generateRandom(int starId, String name, int baseAtmosphere, int baseDistance, int baseGravity, int atmosphereFactor, int distanceFactor, int gravityFactor) { - DimensionProperties properties = new DimensionProperties(getNextFreeDim(dimOffset)); - - if (properties.getId() == Constants.INVALID_PLANET) return null; - - if (name.equals("")) properties.setName(getNextName(starId, properties.getId())); - else { - properties.setName(name); - } - properties.setAtmosphereDensityDirect(MathHelper.clamp(baseAtmosphere + random.nextInt(atmosphereFactor) - atmosphereFactor / 2, DimensionProperties.MIN_ATM_PRESSURE, DimensionProperties.MAX_ATM_PRESSURE)); - int newDist = properties.orbitalDist = MathHelper.clamp(baseDistance + random.nextInt(distanceFactor), DimensionProperties.MIN_DISTANCE, DimensionProperties.MAX_DISTANCE); - - properties.gravitationalMultiplier = Math.min(Math.max(0.05f, (baseGravity + random.nextInt(gravityFactor) - gravityFactor / 2f) / 100f), 1.3f); - - double minDistance; - int walkDist = 0; - - do { - minDistance = Double.MAX_VALUE; - - for (IDimensionProperties properties2 : getStar(starId).getPlanets()) { - int dist = Math.abs(((DimensionProperties) properties2).orbitalDist - newDist); - if (minDistance > dist) minDistance = dist; - } - - newDist = properties.orbitalDist + walkDist; - if (walkDist > -1) walkDist = -walkDist - 1; - else walkDist = -walkDist; - - } while (minDistance < 4); - - properties.orbitalDist = newDist; - properties.baseOrbitTheta = random.nextInt(360) * Math.PI / 180d; - - properties.orbitalPhi = (random.nextGaussian() - 0.5d) * 180; - properties.rotationalPhi = (random.nextGaussian() - 0.5d) * 180; - - //Get Star Color - properties.setStar(getStar(starId)); - - //Linear is easier. Earth is nominal! - properties.averageTemperature = AstronomicalBodyHelper.getAverageTemperature(properties.getStar(), properties.getSolarOrbitalDistance(), properties.getAtmosphereDensity()); - - - if (AtmosphereTypes.getAtmosphereTypeFromValue(properties.getAtmosphereDensity()) == AtmosphereTypes.NONE && random.nextInt() % 5 == 0 && !AdvancedRocketryFluids.fluidOxygen.isGaseous()) { - properties.setOceanBlock(AdvancedRocketryBlocks.blockOxygenFluid.getDefaultState()); - properties.setSeaLevel(random.nextInt(6) + 72); - } - - if (random.nextInt() % 10 == 0) { - properties.setSeaLevel(random.nextInt(40) + 43); - } - - properties.skyColor[0] *= 1 - MathHelper.clamp(random.nextFloat() * 0.1f + (70 - (properties.averageTemperature / 3f)) / 100f, 0.2f, 1); - properties.skyColor[1] *= 1 - (random.nextFloat() * .5f); - properties.skyColor[2] *= 1 - MathHelper.clamp(random.nextFloat() * 0.1f + ((properties.averageTemperature / 3f) - 70) / 100f, 0, 1); - - if (random.nextInt() % 50 == 0) { - properties.setHasRings(true); - properties.ringColor[0] = properties.skyColor[0]; - properties.ringColor[1] = properties.skyColor[1]; - properties.ringColor[2] = properties.skyColor[2]; - } - - properties.rotationalPeriod = (int) (Math.pow((1 / properties.gravitationalMultiplier), 3) * 24000); - - properties.addBiomes(properties.getViableBiomes(true)); - properties.initDefaultAttributes(); - - registerDim(properties, true); - return properties; - } - - public DimensionProperties generateRandom(int starId, int baseAtmosphere, int baseDistance, int baseGravity, int atmosphereFactor, int distanceFactor, int gravityFactor) { - return generateRandom(starId, "", baseAtmosphere, baseDistance, baseGravity, atmosphereFactor, distanceFactor, gravityFactor); - } - - public DimensionProperties generateRandomGasGiant(int starId, String name, int baseAtmosphere, int baseDistance, int baseGravity, int atmosphereFactor, int distanceFactor, int gravityFactor) { - DimensionProperties properties = new DimensionProperties(getNextFreeDim(dimOffset)); - - if (name.isEmpty()) properties.setName(getNextName(starId, properties.getId())); - else { - properties.setName(name); - } - properties.setAtmosphereDensityDirect(MathHelper.clamp(baseAtmosphere + random.nextInt(atmosphereFactor) - atmosphereFactor / 2, DimensionProperties.MIN_ATM_PRESSURE, DimensionProperties.MAX_ATM_PRESSURE)); - properties.orbitalDist = MathHelper.clamp(baseDistance + random.nextInt(distanceFactor), DimensionProperties.MIN_DISTANCE, 800); - //System.out.println(properties.orbitalDist); - properties.gravitationalMultiplier = Math.min(Math.max(0.05f, (baseGravity + random.nextInt(gravityFactor) - gravityFactor / 2f) / 100f), 1.3f); - - double minDistance; - - do { - minDistance = Double.MAX_VALUE; - - properties.orbitTheta = random.nextInt(360) * (2f * Math.PI) / 360f; - - for (IDimensionProperties properties2 : getStar(starId).getPlanets()) { - double dist = Math.abs(((DimensionProperties) properties2).orbitTheta - properties.orbitTheta); - if (dist < minDistance) minDistance = dist; - } - - } while (minDistance < (Math.PI / 40f)); - - //Get Star Color - properties.setStar(getStar(starId)); - - //Linear is easier. Earth is nominal! - properties.averageTemperature = AstronomicalBodyHelper.getAverageTemperature(properties.getStar(), properties.getSolarOrbitalDistance(), properties.getAtmosphereDensity()); - properties.setGasGiant(true); - - // 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())) { - properties.getHarvestableGasses().add(gas.getFluid()); - } - } - - registerDim(properties, true); - return properties; - } - /** * @param dimId dimension id to check * @return true if it can be traveled to, in general if it has a surface @@ -581,24 +440,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); + } } /** @@ -754,72 +657,6 @@ public boolean isPlanetKnown(int dimId) { return knownPlanets != null && knownPlanets.contains(dimId); } - private List generateRandomPlanets(StellarBody star, int numRandomGeneratedPlanets, int numRandomGeneratedGasGiants) { - List dimPropList = new LinkedList<>(); - - Random random = new Random(System.currentTimeMillis()); - - - for (int i = 0; i < numRandomGeneratedGasGiants; i++) { - int baseAtm = 180; - int baseDistance = 100; - - DimensionProperties properties = DimensionManager.getInstance().generateRandomGasGiant(star.getId(), "", baseDistance + 50, baseAtm, 125, 100, 100, 75); - - dimPropList.add(properties); - if (properties.gravitationalMultiplier >= 1f) { - int numMoons = random.nextInt(8); - - for (int ii = 0; ii < numMoons; ii++) { - DimensionProperties moonProperties = DimensionManager.getInstance().generateRandom(star.getId(), properties.getName() + ": " + ii, 25, 100, (int) (properties.gravitationalMultiplier / .02f), 25, 100, 50); - if (moonProperties == null) continue; - - dimPropList.add(moonProperties); - - moonProperties.setParentPlanet(properties); - star.removePlanet(moonProperties); - } - } - } - - for (int i = 0; i < numRandomGeneratedPlanets; i++) { - int baseAtm = 75; - int baseDistance = 100; - - if (i % 4 == 0) { - baseAtm = 0; - } else if (i != 6 && (i + 2) % 4 == 0) baseAtm = 120; - - if (i % 3 == 0) { - baseDistance = 170; - } else if ((i + 1) % 3 == 0) { - baseDistance = 30; - } - - DimensionProperties properties = DimensionManager.getInstance().generateRandom(star.getId(), baseDistance, baseAtm, 125, 100, 100, 75); - - if (properties == null) continue; - - dimPropList.add(properties); - - if (properties.gravitationalMultiplier >= 1f) { - int numMoons = random.nextInt(4); - - for (int ii = 0; ii < numMoons; ii++) { - DimensionProperties moonProperties = DimensionManager.getInstance().generateRandom(star.getId(), properties.getName() + ": " + ii, 25, 100, (int) (properties.gravitationalMultiplier / .02f), 25, 100, 50); - - if (moonProperties == null) continue; - - dimPropList.add(moonProperties); - moonProperties.setParentPlanet(properties); - star.removePlanet(moonProperties); - } - } - } - - return dimPropList; - } - @Nullable private File getCurrentSaveRootDirectory() { File dir = net.minecraftforge.common.DimensionManager.getCurrentSaveRootDirectory(); @@ -902,9 +739,13 @@ public void createAndLoadDimensions(boolean resetFromXml) { } for (StellarBody star : dimCouplingList.stars) { - numRandomGeneratedPlanets = loader.getMaxNumPlanets(star); - numRandomGeneratedGasGiants = loader.getMaxNumGasGiants(star); - dimCouplingList.dims.addAll(generateRandomPlanets(star, numRandomGeneratedPlanets, numRandomGeneratedGasGiants)); + // The pack's body count is CARRIED, not consumed. It used to be spent here by a + // second world-making model seeded on the wall clock, which registered its worlds + // as Forge dimensions up front and made two saves of one seed differ. The count + // now bounds the ONE model's derived retinue for this system, and the worlds are + // realized on arrival like everywhere else. + star.setMaxRetinueBodies(loader.getMaxNumPlanets(star) + + loader.getMaxNumGasGiants(star)); } loadedFromXML = true; @@ -931,7 +772,7 @@ public void createAndLoadDimensions(boolean resetFromXml) { if (zmaster587.advancedRocketry.api.ARConfiguration.getCurrentConfig().MoonId != Constants.INVALID_PLANET) { DimensionProperties dimensionProperties = new DimensionProperties(zmaster587.advancedRocketry.api.ARConfiguration.getCurrentConfig().MoonId); dimensionProperties.setAtmosphereDensityDirect(0); - dimensionProperties.averageTemperature = 20; + dimensionProperties.setAverageTemp(20); dimensionProperties.rotationalPeriod = 128000; dimensionProperties.gravitationalMultiplier = .166f; //Actual moon value dimensionProperties.setName("Luna"); @@ -947,7 +788,8 @@ public void createAndLoadDimensions(boolean resetFromXml) { DimensionManager.getInstance().registerDimNoUpdate(dimensionProperties, !Loader.isModLoaded("GalacticraftCore")); } - generateRandomPlanets(DimensionManager.getInstance().getStar(0), numRandomGeneratedPlanets, numRandomGeneratedGasGiants); + DimensionManager.getInstance().getStar(0) + .setMaxRetinueBodies(numRandomGeneratedPlanets + numRandomGeneratedGasGiants); StellarBody star = new StellarBody(); star.setTemperature(10); @@ -956,7 +798,7 @@ public void createAndLoadDimensions(boolean resetFromXml) { star.setId(DimensionManager.getInstance().getNextFreeStarId()); star.setName("Wolf 12"); DimensionManager.getInstance().addStar(star); - generateRandomPlanets(star, 5, 0); + star.setMaxRetinueBodies(5); star = new StellarBody(); star.setTemperature(170); @@ -965,7 +807,7 @@ public void createAndLoadDimensions(boolean resetFromXml) { star.setId(DimensionManager.getInstance().getNextFreeStarId()); star.setName("Epsilon ire"); DimensionManager.getInstance().addStar(star); - generateRandomPlanets(star, 7, 0); + star.setMaxRetinueBodies(7); star = new StellarBody(); star.setTemperature(200); @@ -974,7 +816,7 @@ public void createAndLoadDimensions(boolean resetFromXml) { star.setId(DimensionManager.getInstance().getNextFreeStarId()); star.setName("Proxima Centaurs"); DimensionManager.getInstance().addStar(star); - generateRandomPlanets(star, 3, 0); + star.setMaxRetinueBodies(3); star = new StellarBody(); star.setTemperature(70); @@ -983,7 +825,7 @@ public void createAndLoadDimensions(boolean resetFromXml) { star.setId(DimensionManager.getInstance().getNextFreeStarId()); star.setName("Magnis Vulpes"); DimensionManager.getInstance().addStar(star); - generateRandomPlanets(star, 2, 0); + star.setMaxRetinueBodies(2); star = new StellarBody(); @@ -993,7 +835,7 @@ public void createAndLoadDimensions(boolean resetFromXml) { star.setId(DimensionManager.getInstance().getNextFreeStarId()); star.setName("Ma-Roo"); DimensionManager.getInstance().addStar(star); - generateRandomPlanets(star, 6, 0); + star.setMaxRetinueBodies(6); star = new StellarBody(); star.setTemperature(120); @@ -1002,7 +844,7 @@ public void createAndLoadDimensions(boolean resetFromXml) { star.setId(DimensionManager.getInstance().getNextFreeStarId()); star.setName("Alykitt"); DimensionManager.getInstance().addStar(star); - generateRandomPlanets(star, 3, 1); + star.setMaxRetinueBodies(4); } } @@ -1068,11 +910,11 @@ public void createAndLoadDimensions(boolean resetFromXml) { // duplicate random planets every load. Gate on the true first-run // discriminator: only generate randoms when no persisted dims exist. if (!loadedFromXML && loadedPlanets.isEmpty()) { - //Add planets + // Carry each system's body count into the universe layer instead of spending it on a + // second world-making model here — see the sibling site above. for (StellarBody star : dimCouplingList.stars) { - int numRandomGeneratedPlanets = loader.getMaxNumPlanets(star); - int numRandomGeneratedGasGiants = loader.getMaxNumGasGiants(star); - generateRandomPlanets(star, numRandomGeneratedPlanets, numRandomGeneratedGasGiants); + star.setMaxRetinueBodies(loader.getMaxNumPlanets(star) + + loader.getMaxNumGasGiants(star)); } } @@ -1081,14 +923,20 @@ public void createAndLoadDimensions(boolean resetFromXml) { zmaster587.advancedRocketry.universe.UniverseRegistry.stageAnchors(dimCouplingList.anchorCoords, resetFromXml); } - // Install the procedural galaxy generator when the pack opts in via ; otherwise reset to - // the authored-anchors-only default. The generator is a JVM-global, so reset every load so a world - // without never inherits a previous world's generator. + // Hand the pack's knobs to the universe layer. The generator built from them is + // installed for real at populate(), because WHICH world model interprets these knobs is a + // property of the SAVE (its schema stamp) and the save is not reachable here — worlds are not + // loaded yet. The pack states the parameters; the world states the version. + // + // The provisional install below keeps this window behaving exactly as it did before the stamp + // existed: the generator is a JVM-global, so it is reset every load and a world without + // never inherits a previous world's generator. populate() then replaces it with the + // generator the save is actually owed, before anything derives. zmaster587.advancedRocketry.universe.GalaxyGenConfig galaxyGenConfig = (dimCouplingList != null) ? dimCouplingList.galaxyGenConfig : null; - zmaster587.advancedRocketry.universe.UniverseRegistry.setGenerator(galaxyGenConfig == null - ? null - : new zmaster587.advancedRocketry.universe.ClusteredGalaxyGenerator(galaxyGenConfig)); + zmaster587.advancedRocketry.universe.UniverseRegistry.stageGalaxyConfig(galaxyGenConfig); + zmaster587.advancedRocketry.universe.UniverseRegistry.setGenerator( + zmaster587.advancedRocketry.universe.UniverseSchemas.current().generator(galaxyGenConfig)); // C129: registration authority on load was planetDefs.xml only (the loop // above), while per-dim persisted state lives in temp.dat (loadedPlanets). // A dim present in temp.dat but absent from a hand-edited / restored / @@ -1105,6 +953,12 @@ public void createAndLoadDimensions(boolean resetFromXml) { props.setStar(props.getStarId()); } + // 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 80e38ef86..9305ed61d 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; @@ -95,8 +103,17 @@ private static float clampFeatureFrequencyMultiplier(float multiplier) { //Used in solar panels public double peakInsolationMultiplier; public double peakInsolationMultiplierWithoutAtmosphere; - //Stored in Kelvin - public int averageTemperature; + /** + * This world's surface temperature in KELVIN — a DERIVED quantity, cached here. + * + *

    Private, and it is the point. It used to be a public field that + * {@link #getAverageTemp()} ASSIGNED on every call, while a dozen readers inside this class took + * the field directly — so what any of them saw depended on whether anything had happened to call + * the accessor first, and the value NBT had faithfully restored was discarded by the first read + * after a load. One door in ({@link #setAverageTemp}), one door out, and the recompute now happens + * where an INPUT changes rather than where the answer is asked for.

    + */ + private int averageTemperature; public int rotationalPeriod; //Stored in radians public double orbitTheta; @@ -181,6 +198,67 @@ 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 = ""; + + // ─── 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; + /** + * 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 + * 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 @@ -247,6 +325,7 @@ public DimensionProperties(int id) { terrainSource = TerrainSource.NATIVE; terrainWorldType = ""; terrainTemplate = ""; + terrainGeneratorOptions = ""; //target_sea_level = seaLevel; //water_can_exist = true; @@ -437,7 +516,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; } /** @@ -449,7 +540,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<>(); @@ -469,7 +560,118 @@ public void resetProperties() { terrainSource = TerrainSource.NATIVE; terrainWorldType = ""; terrainTemplate = ""; + terrainGeneratorOptions = ""; laserDrillOres = new ArrayList<>(); + mass = BULK_UNSET; + radius = BULK_UNSET; + albedo = AstronomicalBodyHelper.EARTH_ALBEDO; + 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; + } + + /** 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; + } + + /** + * 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. + * + * @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() { @@ -488,6 +690,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() { @@ -831,6 +1036,13 @@ public void setAtmosphereDensity(int atmosphereDensity) { int prevAtm = this.atmosphereDensity; this.atmosphereDensity = atmosphereDensity; + // The ONE input that changes while a world is in play — the terraformer thickens or thins the + // air, and the greenhouse term moves with it. Everything else a temperature is derived from + // (the stars, the orbit, the albedo) is fixed when the world is materialized, and is STATED + // through setAverageTemp rather than recomputed here: a load path that recomputed would be + // running before its own inputs had all been read. + recalculateTemperature(); + load_terraforming_helper(true); @@ -1713,6 +1925,16 @@ else if (nbt.hasKey("craterBiomes", 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; + 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; + scaledOreCache = null; + scaledOreCacheFor = Double.NaN; orbitalDist = nbt.getInteger("orbitalDist"); orbitTheta = nbt.getDouble("orbitTheta"); baseOrbitTheta = nbt.getDouble("baseOrbitTheta"); @@ -1748,6 +1970,7 @@ else if (nbt.hasKey("craterBiomes", 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"); @@ -2108,6 +2331,26 @@ 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 (albedo != AstronomicalBodyHelper.EARTH_ALBEDO) { + nbt.setDouble("albedo", albedo); + } + 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); @@ -2140,6 +2383,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); @@ -2199,21 +2444,33 @@ public void writeToNBT(NBTTagCompound nbt) { */ @Override public int getAverageTemp() { - averageTemperature = AstronomicalBodyHelper.getAverageTemperature(this.getStar(), this.getSolarOrbitalDistance(), this.getAtmosphereDensity()); - - /* - int temp = averageTemperature; - float pressure = (float) (atmosphereDensity + 1) / (float) 100; - pressure = (float) Math.max(0.01, pressure); - float water_can_exist_value = 400; - float planetvalue = temp / pressure; + return averageTemperature; + } - if (planetvalue < water_can_exist_value) { - water_can_exist = true; - } else water_can_exist = false; - */ + /** + * State this world's surface temperature, in KELVIN. + * + *

    The one door in. A caller that MATERIALIZES a world — realization from a derived profile, an + * XML load, a probe fixture — states the number it already has; everything else changes an INPUT + * and lets {@link #recalculateTemperature()} follow.

    + */ + public void setAverageTemp(int kelvin) { + this.averageTemperature = kelvin; + } - return averageTemperature; + /** + * Recompute the surface temperature from this world's current inputs — its stars, its orbit, its + * atmosphere and its albedo. + * + *

    Called where an input CHANGES, never where the answer is read. On a world that was + * materialized from a derived profile this is a no-op by construction: {@code PlanetDerivation} + * ends on this same call with this same albedo, so a recompute reproduces the number a telescope + * already reported. That equality is the contract, and it is what stopped a scanned world from + * cooling down on the way there.

    + */ + public void recalculateTemperature() { + setAverageTemp(AstronomicalBodyHelper.getAverageTemperature(getStar(), + getSolarOrbitalDistance(), getAtmosphereDensity(), albedo)); } public IBlockState getOceanBlock() { @@ -2306,11 +2563,11 @@ 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) { - theta = AstronomicalBodyHelper.getOrbitalThetaAt(orbitalDist, host.getSize(), worldTick); + theta = AstronomicalBodyHelper.getOrbitalThetaAt(orbitalDist, host.getMass(), worldTick); } } return (theta + baseOrbitTheta) * (isRetrograde ? -1 : 1); @@ -2421,6 +2678,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; } @@ -2485,12 +2750,35 @@ public boolean canGenerateCaves() { return this.canGenerateCaves; } + /** + * How big this world is drawn in the planet view. + * + *

    It follows the body's RADIUS, which is what a drawn size is. It used to be + * {@code max(g², 0.5)} — a size synthesised from gravity, which is not a size — and that was a + * necessary approximation only while a planet had no radius of its own. It has had one since mass + * and radius became primary properties, and gravity is now DERIVED from them, so sizing by gravity + * squared means sizing by mass²/radius⁴: a dense small world drew larger than a big light one.

    + * + *

    The floor and the per-kind factors are unchanged, so an Earth-sized world (radius 1) draws + * exactly as it did — what moves is everything that is not Earth-sized.

    + */ public float getRenderSizePlanetView() { - return (isMoon() ? 8f : 10f) * Math.max(this.getGravitationalMultiplier() * this.getGravitationalMultiplier(), .5f) * 100; + return (isMoon() ? 8f : 10f) * renderRadiusFactor() * 100; } + /** The same, in the solar view, where a moon is drawn much smaller against its system. */ public float getRenderSizeSolarView() { - return (isMoon() ? 0.2f : 1f) * Math.max(this.getGravitationalMultiplier() * this.getGravitationalMultiplier(), .5f) * 100; + return (isMoon() ? 0.2f : 1f) * renderRadiusFactor() * 100; + } + + /** + * The body's radius in Earth radii, floored — the one quantity both views scale by. A world with + * no stated bulk falls back to one Earth radius, which is what an unstated bulk describes + * everywhere else in this layer. + */ + private float renderRadiusFactor() { + double r = getRadius(); + return (float) Math.max(r > 0d ? r : 1d, 0.5d); } // Relative to parent 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/entity/EntityRocket.java b/src/main/java/zmaster587/advancedRocketry/entity/EntityRocket.java index 7e3e4cafb..46cce49a1 100644 --- a/src/main/java/zmaster587/advancedRocketry/entity/EntityRocket.java +++ b/src/main/java/zmaster587/advancedRocketry/entity/EntityRocket.java @@ -1098,6 +1098,25 @@ public void tickFreeFlight() { in, thrustMag, gravity, canThrust); } + // ATMOSPHERE. Applied to whatever law just ran, because air does not ask which one it was. + // This is what bounds a craft's speed now that the law does not: the ceiling is a property of + // where you are, and in vacuum there is none. + // + // The STRICT lookup, deliberately: getDimensionProperties answers an unknown id with the + // OVERWORLD's properties, which carry a full atmosphere - so a space cell, a slot world or + // hyperspace would read as one-atmosphere air and quietly brake every ship flying through + // vacuum. A dimension that is not a registered body has no air here, which is also the + // physically right answer. + DimensionProperties atmProps = DimensionManager.getInstance() + .getDimensionPropertiesOrNull(this.world.provider.getDimension()); + double atmDensity = atmProps == null ? 0.0 : atmProps.getAtmosphereDensity() / 100.0; + if (atmDensity > 0.0) { + double[] dragged = FreeFlightPhysics.atmosphericDrag( + result.motionX, result.motionY, result.motionZ, atmDensity); + result = new FreeFlightPhysics.Step(dragged[0], dragged[1], dragged[2], + result.yaw, result.pitch, result.roll, result.thrustApplied); + } + // Engine power = magnitude of the thrust the engines applied this tick, // i.e. the world-frame Δv MINUS gravity (gravity is not thrust): the // difference between the resulting motion and where the craft would have diff --git a/src/main/java/zmaster587/advancedRocketry/hyperdrive/CapacitorCharge.java b/src/main/java/zmaster587/advancedRocketry/hyperdrive/CapacitorCharge.java index bce88a9e8..e5d077cde 100644 --- a/src/main/java/zmaster587/advancedRocketry/hyperdrive/CapacitorCharge.java +++ b/src/main/java/zmaster587/advancedRocketry/hyperdrive/CapacitorCharge.java @@ -1,17 +1,21 @@ package zmaster587.advancedRocketry.hyperdrive; /** - * The capacitor's charge, computed rather than accumulated. + * How long a jump bank takes to reach a level — the cooldown a pilot is quoted, and nothing else. * - *

    Nothing here ever ticks. The charge is a closed form of the world clock — {@code charge(t) = - * min(capacity, c0 + rate·(t − since))} — so a capacitor aboard a ship parked in an unloaded cell, - * or one that spent a month in hyperspace, is exactly as charged as one that sat in a loaded chunk - * the whole time. Only {@code c0} and {@code since} persist, and they only change when something - * really happens to the capacitor: a burst, or a rebuild.

    + *

    This class used to BE the charge, and that was the defect. It held a closed form of the + * world clock, {@code charge(t) = min(capacity, c0 + rate·(t − since))}, so a capacitor stored no + * energy: its level was arithmetic over elapsed ticks and the rate was conjured by welding heat sinks + * on. The hyperdrive's largest single cost — the window burst, twenty times the drive's power — was + * therefore free, paid for in wall-clock time rather than in generation. The bank is now a real Forge + * Energy receiver fed by the ship (see {@code TileJumpCapacitor}), and what is left here is the one + * thing that was never wrong: turning a deficit and a rate into a number of ticks.

    * - *

    The cooldown a pilot feels falls out of the same form and needs no timer of its own: after a - * burst the capacitor is empty, so the reload is however long {@code charge(t)} takes to climb back - * to the next burst's cost.

    + *

    What that number IS has changed with it. It used to be a prediction, because the rate was a + * property of the capacitor and could not be missed. It is now a best case: the rate is the + * bank's own accept limit, and whether the ship's power plant actually delivers it is the plant's + * business. A forecast that says "at full inflow" is honest; the same number presented as a promise + * would be the free energy coming back as a lie about time.

    */ public final class CapacitorCharge { @@ -19,43 +23,23 @@ private CapacitorCharge() { } /** - * The charge at {@code now}. Clamped at both ends: never below zero, never above capacity, and - * never advanced by a clock that has run backwards (which a restored world can do). + * Ticks from now until a bank holding {@code current} of {@code capacity} reaches {@code needed}, + * fed at {@code ratePerTick}. Zero means "already"; {@code -1} means never, because the bank + * cannot hold that much however long anybody waits. */ - public static long at(long baseCharge, long since, long chargeRate, long capacity, long now) { - long cap = Math.max(0L, capacity); - long base = Math.min(cap, Math.max(0L, baseCharge)); - long elapsed = now - since; - if (elapsed <= 0L || chargeRate <= 0L) { - return base; - } - long gained; - long rate = Math.max(0L, chargeRate); - if (rate != 0L && elapsed > (Long.MAX_VALUE - base) / rate) { - gained = Long.MAX_VALUE - base; // a months-long absence overflows a naive multiply - } else { - gained = rate * elapsed; - } - return Math.min(cap, base + gained); - } - - /** - * How many ticks from {@code now} until the charge reaches {@code needed}, or {@code -1} when it - * never will because the capacitor is too small to hold that much. Zero means "already". - */ - public static long ticksUntil(long baseCharge, long since, long chargeRate, long capacity, - long now, long needed) { + public static long ticksToReach(long current, long capacity, long ratePerTick, long needed) { long cap = Math.max(0L, capacity); if (needed <= 0L) { return 0L; } - long current = at(baseCharge, since, chargeRate, cap, now); - if (current >= needed) { + long have = Math.min(cap, Math.max(0L, current)); + if (have >= needed) { return 0L; } - if (needed > cap || chargeRate <= 0L) { + if (needed > cap || ratePerTick <= 0L) { return -1L; // no amount of waiting gets there } - return (needed - current + chargeRate - 1L) / chargeRate; + long deficit = needed - have; + return (deficit + ratePerTick - 1L) / ratePerTick; } } diff --git a/src/main/java/zmaster587/advancedRocketry/hyperdrive/DriveTier.java b/src/main/java/zmaster587/advancedRocketry/hyperdrive/DriveTier.java new file mode 100644 index 000000000..9fbfbd172 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/hyperdrive/DriveTier.java @@ -0,0 +1,81 @@ +package zmaster587.advancedRocketry.hyperdrive; + +import zmaster587.advancedRocketry.universe.UniverseScale; + +/** + * A hyperdrive's generation, and the only thing it changes: how efficiently power becomes speed. + * + *

    A tier is a coefficient, never a licence

    + *

    There is no permission gate anywhere on this enum. A first-generation drive aimed across + * interstellar space is not refused — it simply goes much slower, which makes the trip unreasonable + * rather than impossible, and the barrier a player then meets is life support and generation without + * sunlight over that duration. Real systems and real risks, not a red message.

    + * + *

    Why the tiers are the bands, and why there are exactly two

    + *

    Distance in this universe is not smooth: it comes in bands separated by orders of magnitude — + * across a system, out to the nearest stars, across a galaxy, out to the next one. Growing a drive + * (more coils) is spent ONCE and closes the first of those gaps; after that the coils are gone, so a + * tier has to pay a WHOLE band gap rather than a residue. A tier therefore exists for each gap that + * building bigger cannot cover, and each one is NAMED for the band it owns.

    + * + *

    The gap out to the next galaxy is only {@link UniverseScale#GALAXY_SEPARATION_IN_DIAMETERS}, far + * below what one generation of drive is worth, so there is no third tier: reaching another galaxy is + * patience at full {@link #GALACTIC}, which is an honest answer rather than a refusal.

    + */ +public enum DriveTier { + + /** + * The drive a player builds himself. Its band is his own neighbourhood of stars, and it closes + * that band by SIZE — the coil count — rather than by efficiency, which is why its efficiency is + * the unit: every other tier is quoted against it. + */ + INTERSTELLAR(1d), + + /** + * The drive that makes a galaxy crossable. Its efficiency is not a chosen number: it IS the gap + * between the two bands, a galaxy's diameter measured in interstellar steps, so it is derived from + * the two lengths the universe layer already declares and moves with them if they ever move. + * + *

    That derivation is the point. Written as a literal it would be a number nobody could check + * and one that silently stopped meaning "one band" the first time the star separation or the + * galaxy size was retuned.

    + */ + GALACTIC(2d * UniverseScale.REFERENCE_GALAXY_RADIUS_LY / UniverseScale.MEAN_STAR_SEPARATION_LY); + + private final double efficiency; + + DriveTier(double efficiency) { + this.efficiency = Math.max(1d, efficiency); + } + + /** + * How much more speed this generation gets out of the same power as {@link #INTERSTELLAR}, which + * is 1 by definition. + * + *

    It sits in the DENOMINATOR of a route's total energy — ticks are {@code d·m/(η·P)} and the + * in-flight draw is proportional to {@code P}, so power cancels and the bill for a leg depends on + * distance, mass and the tier alone. "A tier buys efficiency" is therefore literal arithmetic and + * not a figure of speech.

    + */ + public double efficiency() { + return efficiency; + } + + /** The band this generation is built to cross in the time one band is meant to take. */ + public double bandLightYears() { + return this == GALACTIC + ? 2d * UniverseScale.REFERENCE_GALAXY_RADIUS_LY + : UniverseScale.MEAN_STAR_SEPARATION_LY; + } + + /** The generation every hull has until a later one is built. */ + public static DriveTier baseline() { + return INTERSTELLAR; + } + + /** The tier stored under {@code ordinal}, or the baseline when the value is not one of ours. */ + public static DriveTier byOrdinal(int ordinal) { + DriveTier[] all = values(); + return (ordinal < 0 || ordinal >= all.length) ? baseline() : all[ordinal]; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/hyperdrive/DriveTuning.java b/src/main/java/zmaster587/advancedRocketry/hyperdrive/DriveTuning.java index c83b2cbe1..32faac378 100644 --- a/src/main/java/zmaster587/advancedRocketry/hyperdrive/DriveTuning.java +++ b/src/main/java/zmaster587/advancedRocketry/hyperdrive/DriveTuning.java @@ -26,6 +26,47 @@ private DriveTuning() { */ public static final int MAX_COILS = 512; + /** + * The coil count a "baseline" generator has — the smallest build worth calling a drive, and the + * one every quoted speed and every band figure is measured against. + */ + public static final int BASELINE_COILS = 7; + + /** + * How a generator's power grows with its SIZE: {@code base + per_coil · n^α}. Above 1, one large + * machine is worth more than several small ones of the same total volume, which is what makes + * building a bigger drive a progression rather than an addition. + * + *

    Currently 1, and the reason it is not the 2 the design derived is an invariant this file + * cannot satisfy alone. Every energy cost of a drive is proportional to its power — the window + * burst above all — while the capacitor that must pay that burst grows only with its COMPONENT + * count, capped at {@link #MAX_CAPACITOR_COMPONENTS}. So power spans {@code (512/7)^α} while the + * bank that feeds it spans a few hundred, and above α = 1 the two detach: at α = 2 a fully built + * drive's burst is roughly two hundred times a full bank, and a jump is REFUSED outright once the + * coil count passes about 35. Raising α therefore needs the capacitor economy re-derived with it, + * and no single constant does that — lifting the bank's capacity leaves the reload time absurd, + * and lowering the burst deletes the capacitor as an early-game requirement.

    + * + *

    What holds the line is the invariant, not this comment: a fully built drive must be able to + * open its own window. It is pinned by a test, so raising this number turns that test red instead + * of shipping a drive that gets slower the moment it is finished.

    + */ + public static final double COIL_POWER_EXPONENT = 1.0D; + + /** + * The drive power a generator with {@code coils} coils is worth. The one place the law lives + * — every quoted power, the baseline, the maximum and the tile that scans a real ship all read it + * here, so the exponent above cannot apply in some places and not others. + */ + public static long powerForCoils(int coils) { + int n = Math.max(0, Math.min(MAX_COILS, coils)); + if (n == 0) { + return GENERATOR_BASE_POWER; + } + double scaled = POWER_PER_COIL * Math.pow(n, COIL_POWER_EXPONENT); + return GENERATOR_BASE_POWER + (long) Math.min((double) Long.MAX_VALUE, Math.round(scaled)); + } + /** Energy the drive draws per tick while the window is held open, per unit of drive power. */ public static final double IN_FLIGHT_DRAW_PER_POWER = 0.05D; /** Energy the capacitor must dump in one moment to open the window, per unit of drive power. */ @@ -48,33 +89,64 @@ private DriveTuning() { public static final long CAPACITOR_BASE_CAPACITY = 20_000L; /** Charge each capacitor cell adds. */ public static final long CAPACITY_PER_CELL = 100_000L; - /** Charge per tick the controller recovers on its own. */ - public static final long CAPACITOR_BASE_CHARGE_RATE = 10L; + + /** + * How much charge per tick the controller can ACCEPT on its own — a throughput limit, never a + * supply. The energy comes from the ship's own generation; this is only how fast the buffer will + * swallow it. + * + *

    It was {@code CAPACITOR_BASE_CHARGE_RATE} and it meant the opposite: joules the block + * recovered by itself, which made the largest cost in this family free. The rename is the whole + * correction — a rate constant standing in for an absent power plant will absorb any amount of + * tuning and never come right.

    + */ + public static final long CAPACITOR_BASE_ACCEPT_RATE = 10L; /** - * Charge per tick each heat sink adds. Cooling does not get a mechanism of its own: a sink - * raises the rate at which the capacitor refills, and the reload time — the cooldown a pilot - * actually feels — is {@code burstCost / chargeRate} with no timer to persist. + * How much more charge per tick each heat sink lets the bank accept. Cooling does not get a + * mechanism of its own: a sink is what allows a large inflow to be swallowed without cooking, so + * the cooldown a pilot feels is his reactors' output against this ceiling — with no timer to + * persist and no energy created anywhere. */ - public static final long CHARGE_RATE_PER_SINK = 40L; + public static final long ACCEPT_RATE_PER_SINK = 40L; /** How many capacitor components (cells + sinks) one controller will count. */ public static final int MAX_CAPACITOR_COMPONENTS = 256; // ─── Speed ───────────────────────────────────────────────────────────────── /** - * The drive power a "baseline" drive has, and the mass of a "baseline" hull. A ship built to - * both flies at {@link #BASELINE_SPEED_BLOCKS_PER_TICK}, and the bands that speed produces - * (seconds inside a system, an hour across a galaxy, months across the universe) are the point - * of the number — not the number itself. + * The drive power a "baseline" drive has, and the mass of a "baseline" hull. A ship built to both, + * on the baseline TIER, flies at {@link #BASELINE_SPEED_BLOCKS_PER_TICK}. + * + *

    DERIVED from {@link #BASELINE_COILS} through {@link #powerForCoils}, never written down: as a + * literal it silently stopped meaning "what a seven-coil generator is worth" the moment the power + * law gained an exponent, and the entry-level speed — a datum from play, and the one the maintainer + * has said is already acceptable — would have moved without anybody choosing to move it.

    */ - public static final long BASELINE_DRIVE_POWER = 8_000L; + public static final long BASELINE_DRIVE_POWER = powerForCoils(BASELINE_COILS); public static final long BASELINE_SHIP_MASS = 4_000L; public static final long BASELINE_SPEED_BLOCKS_PER_TICK = 1_000_000L; + /** + * What a FULLY built generator is worth — the top of what size alone can buy, and the number the + * capacitor economy has to be able to feed. Derived for the same reason as the baseline. + */ + public static final long MAX_DRIVE_POWER = powerForCoils(MAX_COILS); + // ─── Gravity dampeners ───────────────────────────────────────────────────── - /** Exit speed one powered dampener fully absorbs, in blocks per tick. */ - public static final long DAMPENER_ABSORBED_SPEED = 500_000L; + /** + * How much of a BASELINE arrival one powered dampener absorbs. A fraction and not an absolute + * speed: the balance it encodes is "two dampeners cover the ship a novice actually flies", and + * stated in blocks per tick that promise detached silently the first time the speed law moved — + * a tier multiplies every speed by its efficiency, so an absolute half of the old baseline would + * have become a rounding error on the next generation of drive. + */ + public static final double DAMPENER_ABSORBED_BASELINE_FRACTION = 0.5D; + + /** Exit speed one powered dampener fully absorbs, in blocks per tick. Derived from the fraction. */ + public static final long DAMPENER_ABSORBED_SPEED = + (long) Math.max(1d, Math.round(BASELINE_SPEED_BLOCKS_PER_TICK + * DAMPENER_ABSORBED_BASELINE_FRACTION)); /** Radius, in blocks, within which a dampener protects a crew member. */ public static final int DAMPENER_RADIUS = 12; /** Damage taken per block/tick of exit speed the dampeners failed to absorb. */ diff --git a/src/main/java/zmaster587/advancedRocketry/hyperdrive/JumpSpeed.java b/src/main/java/zmaster587/advancedRocketry/hyperdrive/JumpSpeed.java index 7afde7155..31674312d 100644 --- a/src/main/java/zmaster587/advancedRocketry/hyperdrive/JumpSpeed.java +++ b/src/main/java/zmaster587/advancedRocketry/hyperdrive/JumpSpeed.java @@ -7,10 +7,27 @@ * fly at the same speed if one of them is a freighter, and a cruiser that wants a warship's transit * time has to carry a warship's drive.

    * - *

    One constant speed covers every band the game wants — seconds inside a system, an hour across a - * galaxy, months across the universe — because the distances themselves already span nine orders of - * magnitude. Nothing piecewise is needed, and the months figure at the far end is the endgame's - * gate, not a bug to tune away.

    + *

    One constant speed does NOT cover every band — measured

    + * + *

    This class used to claim it did, on the argument that the distances already span nine orders of + * magnitude so nothing piecewise is needed. That was measured and is false. Crossing a system and + * reaching the nearest star differ by about ×5 900, and one linear coefficient cannot serve both: + * calibrated for the star, a system collapses into a single tick; calibrated for the system, the star + * costs months. The far figure was not an endgame gate, it was the same coefficient failing at the + * other end of its range.

    + * + *

    So speed has THREE inputs, and each one answers a different question:

    + *
      + *
    • power — how big the machine is. Bought with coils, spent ONCE, and it closes the first + * band.
    • + *
    • mass — what it is hauling. This is the whole reason mass stops being cosmetic: two + * ships with the same generator do not fly at the same speed if one is a freighter.
    • + *
    • {@link DriveTier} — how efficiently that power becomes speed. A whole band gap per + * generation, because by the time a tier matters the coils are already spent.
    • + *
    + * + *

    Nothing here is piecewise even so: it is one formula whose efficiency term is a property of the + * drive rather than of the distance. A leg is never classified, and no range is ever refused.

    */ public final class JumpSpeed { @@ -18,24 +35,50 @@ private JumpSpeed() { } /** - * Blocks per tick for a drive of {@code drivePower} hauling {@code shipMass}. Never below 1 — - * the transit integrator refuses a zero step, and a ship that cannot move is a softlock rather - * than a slow ship. + * Blocks per tick for a drive of {@code drivePower} and generation {@code tier} hauling + * {@code shipMass}. Never below 1 — the transit integrator refuses a zero step, and a ship that + * cannot move is a softlock rather than a slow ship. + * + *

    There is deliberately no overload that omits the tier. Which generation of drive is flying is + * something every caller KNOWS, and a default would quietly make the answer the baseline one for + * whichever call site forgot — a wrong speed being harder to notice than a missing argument.

    */ - public static long blocksPerTick(long drivePower, long shipMass) { + public static long blocksPerTick(long drivePower, long shipMass, DriveTier tier) { if (drivePower <= 0L) { return 0L; // no drive, no transit: this is refused upstream, not flown slowly } long mass = Math.max(1L, shipMass); double ratio = (drivePower / (double) DriveTuning.BASELINE_DRIVE_POWER) / (mass / (double) DriveTuning.BASELINE_SHIP_MASS); - double speed = DriveTuning.BASELINE_SPEED_BLOCKS_PER_TICK * ratio; + double efficiency = (tier == null ? DriveTier.baseline() : tier).efficiency(); + double speed = DriveTuning.BASELINE_SPEED_BLOCKS_PER_TICK * ratio * efficiency; if (speed >= Long.MAX_VALUE) { return Long.MAX_VALUE; } return Math.max(1L, (long) speed); } + /** + * Total energy a leg of {@code distanceBlocks} costs a ship of {@code shipMass} on {@code tier} — + * the in-flight draw over the whole flight. + * + *

    Drive POWER does not appear, and that is the point. Ticks go as {@code d·m/(η·P)} and + * the draw goes as {@code P}, so the two cancel exactly: a bigger drive does not change the bill + * for a trip, it changes how fast you pay it. "Size buys power, the tier buys efficiency" is + * therefore arithmetic rather than a slogan — η is the only term here that a player can improve, + * and it sits in the denominator.

    + */ + public static double routeEnergy(double distanceBlocks, long shipMass, DriveTier tier) { + if (distanceBlocks <= 0d) { + return 0d; + } + double efficiency = (tier == null ? DriveTier.baseline() : tier).efficiency(); + double massRatio = Math.max(1L, shipMass) / (double) DriveTuning.BASELINE_SHIP_MASS; + return DriveTuning.IN_FLIGHT_DRAW_PER_POWER * distanceBlocks * massRatio + * DriveTuning.BASELINE_DRIVE_POWER + / (efficiency * DriveTuning.BASELINE_SPEED_BLOCKS_PER_TICK); + } + /** * Ticks a transit of {@code distanceBlocks} takes at {@code speedBlocksPerTick}, the same way * the transit manager computes its own arrival tick — so the forecast the pilot reads before he diff --git a/src/main/java/zmaster587/advancedRocketry/hyperdrive/JumpTrigger.java b/src/main/java/zmaster587/advancedRocketry/hyperdrive/JumpTrigger.java index ca64619ac..2984c2d0f 100644 --- a/src/main/java/zmaster587/advancedRocketry/hyperdrive/JumpTrigger.java +++ b/src/main/java/zmaster587/advancedRocketry/hyperdrive/JumpTrigger.java @@ -176,7 +176,8 @@ public static Result commit(World world, BlockPos flightComputerPos, UUID shipId return new Result(Outcome.FAILED, MSG_NO_POSITION); } long speed = JumpSpeed.blocksPerTick(nav.drive().stats().drivePower(), - ShipMassProvider.massOf(world, flightComputerPos, shipId)); + ShipMassProvider.massOf(world, flightComputerPos, shipId), + nav.drive().stats().tier()); // Which world the ship must be cut out of is asked of the thing that binds cells to slots, // never remembered next to the coordinate: a slot id is minted per boot and re-used, so a diff --git a/src/main/java/zmaster587/advancedRocketry/hyperdrive/ShipDrive.java b/src/main/java/zmaster587/advancedRocketry/hyperdrive/ShipDrive.java index 11d8c4f06..25eaf1ff8 100644 --- a/src/main/java/zmaster587/advancedRocketry/hyperdrive/ShipDrive.java +++ b/src/main/java/zmaster587/advancedRocketry/hyperdrive/ShipDrive.java @@ -99,11 +99,11 @@ public ShipDriveStats stats() { return gen == null ? ShipDriveStats.NONE : gen.stats(); } - /** Charge available across every connected capacitor at {@code now}. */ - public long capacitorCharge(long now) { + /** Charge available across every connected capacitor. */ + public long capacitorCharge() { long total = 0L; for (TileJumpCapacitor capacitor : capacitors()) { - total += capacitor.chargeAt(now); + total += capacitor.charge(); } return total; } @@ -118,21 +118,23 @@ public long capacitorCapacity() { } /** - * Ticks until the bank can open a window again, or {@code -1} when it never can. This is the - * cooldown, and it is entirely a consequence of what the player built. + * Ticks until the bank can open a window again if the ship feeds it at the bank's full accept + * rate, or {@code -1} when it never can. A BEST CASE: the energy comes from the ship's own + * generation, so a pilot who has under-built his reactors waits longer than this says. It is still + * entirely a consequence of what the player built — now of two things he built rather than one. */ - public long cooldownTicks(long now) { + public long cooldownTicks() { long needed = stats().burstCost(); if (needed <= 0L) { return -1L; } long best = -1L; - long charge = capacitorCharge(now); + long charge = capacitorCharge(); if (charge >= needed) { return 0L; } for (TileJumpCapacitor capacitor : capacitors()) { - long ticks = capacitor.ticksUntil(needed, now); + long ticks = capacitor.ticksUntilAtFullInflow(needed); if (ticks < 0L) { continue; } @@ -149,7 +151,7 @@ public long cooldownTicks(long now) { */ public boolean fireBurst(long now) { long needed = stats().burstCost(); - if (needed <= 0L || capacitorCharge(now) < needed) { + if (needed <= 0L || capacitorCharge() < needed) { return false; } long remaining = needed; @@ -157,9 +159,9 @@ public boolean fireBurst(long now) { if (remaining <= 0L) { break; } - long available = capacitor.chargeAt(now); + long available = capacitor.charge(); long take = Math.min(available, remaining); - if (take > 0L && capacitor.discharge(take, now) == take) { + if (take > 0L && capacitor.discharge(take) == take) { remaining -= take; } } diff --git a/src/main/java/zmaster587/advancedRocketry/hyperdrive/ShipDriveStats.java b/src/main/java/zmaster587/advancedRocketry/hyperdrive/ShipDriveStats.java index 829c201aa..c1995405f 100644 --- a/src/main/java/zmaster587/advancedRocketry/hyperdrive/ShipDriveStats.java +++ b/src/main/java/zmaster587/advancedRocketry/hyperdrive/ShipDriveStats.java @@ -13,33 +13,46 @@ public final class ShipDriveStats { private static final String NBT_POWER = "drivePower"; private static final String NBT_DRAW = "inFlightDraw"; private static final String NBT_BURST = "burstCost"; + private static final String NBT_TIER = "driveTier"; /** A ship with no generator at all. Every stat is zero, which is what makes it refusable. */ - public static final ShipDriveStats NONE = new ShipDriveStats(0L, 0L, 0L); + public static final ShipDriveStats NONE = + new ShipDriveStats(0L, 0L, 0L, DriveTier.baseline()); private final long drivePower; private final long inFlightDraw; private final long burstCost; + private final DriveTier tier; - public ShipDriveStats(long drivePower, long inFlightDraw, long burstCost) { + public ShipDriveStats(long drivePower, long inFlightDraw, long burstCost, DriveTier tier) { this.drivePower = Math.max(0L, drivePower); this.inFlightDraw = Math.max(0L, inFlightDraw); this.burstCost = Math.max(0L, burstCost); + this.tier = (tier == null) ? DriveTier.baseline() : tier; } /** - * The stats a generator of {@code drivePower} produces. The draw and the burst are both derived - * from the power, so a player who builds a stronger drive automatically signs up for the bigger - * capacitor and the heavier in-flight bill that come with it. + * The stats a generator of {@code drivePower} and generation {@code tier} produces. The draw and + * the burst are both derived from the power, so a player who builds a stronger drive automatically + * signs up for the bigger capacitor and the heavier in-flight bill that come with it. + * + *

    The tier is stated rather than assumed: it is the one thing about a drive that the blocks + * themselves declare, and a stats object that guessed it would fly a later generation at the + * baseline's speed with nothing to show that it had.

    */ - public static ShipDriveStats ofPower(long drivePower) { + public static ShipDriveStats ofPower(long drivePower, DriveTier tier) { long power = Math.max(0L, drivePower); if (power == 0L) { return NONE; } return new ShipDriveStats(power, (long) Math.ceil(power * DriveTuning.IN_FLIGHT_DRAW_PER_POWER), - (long) Math.ceil(power * DriveTuning.BURST_COST_PER_POWER)); + (long) Math.ceil(power * DriveTuning.BURST_COST_PER_POWER), tier); + } + + /** Which generation of drive this is — the efficiency half of the speed law. */ + public DriveTier tier() { + return tier; } /** How deep a well this drive crosses, and how fast it crosses it. */ @@ -66,16 +79,17 @@ public void writeToNBT(NBTTagCompound nbt) { nbt.setLong(NBT_POWER, drivePower); nbt.setLong(NBT_DRAW, inFlightDraw); nbt.setLong(NBT_BURST, burstCost); + nbt.setInteger(NBT_TIER, tier.ordinal()); } public static ShipDriveStats readFromNBT(NBTTagCompound nbt) { return new ShipDriveStats(nbt.getLong(NBT_POWER), nbt.getLong(NBT_DRAW), - nbt.getLong(NBT_BURST)); + nbt.getLong(NBT_BURST), DriveTier.byOrdinal(nbt.getInteger(NBT_TIER))); } @Override public String toString() { return "ShipDriveStats[power=" + drivePower + ",draw=" + inFlightDraw - + ",burst=" + burstCost + "]"; + + ",burst=" + burstCost + ",tier=" + tier + "]"; } } diff --git a/src/main/java/zmaster587/advancedRocketry/integration/vs/VSBridge.java b/src/main/java/zmaster587/advancedRocketry/integration/vs/VSBridge.java index e42f410ad..864787338 100644 --- a/src/main/java/zmaster587/advancedRocketry/integration/vs/VSBridge.java +++ b/src/main/java/zmaster587/advancedRocketry/integration/vs/VSBridge.java @@ -832,6 +832,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 d4d1b53fb..b8eb92d27 100644 --- a/src/main/java/zmaster587/advancedRocketry/integration/vs/VSIntegration.java +++ b/src/main/java/zmaster587/advancedRocketry/integration/vs/VSIntegration.java @@ -563,6 +563,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 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/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/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/navigation/ShipNavigation.java b/src/main/java/zmaster587/advancedRocketry/navigation/ShipNavigation.java index 1802a1d87..f3f48b878 100644 --- a/src/main/java/zmaster587/advancedRocketry/navigation/ShipNavigation.java +++ b/src/main/java/zmaster587/advancedRocketry/navigation/ShipNavigation.java @@ -15,6 +15,7 @@ import zmaster587.advancedRocketry.integration.vs.VSIntegration; import zmaster587.advancedRocketry.space.GalacticCoord; import zmaster587.advancedRocketry.space.ShipLedger; +import zmaster587.advancedRocketry.space.ShipTransitManager; import zmaster587.advancedRocketry.space.SpaceSubsystem; import zmaster587.advancedRocketry.tile.TileNavigationComputer; @@ -92,7 +93,7 @@ public long capacitorCapacity() { @Override public long capacitorCharge() { - return drive().capacitorCharge(SpaceSubsystem.spaceClock()); + return drive().capacitorCharge(); } @Override @@ -126,7 +127,8 @@ public ShipDrive drive() { /** Blocks per tick this ship would fly at, given its drive and its hull. */ public long plannedSpeed() { return JumpSpeed.blocksPerTick(drive().stats().drivePower(), - ShipMassProvider.massOf(world, flightComputerPos, shipId)); + ShipMassProvider.massOf(world, flightComputerPos, shipId), + drive().stats().tier()); } /** How long the flight to the current target would take, in ticks. Zero without a target. */ @@ -141,6 +143,25 @@ public long plannedTransitTicks() { plannedSpeed()); } + /** + * Would this jump be performed as a single crossing rather than flown through hyperspace? + * + *

    Asked of {@link ShipTransitManager#isDirectCrossing} — the same predicate the departure reads, + * never a second copy of the rule. A console that quoted one mechanism while the drive performed + * the other would be showing the pilot a flight he is not going to get, and he has no way to + * check.

    + */ + public boolean plannedJumpIsDirect() { + GalacticCoord target = target(); + GalacticCoord origin = currentCoord(); + if (target == null || origin == null) { + return false; + } + return ShipTransitManager.isDirectCrossing( + SpaceSubsystem.frames().distanceBetween(origin, target, SpaceSubsystem.spaceClock()), + plannedSpeed()); + } + /** Where the ship is now, as the durable ledger records it, or {@code null}. */ public GalacticCoord currentCoord() { ShipLedger ledger = SpaceSubsystem.ledger(); diff --git a/src/main/java/zmaster587/advancedRocketry/network/PacketSystemBodiesSync.java b/src/main/java/zmaster587/advancedRocketry/network/PacketSystemBodiesSync.java index 284769e82..8a8a69287 100644 --- a/src/main/java/zmaster587/advancedRocketry/network/PacketSystemBodiesSync.java +++ b/src/main/java/zmaster587/advancedRocketry/network/PacketSystemBodiesSync.java @@ -32,14 +32,28 @@ * {@code writeInt(slotDimId)}, {@code writeInt(bodyCount)} and, per body, * {@code writeInt(kindOrdinal)}, {@code writeLong(localX)}, {@code writeLong(localY)}, * {@code writeLong(localZ)}, {@code writeInt(dimId)}, {@code writeBoolean(descendTarget)}, - * {@code writeLong(boundaryRadius)}. - * {@code executeClient} stashes the decoded payload into a client-side static map (idempotent overwrite) - * that {@link #bodiesForDim(int)} reads; {@code read} and {@code executeServer} are never used.

    + * {@code writeLong(boundaryRadius)}, {@code writeLong(radiusBlocks)}, {@code writeInt(parentIndex)}; + * then the NEBULA half, {@code writeInt(dimCount)} and, per dim, + * {@code writeInt(slotDimId)}, {@code writeInt(nebulaCount)} and, per cloud, + * {@code writeFloat(dirX/dirY/dirZ)}, {@code writeFloat(angularRadius)}, + * {@code writeInt(appearanceOrdinal)}, {@code writeFloat(opacity)}. + * {@code executeClient} stashes the decoded payload into client-side static maps (idempotent overwrite) + * that {@link #bodiesForDim(int)} and {@link #nebulaeForDim(int)} read; {@code read} and + * {@code executeServer} are never used.

    + * + *

    The nebula half rides this packet rather than one of its own because it answers the same question + * — what does the sky of this cell show — keyed by the same cell→slot binding and cleared by the + * same empty payload. Bodies carry a POSITION (they are destinations); a cloud carries a DIRECTION and + * an apparent size, and nothing else, because it is not one.

    */ public final class PacketSystemBodiesSync extends BasePacket { /** One render body for a slot dim: what to draw and where, plus the descend-target highlight flag. */ public static final class RenderBody { + + /** {@link #parentIndex} of a body that belongs to nothing — a star, a planet, a lone POI. */ + public static final int NO_PARENT = -1; + public final int kindOrdinal; public final long localX; public final long localY; @@ -58,8 +72,39 @@ public static final class RenderBody { */ public final long boundaryRadius; + /** + * How big the body itself is, in blocks — its own radius on the chart metric, not the shell + * around it. + * + *

    Sent because the client cannot derive it: the universe registry is server-side, and a + * procedural world has no dimension to read a radius out of until somebody lands on it. + * Without this the sky sized a body by DISTANCE alone, so a moon and a gas giant side by + * side drew exactly the same disc. Zero for anything that is not a sphere — a belt, a + * station slot — which a renderer must treat as "no size of its own" rather than as + * "infinitely small".

    + */ + public final long radiusBlocks; + + /** + * Index, WITHIN THIS DIM'S BODY LIST, of the body this one belongs to — or {@code -1} for a + * body that belongs to nothing. + * + *

    Structure, which is the half of the feed that was missing: a moon carried a direction + * and a size but no way to say whose moon it was, so the sky could draw a giant and its + * retinue and not tell a pilot they were one destination. An INDEX rather than an id because + * the list is sent as a unit and a procedural body has no id of any kind — it has no + * dimension until somebody lands on it.

    + * + *

    Resolved server-side from the invariant the universe layer already holds: a moon shares + * its parent's CELL, and a cell holds at most one real body with moons excepted. So the + * parent of a moon is the non-moon body of the same cell, and there is never a second + * candidate.

    + */ + public final int parentIndex; + public RenderBody(int kindOrdinal, long localX, long localY, long localZ, int dimId, - boolean descendTarget, long boundaryRadius) { + boolean descendTarget, long boundaryRadius, long radiusBlocks, + int parentIndex) { this.kindOrdinal = kindOrdinal; this.localX = localX; this.localY = localY; @@ -67,26 +112,88 @@ public RenderBody(int kindOrdinal, long localX, long localY, long localZ, int di this.dimId = dimId; this.descendTarget = descendTarget; this.boundaryRadius = boundaryRadius; + this.radiusBlocks = radiusBlocks; + this.parentIndex = parentIndex; } @Override public String toString() { return "RenderBody{kind=" + kindOrdinal + ",dir=" + localX + "," + localY + "," + localZ - + ",dim=" + dimId + ",descend=" + descendTarget + ",shell=" + boundaryRadius + "}"; + + ",dim=" + dimId + ",descend=" + descendTarget + ",shell=" + boundaryRadius + + ",r=" + radiusBlocks + ",parent=" + parentIndex + "}"; + } + } + + /** + * One nebula for a slot dim: a DIRECTION and an apparent SIZE, never a position. + * + *

    A cloud is light years across and hundreds of light years away, so it has no parallax across + * a cell and nothing can be flown to it — it is deliberately not a destination and carries no + * address. What the sky needs is where to look, how much of the sky it covers, what it looks + * like, and how thick it is; those four are all of it.

    + */ + public static final class RenderNebula { + /** Unit vector from the observer towards the cloud's centre, in the static frame. */ + public final float dirX; + public final float dirY; + public final float dirZ; + /** + * Half-angle the cloud subtends, in radians. A viewer INSIDE one gets a right angle: the + * cloud is all around him, which is the honest limit rather than an overflow. + */ + public final float angularRadius; + /** {@code Nebula.Appearance} ordinal — dark, emission or reflection. Decides the tint. */ + public final int appearanceOrdinal; + /** How thick it is at its densest, {@code 0}..{@code 1}. Decides how strongly it draws. */ + public final float opacity; + + public RenderNebula(float dirX, float dirY, float dirZ, float angularRadius, + int appearanceOrdinal, float opacity) { + this.dirX = dirX; + this.dirY = dirY; + this.dirZ = dirZ; + this.angularRadius = angularRadius; + this.appearanceOrdinal = appearanceOrdinal; + this.opacity = opacity; + } + + @Override + public String toString() { + return "RenderNebula{dir=" + dirX + "," + dirY + "," + dirZ + ",theta=" + angularRadius + + ",look=" + appearanceOrdinal + ",opacity=" + opacity + "}"; } } /** Client-side render store: slot dim id -> bodies to draw. Read by the sky renderer via {@link #bodiesForDim}. */ private static final Map> CLIENT_BODIES = new LinkedHashMap<>(); + /** Client-side render store: slot dim id -> nebulae to draw. Read via {@link #nebulaeForDim}. */ + private static final Map> CLIENT_NEBULAE = new LinkedHashMap<>(); + /** The decoded payload carried by this instance (server: what to send; client: what was received). */ private Map> byDim = new LinkedHashMap<>(); + /** The nebula half of the same payload, keyed the same way. */ + private Map> nebulaeByDim = new LinkedHashMap<>(); + public PacketSystemBodiesSync() { } /** Server factory: snapshot the per-slot-dim render bodies to broadcast to a client. */ public static PacketSystemBodiesSync forDims(Map> byDim) { + return forDims(byDim, null); + } + + /** + * Server factory carrying BOTH halves of a cell's sky. + * + *

    One channel and not two, because both are answers to the same question — what does the sky of + * this cell show — keyed by the same cell→slot binding, cleared by the same empty payload and + * broadcast on the same tick. A second channel would be a second lifecycle to keep in step, and the + * two skies could then disagree about which cell the viewer is in.

    + */ + public static PacketSystemBodiesSync forDims(Map> byDim, + Map> nebulaeByDim) { PacketSystemBodiesSync p = new PacketSystemBodiesSync(); if (byDim != null) { for (Map.Entry> e : byDim.entrySet()) { @@ -96,6 +203,14 @@ public static PacketSystemBodiesSync forDims(Map> byDi p.byDim.put(e.getKey(), bodies); } } + if (nebulaeByDim != null) { + for (Map.Entry> e : nebulaeByDim.entrySet()) { + List clouds = e.getValue() == null + ? new ArrayList() + : new ArrayList<>(e.getValue()); + p.nebulaeByDim.put(e.getKey(), clouds); + } + } return p; } @@ -109,6 +224,11 @@ public Map> payload() { return byDim; } + /** The nebula half of the decoded payload of THIS instance. */ + public Map> nebulaPayload() { + return nebulaeByDim; + } + @Override public void write(ByteBuf out) { PacketBuffer buffer = new PacketBuffer(out); @@ -125,6 +245,22 @@ public void write(ByteBuf out) { buffer.writeInt(b.dimId); buffer.writeBoolean(b.descendTarget); buffer.writeLong(b.boundaryRadius); + buffer.writeLong(b.radiusBlocks); + buffer.writeInt(b.parentIndex); + } + } + buffer.writeInt(nebulaeByDim.size()); + for (Map.Entry> e : nebulaeByDim.entrySet()) { + List clouds = e.getValue(); + buffer.writeInt(e.getKey()); + buffer.writeInt(clouds.size()); + for (RenderNebula n : clouds) { + buffer.writeFloat(n.dirX); + buffer.writeFloat(n.dirY); + buffer.writeFloat(n.dirZ); + buffer.writeFloat(n.angularRadius); + buffer.writeInt(n.appearanceOrdinal); + buffer.writeFloat(n.opacity); } } } @@ -146,12 +282,34 @@ public void readClient(ByteBuf in) { int dimId = buffer.readInt(); boolean descendTarget = buffer.readBoolean(); long boundaryRadius = buffer.readLong(); + long radiusBlocks = buffer.readLong(); + int parentIndex = buffer.readInt(); bodies.add(new RenderBody(kindOrdinal, localX, localY, localZ, dimId, descendTarget, - boundaryRadius)); + boundaryRadius, radiusBlocks, parentIndex)); } decoded.put(slotDimId, bodies); } byDim = decoded; + + Map> decodedClouds = new LinkedHashMap<>(); + int cloudDimCount = buffer.readInt(); + for (int i = 0; i < cloudDimCount; i++) { + int slotDimId = buffer.readInt(); + int cloudCount = buffer.readInt(); + List clouds = new ArrayList<>(); + for (int j = 0; j < cloudCount; j++) { + float dirX = buffer.readFloat(); + float dirY = buffer.readFloat(); + float dirZ = buffer.readFloat(); + float angularRadius = buffer.readFloat(); + int appearanceOrdinal = buffer.readInt(); + float opacity = buffer.readFloat(); + clouds.add(new RenderNebula(dirX, dirY, dirZ, angularRadius, appearanceOrdinal, + opacity)); + } + decodedClouds.put(slotDimId, clouds); + } + nebulaeByDim = decodedClouds; } @Override @@ -164,6 +322,8 @@ public void read(ByteBuf in) { public void executeClient(EntityPlayer player) { CLIENT_BODIES.clear(); CLIENT_BODIES.putAll(byDim); + CLIENT_NEBULAE.clear(); + CLIENT_NEBULAE.putAll(nebulaeByDim); } @Override @@ -176,4 +336,11 @@ public static List bodiesForDim(int slotDimId) { List bodies = CLIENT_BODIES.get(slotDimId); return bodies == null ? Collections.emptyList() : bodies; } + + /** Client render read: the nebulae to draw in {@code slotDimId}. Never null. */ + @SideOnly(Side.CLIENT) + public static List nebulaeForDim(int slotDimId) { + List clouds = CLIENT_NEBULAE.get(slotDimId); + return clouds == null ? Collections.emptyList() : clouds; + } } diff --git a/src/main/java/zmaster587/advancedRocketry/space/AbsolutePos.java b/src/main/java/zmaster587/advancedRocketry/space/AbsolutePos.java index cf19ebdf3..8ac7c73f2 100644 --- a/src/main/java/zmaster587/advancedRocketry/space/AbsolutePos.java +++ b/src/main/java/zmaster587/advancedRocketry/space/AbsolutePos.java @@ -1,7 +1,7 @@ package zmaster587.advancedRocketry.space; /** - * A position in absolute galactic blocks at one stated moment. + * A position in absolute galactic space at one stated moment. * *

    This is deliberately NOT a {@link GalacticCoord}. A {@code GalacticCoord} is a cell NAME plus an * offset inside that cell's frame, and a cell's frame moves: its origin is the position of the body @@ -9,80 +9,176 @@ * from "which cell it is in and where inside it", and the two must not share a type — not for * tidiness, but because {@link GalacticCoord#ofSectorLocal} carries an out-of-range offset into * the sector triple. Expressing a frame-displaced position as a {@code GalacticCoord} would therefore - * silently RENAME the cell the moment the frame origin drifts more than half a cell from - * {@code sector * CELL}, which is a routine amount of orbital travel.

    + * silently RENAME the cell the moment the frame origin drifts more than half a cell from the cell's + * own grid position, which is a routine amount of orbital travel.

    * *

    An absolute position is only ever an intermediate: it exists to be subtracted from another one at * the same tick, giving a {@link BlockDelta} — a direction and a true distance. Nothing is stored * as one and nothing is addressed by one: what goes on disk is always a cell name plus an in-cell * offset, never a value whose meaning depends on the tick it happened to be written at.

    * - *

    Immutable value type. As with {@link GalacticCoord#absoluteX()}, the {@code long} arithmetic can - * overflow at extreme sector magnitudes; that is the same bound the sectorized coordinate already - * carries and is far outside any generated galaxy.

    + *

    Why it is sectorised, and not three block counts

    + * + *

    It used to hold three raw block {@code long}s. A sector index reaches 9.2·1018, + * while {@code sector * CELL} overflows a {@code long} at 2.9·1011 — so the + * coordinate system could NAME positions this type could not express, over seven orders of magnitude, + * silently and with no error. Nothing caught it because nothing had yet been placed far enough out. + * Holding a sector triple and an in-cell offset removes the ceiling entirely: the whole addressable + * range is expressible, and a distance is computed from the two deltas rather than from a product + * that cannot fit.

    + * + *

    Immutable value type.

    */ public final class AbsolutePos { - /** Absolute (0,0,0) — the centre of the origin cell of a static frame. */ - public static final AbsolutePos ORIGIN = new AbsolutePos(0L, 0L, 0L); + /** Absolute origin: sector {@code (0,0,0)}, offset {@code (0,0,0)}. */ + public static final AbsolutePos ORIGIN = new AbsolutePos(0L, 0L, 0L, 0L, 0L, 0L); + + private final long sectorX; + private final long sectorY; + private final long sectorZ; + private final long localX; // canonical: [-HALF_CELL, HALF_CELL) + private final long localY; + private final long localZ; - private final long x; - private final long y; - private final long z; + private AbsolutePos(long sectorX, long sectorY, long sectorZ, + long localX, long localY, long localZ) { + this.sectorX = sectorX; + this.sectorY = sectorY; + this.sectorZ = sectorZ; + this.localX = localX; + this.localY = localY; + this.localZ = localZ; + } - private AbsolutePos(long x, long y, long z) { - this.x = x; - this.y = y; - this.z = z; + /** Build from a sector triple and a (possibly out-of-range) offset triple, carrying the overflow. */ + public static AbsolutePos ofSectorLocal(long sectorX, long sectorY, long sectorZ, + long localX, long localY, long localZ) { + long carryX = Math.floorDiv(localX + GalacticCoord.HALF_CELL, GalacticCoord.CELL); + long carryY = Math.floorDiv(localY + GalacticCoord.HALF_CELL, GalacticCoord.CELL); + long carryZ = Math.floorDiv(localZ + GalacticCoord.HALF_CELL, GalacticCoord.CELL); + return new AbsolutePos( + sectorX + carryX, sectorY + carryY, sectorZ + carryZ, + localX - carryX * GalacticCoord.CELL, + localY - carryY * GalacticCoord.CELL, + localZ - carryZ * GalacticCoord.CELL); } + /** + * Build from a raw block triple, i.e. an offset from the origin cell. Exact for anything inside + * the range a {@code long} of blocks can hold; beyond that, state the sectors. + */ public static AbsolutePos of(long x, long y, long z) { - return new AbsolutePos(x, y, z); + return ofSectorLocal(0L, 0L, 0L, x, y, z); } /** - * The absolute position a cell NAME denotes under a STATIC frame: {@code sector * CELL}. This is - * the frame origin of a void cell — one with no primary to ride, so it never moves — - * and the fallback for any cell whose primary cannot be resolved. + * The absolute position a cell NAME denotes under a STATIC frame. This is the frame origin of a + * void cell — one with no primary to ride, so it never moves — and the fallback for + * any cell whose primary cannot be resolved. */ public static AbsolutePos ofCellName(GalacticCoord name) { if (name == null) { return ORIGIN; } - return new AbsolutePos(name.sectorX() * GalacticCoord.CELL, - name.sectorY() * GalacticCoord.CELL, - name.sectorZ() * GalacticCoord.CELL); + // The cell's own grid position, and ONLY that: a name denotes a CELL. Where something stands + // inside that cell is carried separately, by the frame's law, and adding it here would count + // the offset twice for every body in the game. + return new AbsolutePos(name.sectorX(), name.sectorY(), name.sectorZ(), 0L, 0L, 0L); } - public long x() { return x; } - public long y() { return y; } - public long z() { return z; } + public long sectorX() { return sectorX; } + public long sectorY() { return sectorY; } + public long sectorZ() { return sectorZ; } + + public long localX() { return localX; } + public long localY() { return localY; } + public long localZ() { return localZ; } /** This position displaced by {@code delta}. */ public AbsolutePos plus(BlockDelta delta) { - return delta == null ? this : new AbsolutePos(x + delta.dx(), y + delta.dy(), z + delta.dz()); + return delta == null ? this : plus(delta.dx(), delta.dy(), delta.dz()); } /** This position displaced by a raw block triple. */ public AbsolutePos plus(long dx, long dy, long dz) { - return new AbsolutePos(x + dx, y + dy, z + dz); + return ofSectorLocal(sectorX, sectorY, sectorZ, localX + dx, localY + dy, localZ + dz); } - /** The vector FROM {@code from} TO this position — the observer→body direction when - * {@code from} is the observer. */ + /** + * The vector FROM {@code from} TO this position — the observer→body direction when + * {@code from} is the observer. + * + *

    Saturates instead of wrapping, and the delta says that it did. A separation past a + * {@code long} of blocks is one between things in different galaxies — roughly 244 000 light + * years out, which the galaxy lattice reaches routinely — and there a block vector is a + * direction rather than a distance. Two things must never happen: that it comes back as a small + * number pointing the wrong way (which is what wrapping would do), and that a clamped vector is + * indistinguishable from a real one (which is what silent saturation did). For a distance at any + * magnitude use {@link #distanceTo}, which is computed from the sector delta and never clamps.

    + */ public BlockDelta minus(AbsolutePos from) { - return from == null ? BlockDelta.of(x, y, z) - : BlockDelta.of(x - from.x, y - from.y, z - from.z); + AbsolutePos origin = (from == null) ? ORIGIN : from; + long dSectorX = sectorX - origin.sectorX; + long dSectorY = sectorY - origin.sectorY; + long dSectorZ = sectorZ - origin.sectorZ; + long dLocalX = localX - origin.localX; + long dLocalY = localY - origin.localY; + long dLocalZ = localZ - origin.localZ; + + boolean clamped = boundHit(dSectorX, dLocalX) != 0 + || boundHit(dSectorY, dLocalY) != 0 + || boundHit(dSectorZ, dLocalZ) != 0; + long dx = saturatingBlocks(dSectorX, dLocalX); + long dy = saturatingBlocks(dSectorY, dLocalY); + long dz = saturatingBlocks(dSectorZ, dLocalZ); + return clamped ? BlockDelta.saturated(dx, dy, dz) : BlockDelta.of(dx, dy, dz); + } + + /** {@code sectors * CELL + local}, held at the {@code long} bounds rather than wrapping past them. */ + private static long saturatingBlocks(long sectors, long local) { + int hit = boundHit(sectors, local); + if (hit != 0) { + return hit > 0 ? Long.MAX_VALUE : Long.MIN_VALUE; + } + return sectors * GalacticCoord.CELL + local; } - /** Squared distance to {@code other}, in blocks². Both must be evaluated at the SAME tick. */ + /** + * Which {@code long} bound {@code sectors * CELL + local} runs into: {@code +1} past the top, + * {@code -1} past the bottom, {@code 0} when it fits. + * + *

    The ONE place the overflow is decided. The clamped value and the flag that reports it are + * both read off this, so a delta cannot come back held at a bound while claiming to be exact.

    + */ + private static int boundHit(long sectors, long local) { + if (sectors > Long.MAX_VALUE / GalacticCoord.CELL) { + return 1; + } + if (sectors < Long.MIN_VALUE / GalacticCoord.CELL) { + return -1; + } + long scaled = sectors * GalacticCoord.CELL; + long sum = scaled + local; + if (((scaled ^ sum) & (local ^ sum)) < 0L) { + return local > 0L ? 1 : -1; + } + return 0; + } + + /** + * Squared distance to {@code other}, in blocks². Both must be evaluated at the SAME tick. + * + *

    Computed from the sector delta plus the offset delta, so nearby positions stay exact at any + * magnitude and distant ones do not overflow on the way to being measured.

    + */ public double distanceSqTo(AbsolutePos other) { if (other == null) { return 0.0; } - double dx = (double) other.x - x; - double dy = (double) other.y - y; - double dz = (double) other.z - z; + double dx = (double) (other.sectorX - sectorX) * GalacticCoord.CELL + (other.localX - localX); + double dy = (double) (other.sectorY - sectorY) * GalacticCoord.CELL + (other.localY - localY); + double dz = (double) (other.sectorZ - sectorZ) * GalacticCoord.CELL + (other.localZ - localZ); return dx * dx + dy * dy + dz * dz; } @@ -100,19 +196,24 @@ public boolean equals(Object o) { return false; } AbsolutePos other = (AbsolutePos) o; - return x == other.x && y == other.y && z == other.z; + return sectorX == other.sectorX && sectorY == other.sectorY && sectorZ == other.sectorZ + && localX == other.localX && localY == other.localY && localZ == other.localZ; } @Override public int hashCode() { - int result = Long.hashCode(x); - result = 31 * result + Long.hashCode(y); - result = 31 * result + Long.hashCode(z); + int result = Long.hashCode(sectorX); + result = 31 * result + Long.hashCode(sectorY); + result = 31 * result + Long.hashCode(sectorZ); + result = 31 * result + Long.hashCode(localX); + result = 31 * result + Long.hashCode(localY); + result = 31 * result + Long.hashCode(localZ); return result; } @Override public String toString() { - return "AbsolutePos[" + x + "," + y + "," + z + "]"; + return "AbsolutePos[sector=(" + sectorX + "," + sectorY + "," + sectorZ + "), offset=(" + + localX + "," + localY + "," + localZ + ")]"; } } diff --git a/src/main/java/zmaster587/advancedRocketry/space/BlockDelta.java b/src/main/java/zmaster587/advancedRocketry/space/BlockDelta.java index a90bf8e7c..a6cdbf553 100644 --- a/src/main/java/zmaster587/advancedRocketry/space/BlockDelta.java +++ b/src/main/java/zmaster587/advancedRocketry/space/BlockDelta.java @@ -8,35 +8,82 @@ * of two positions whose frames were moving. The render channel carries one per body (the * observer→body vector), and its length is the true distance at that moment.

    * + *

    A separation can be larger than this type can hold, and it SAYS SO

    + * + *

    Three block {@code long}s reach about 244 000 light years, which is a quarter of the way to the + * nearest galaxy: the universe NAMES separations this type cannot express, and it always did — + * a cell name is a sector triple, so the addressable range is orders wider than a block count. What + * changed is that such a separation is now reachable in play rather than hypothetical.

    + * + *

    So a delta carries {@link #isSaturated()}. A saturated delta holds each over-range component at + * the {@code long} bound — the direction survives, which is what a renderer and a nav computer + * actually read — and its {@link #length()} is a LOWER BOUND on the true distance. What must + * never happen, and is what the flag exists to prevent, is a consumer measuring a clamped vector and + * reporting the number as a distance: for that, ask the two {@link AbsolutePos} for + * {@link AbsolutePos#distanceTo}, which is computed from the sector delta and does not clamp.

    + * *

    Immutable value type.

    */ public final class BlockDelta { - public static final BlockDelta ZERO = new BlockDelta(0L, 0L, 0L); + public static final BlockDelta ZERO = new BlockDelta(0L, 0L, 0L, false); private final long dx; private final long dy; private final long dz; + private final boolean saturated; - private BlockDelta(long dx, long dy, long dz) { + private BlockDelta(long dx, long dy, long dz, boolean saturated) { this.dx = dx; this.dy = dy; this.dz = dz; + this.saturated = saturated; } + /** An EXACT displacement: these three numbers are the whole separation. */ public static BlockDelta of(long dx, long dy, long dz) { - return (dx == 0L && dy == 0L && dz == 0L) ? ZERO : new BlockDelta(dx, dy, dz); + return (dx == 0L && dy == 0L && dz == 0L) ? ZERO : new BlockDelta(dx, dy, dz, false); + } + + /** + * A displacement that ran into the {@code long} bound on at least one axis: the components are + * held at the bound and the value reports itself {@link #isSaturated()}. + * + *

    Named rather than a flag on {@link #of}, because which of the two a caller is producing is + * something it KNOWS — and a boolean at the call site would let it be got wrong silently, + * which is the whole defect this pair exists to close.

    + */ + public static BlockDelta saturated(long dx, long dy, long dz) { + return new BlockDelta(dx, dy, dz, true); } public long dx() { return dx; } public long dy() { return dy; } public long dz() { return dz; } + /** + * {@code true} when at least one component was held at the {@code long} bound, so the components + * are a direction and {@link #length()} is a lower bound rather than a distance. + */ + public boolean isSaturated() { + return saturated; + } + + /** + * The two displacements added. Saturation is CARRIED: a sum involving a clamped vector is itself + * only a lower bound, and losing the flag here would launder one back into an exact answer. + */ public BlockDelta plus(BlockDelta other) { - return other == null ? this : of(dx + other.dx, dy + other.dy, dz + other.dz); + if (other == null) { + return this; + } + long sx = dx + other.dx; + long sy = dy + other.dy; + long sz = dz + other.dz; + return (saturated || other.saturated) ? saturated(sx, sy, sz) : of(sx, sy, sz); } - /** Length in blocks. */ + /** Length in blocks — a LOWER BOUND when {@link #isSaturated()}. */ public double length() { double x = dx; double y = dy; @@ -58,7 +105,8 @@ public boolean equals(Object o) { return false; } BlockDelta other = (BlockDelta) o; - return dx == other.dx && dy == other.dy && dz == other.dz; + return dx == other.dx && dy == other.dy && dz == other.dz + && saturated == other.saturated; } @Override @@ -66,11 +114,12 @@ public int hashCode() { int result = Long.hashCode(dx); result = 31 * result + Long.hashCode(dy); result = 31 * result + Long.hashCode(dz); + result = 31 * result + (saturated ? 1 : 0); return result; } @Override public String toString() { - return "BlockDelta[" + dx + "," + dy + "," + dz + "]"; + return "BlockDelta[" + dx + "," + dy + "," + dz + (saturated ? ",saturated]" : "]"); } } diff --git a/src/main/java/zmaster587/advancedRocketry/space/CellCrossingController.java b/src/main/java/zmaster587/advancedRocketry/space/CellCrossingController.java new file mode 100644 index 000000000..35b912fba --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/space/CellCrossingController.java @@ -0,0 +1,266 @@ +package zmaster587.advancedRocketry.space; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.LongSupplier; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import net.minecraft.util.math.BlockPos; + +/** + * Moves a settled ship from one cell into another in a SINGLE crossing. + * + *

    Two things ask for that, and they differ only in how the destination is chosen:

    + * + *
      + *
    • A seam carry — the ship flew out through its cell's face, and the neighbour it left + * through is where it belongs ({@link #requestCarry}). Before this existed, a ship past its cell + * face was neither stopped nor carried: its pose kept going while the ledger report SATURATED at + * the boundary, so the ship was in one place and named in another. Everything keyed on the name + * then answered about the wrong cell — it could not descend (the named cell holds no bodies), its + * jumps were refused, and the cell it was really in lost the ledger's garbage-collection + * protection.
    • + *
    • A short jump — the drive was fired at a destination the ship reaches in less time than + * the flight would take to present itself ({@link #requestDirectJump}). Routing that through + * hyperspace is pure overhead: two crossings and a park for a flight that is over before it + * starts. {@link ShipTransitManager} owns the decision; this owns the move.
    • + *
    + * + *

    The arithmetic of the seam — when a pose counts as having left, and where in the neighbour the + * ship belongs — is {@link CellSeam}'s, and has no Minecraft in it. What lives here is the world half: + * acquiring the destination cell, capturing the crew, driving the shared {@link ShipCrossingService}, + * and the refcount handoff.

    + * + *

    The handoff order, and why it is not the other one

    + * + *

    The destination is materialized before the source is released. The reverse order leaves a + * window in which the ship holds no cell at all, and a garbage collection landing in that window + * collects the very cell the ship is being pasted into. The cost of this order is that a refused + * crossing must hand the destination back, which is what the failure paths below do.

    + * + *

    A refusal is a normal outcome, not an error: the pool can be full. A refused seam carry keeps + * flying with its report saturated at the boundary — the old behaviour, now the fallback rather than + * the rule — and is retried after a cooldown. A refused jump is reported to its caller, which has + * already charged the pilot for the attempt.

    + */ +public final class CellCrossingController { + + private static final Logger LOGGER = LogManager.getLogger("advancedrocketry/space"); + + /** Ticks before a refused seam carry may be attempted again. */ + private static final int RETRY_COOLDOWN_TICKS = 100; + + /** The arrival paste band in the destination slot world — the entry crossing's geometry, because + * it is the same kind of destination: an empty slot world with nothing at its origin. */ + private static final int SEAM_PASTE_Z = -1024; + private static final int SEAM_PASTE_Y = 200; + private static final int SEAM_LANE_STRIDE = 64; + private static final int SEAM_LANE_COUNT = 8; + + /** + * What the crew is told, and what the log calls the move. The two callers differ in nothing else, + * and a crossing that reported "carried into the next neighbourhood" for a jump across a system + * would be lying to the only person who can see it. + */ + private enum Kind { + SEAM("cell-seam carry", "msg.shipseam.arrived", "msg.shipseam.failed"), + JUMP("direct jump", "msg.shiptransit.arrived", "msg.shiptransit.directfailed"); + + final String label; + final String arrivedKey; + final String failedKey; + + Kind(String label, String arrivedKey, String failedKey) { + this.label = label; + this.arrivedKey = arrivedKey; + this.failedKey = failedKey; + } + } + + private final SpaceManager space; + private final ShipLedger ledger; + private final ShipCrossingService crossing; + private final LongSupplier clock; + private final Map retryAfter = new HashMap<>(); + private int laneCounter; + + public CellCrossingController(SpaceManager space, ShipLedger ledger, ShipCrossingService.Ops ops, + LongSupplier clock) { + this.space = space; + this.ledger = ledger; + this.crossing = new ShipCrossingService(ops); + this.clock = clock; + } + + /** + * Carry the SETTLED ship at {@code afcPos} out of {@code cell} and into the neighbour its pose has + * left through. Returns {@code true} when the crossing started, in which case the ship has been + * cut out of this world and the caller must stop touching it this tick. + * + *

    {@code shipPos} is passed in rather than re-read: the decision and the arrival must be + * computed from the SAME pose. Re-reading it here would let a fast ship be judged on one position + * and placed by another, and at these speeds the two can be thousands of blocks apart.

    + */ + public boolean requestCarry(int slotDim, BlockPos afcPos, UUID shipId, GalacticCoord cell, + double[] shipPos) { + if (shipId == null || cell == null || shipPos == null || crossing.isCrossing(shipId)) { + return false; + } + if (!isSettled(shipId)) { + // Only a ship genuinely settled in a cell can leave one by flying. A ship mid-arrival sits + // in the paste band, which is far outside its cell's pose range and would otherwise read as + // an escape on every single crossing. + return false; + } + if (!CellSeam.shouldCarry(shipPos[0], shipPos[1], shipPos[2])) { + return false; + } + long now = clock.getAsLong(); + Long cooldown = retryAfter.get(shipId); + if (cooldown != null && now < cooldown) { + return false; + } + GalacticCoord destCoord = CellSeam.carriedCoord(cell, shipPos[0], shipPos[1], shipPos[2]); + return cross(slotDim, afcPos, shipId, ledger.get(shipId).coord, destCoord, shipPos, Kind.SEAM); + } + + /** + * Cross the SETTLED ship at {@code afcPos} from {@code cell} straight into {@code target}, with no + * hyperspace leg. Returns {@code true} when the crossing started. + * + *

    Unlike a seam carry this has no cooldown and no refusal fallback: the caller has already + * charged the drive for the attempt, so a {@code false} here is a failed jump that must be + * reported, not a condition to be retried quietly next tick.

    + * + *

    It also READS the ship's pose rather than being handed one, which the seam may not do. The + * seam's decision is about the pose — judged on one position and placed by another, a fast + * ship lands thousands of blocks from where it was measured — while a jump's destination comes from + * the pilot's target and does not depend on where in the cell the ship happens to be.

    + */ + public boolean requestDirectJump(int slotDim, BlockPos afcPos, UUID shipId, GalacticCoord cell, + GalacticCoord target) { + if (shipId == null || cell == null || target == null || crossing.isCrossing(shipId)) { + return false; + } + if (!isSettled(shipId)) { + return false; + } + double[] shipPos = crossing.ops().shipWorldPosition(slotDim, afcPos); + if (shipPos == null) { + LOGGER.warn("[SPACE] direct jump refused for ship {}: no ship resolves at {} in slot {}", + shipId, afcPos, slotDim); + return false; + } + return cross(slotDim, afcPos, shipId, cell, target, shipPos, Kind.JUMP); + } + + /** Whether the ledger has this ship SETTLED somewhere — the precondition both entries share. */ + private boolean isSettled(UUID shipId) { + ShipLedger.Entry entry = ledger.get(shipId); + return entry != null && entry.state == ShipLedger.State.SETTLED; + } + + /** The move itself: acquire the destination, capture, cut, release the source, name the result. */ + private boolean cross(int slotDim, BlockPos afcPos, UUID shipId, GalacticCoord sourceCell, + GalacticCoord destCoord, double[] shipPos, Kind kind) { + long now = clock.getAsLong(); + final int destSlotDim; + try { + destSlotDim = space.materialize(destCoord); + } catch (SpaceManager.PoolExhaustedException full) { + // No slot for the destination. The ship stays where it is. The crew is only READ here, so + // nobody is dismounted by a refusal. + List told = crossing.ops().peekCrew(slotDim, afcPos, shipPos); + LOGGER.warn("[SPACE] {} refused for ship {} leaving {}: {} (told {} aboard)", + kind.label, shipId, sourceCell.cellKey(), full.getMessage(), + told == null ? 0 : told.size()); + crossing.ops().messageCrew(told, "msg.shipseam.refused"); + if (kind == Kind.SEAM) { + retryAfter.put(shipId, now + RETRY_COOLDOWN_TICKS); + } + return false; + } + + // Capture only now, with the destination GRANTED — the last refusal is behind — and still + // before the cut: the crossing cuts the seat blocks, and a post-cut capture finds nothing. + final List crew = crossing.ops().captureCrew(slotDim, afcPos, shipPos); + + int lane = (laneCounter++ % SEAM_LANE_COUNT); + double[] pose = CellWorldMapper.poseWorldOf(destCoord); + final GalacticCoord arrivalCoord = destCoord; + final Kind arrivalKind = kind; + BlockPos anchor = crossing.begin(shipId, slotDim, shipPos, destSlotDim, + lane * SEAM_LANE_STRIDE, SEAM_PASTE_Y, SEAM_PASTE_Z, crew, pose, + new ShipCrossingService.Completion() { + @Override + public void settled(UUID id) { + ledger.settle(id, arrivalCoord); + crossing.ops().messageCrew(crew, arrivalKind.arrivedKey); + LOGGER.info("[SPACE] {} settled: ship {} now in cell {} (slot {})", + arrivalKind.label, id, arrivalCoord.cellKey(), destSlotDim); + } + + @Override + public void abandoned(UUID id) { + // The arrival never finished. The ship is somewhere in the destination slot + // world — which place depends on the half that stalled, and the crossing's own + // give-up line names it; do not claim one here. Settle it in the destination + // anyway: that IS the cell it is in, and leaving the row IN_TRANSIT would strand + // a real ship in a state nothing else advances. + ledger.settle(id, arrivalCoord); + crossing.ops().messageCrew(crew, arrivalKind.failedKey); + LOGGER.error("[SPACE] {} settle never completed for ship {} arriving in " + + "cell {} (slot {}) - see the crossing give-up line above for which " + + "half stalled", arrivalKind.label, id, arrivalCoord.cellKey(), + destSlotDim); + } + }); + if (anchor == null) { + LOGGER.error("[SPACE] {} crossing failed for ship {} leaving cell {}", + kind.label, shipId, sourceCell.cellKey()); + // The cut never produced a paste, so the ship is (best-effort) still intact where it was: + // hand the destination back, re-seat the crew we already captured, and let it keep flying. + space.dematerialize(destCoord); + crossing.ops().reseat(slotDim, + new BlockPos(shipPos[0], shipPos[1], shipPos[2]), crew, shipId, null); + crossing.ops().messageCrew(crew, kind.failedKey); + if (kind == Kind.SEAM) { + retryAfter.put(shipId, now + RETRY_COOLDOWN_TICKS); + } + return false; + } + + // The ship is physically out of the source cell now, so the source is released NOW and not on + // settle — the settle only completes the arrival on the far side. The destination refcount was + // taken above, so the ship is never between cells. + space.markDirty(sourceCell); + space.dematerialize(sourceCell); + space.markDirty(destCoord); + // SETTLED at the destination, from the cut — deliberately NOT `beginTransit`. IN_TRANSIT is + // not a generic "crossing" state: `LoginRestore` reads it as "parked in the shared hyperspace + // world" and resolves the player through the transit dim, so a crossing ship wearing it + // would orphan anyone who logged in during the few ticks of re-assembly. The row names the + // cell the ship's blocks are actually in, which is also the cell whose refcount is held. + // + // For a jump this is also the whole saving: a crossing that never enters IN_TRANSIT has no + // mid-flight for a restart to resume, so it needs no snapshot and cannot strand a ship. + ledger.settle(shipId, destCoord); + LOGGER.info("[SPACE] {} started: ship {} {} -> {} (slot {})", + kind.label, shipId, sourceCell.cellKey(), destCoord.cellKey(), destSlotDim); + return true; + } + + /** Advance every in-flight crossing one tick (the shared crossing settle loop). */ + public void tick() { + crossing.tick(); + } + + /** Whether {@code shipId} is being moved between cells right now — by either entry point. */ + public boolean isCarrying(UUID shipId) { + return crossing.isCrossing(shipId); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/space/CellSeam.java b/src/main/java/zmaster587/advancedRocketry/space/CellSeam.java new file mode 100644 index 000000000..6252b5d59 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/space/CellSeam.java @@ -0,0 +1,116 @@ +package zmaster587.advancedRocketry.space; + +/** + * The cell face, read as something a ship can FLY THROUGH. + * + *

    A cell's local range is finite, so a ship under sustained thrust reaches its face. Two answers + * were possible: stop it, or carry it. This class holds the second one's arithmetic — when a pose + * has left its cell far enough to count, and where the ship belongs in the neighbour it entered. + * Nothing here touches Minecraft, a world or a ledger; that is {@link CellCrossingController}'s work.

    + * + *

    Why a margin exists at all

    + * + *

    A face is a mathematical plane, and a ship loitering ON one would otherwise re-decide its cell + * every tick, ping-ponging between two worlds and paying a full cut-and-paste each time. So the + * crossing arms only once the pose is {@link #CARRY_MARGIN} PAST the face, and the ship is placed + * {@link #REENTRY_DEPTH} inside the neighbour rather than on its face — with + * {@code REENTRY_DEPTH > CARRY_MARGIN}, so coming back costs + * {@code REENTRY_DEPTH + CARRY_MARGIN} of deliberate travel and cannot happen by drift.

    + * + *

    Both are FRACTIONS of the cell, never absolutes

    + * + *

    An absolute margin silently encodes an assumed speed and an assumed cell size: change either and + * a number chosen for "about two seconds" quietly becomes two minutes or two ticks. Derived from + * {@link GalacticCoord#HALF_CELL} they move with the cell, and the property they were chosen for — + * a duration — survives.

    + */ +public final class CellSeam { + + /** + * How far past its cell's face a pose must be before the ship is carried: {@code HALF_CELL/10 000} + * = 1 600 blocks at today's cell. Ratified 2026-08-17 in flight time, which is the unit that + * matters: about 2 s at a 40 b/t cruise, and still 4 ticks for a craft doing 395 b/t (first cosmic + * velocity, which the acceleration law makes reachable). Small enough that the ship is never long + * in a place its cell does not name, large enough that no single tick of any plausible speed + * straddles the decision. + */ + public static final long CARRY_MARGIN = GalacticCoord.HALF_CELL / 10_000L; + + /** + * How far inside the neighbour's opposite face the carried ship is placed: {@code HALF_CELL/1 000} + * = 16 000 blocks, ten times {@link #CARRY_MARGIN}. Ratified 2026-08-17: coming straight back is + * about 20 s of deliberate flight at a 40 b/t cruise, so a pilot who crosses knows he crossed. + */ + public static final long REENTRY_DEPTH = GalacticCoord.HALF_CELL / 1_000L; + + private CellSeam() { } + + /** + * The local offset a world-frame pose component maps to, per {@link CellWorldMapper}'s honest-3D + * mapping. Y carries the pose band; X and Z do not. + */ + public static long localOf(double world, boolean isY) { + long rounded = Math.round(world); + return isY ? rounded - GalacticCoord.HALF_CELL - CellWorldMapper.POSE_BAND_Y : rounded; + } + + /** + * Whether a pose has left its cell far enough to be CARRIED rather than merely reported at the + * boundary. Strictly more than the margin past the face, on any one axis. + * + *

    Deliberately not the same question as {@link CellWorldMapper#poseEscapesCell}: that one asks + * whether the REPORT had to saturate, and it is true the moment a pose steps a single block out — + * including the arrival paste band, which sits far below the cell's own pose range for the few + * ticks between the paste and the settle. A carry keyed on that question would fire on every + * arrival.

    + */ + public static boolean shouldCarry(double wx, double wy, double wz) { + return beyondMargin(localOf(wx, false)) + || beyondMargin(localOf(wy, true)) + || beyondMargin(localOf(wz, false)); + } + + private static boolean beyondMargin(long local) { + return local > GalacticCoord.HALF_CELL + CARRY_MARGIN + || local < -GalacticCoord.HALF_CELL - CARRY_MARGIN; + } + + /** + * Where the ship belongs after being carried out of {@code cell} by {@code pose}: the neighbouring + * cell it left through, with the ship set {@link #REENTRY_DEPTH} inside the face it came in by. + * + *

    Only the axes that actually crossed move to the entry face. An axis that did not cross keeps + * the position the pilot flew it to (clamped into the local range, since a pose may sit a little + * outside without having crossed) — a ship leaving through the +X face has not consented to being + * re-centred in Y and Z.

    + */ + public static GalacticCoord carriedCoord(GalacticCoord cell, double wx, double wy, double wz) { + long lx = localOf(wx, false); + long ly = localOf(wy, true); + long lz = localOf(wz, false); + return GalacticCoord.ofSectorLocal( + cell.sectorX() + step(lx), cell.sectorY() + step(ly), cell.sectorZ() + step(lz), + placed(lx), placed(ly), placed(lz)); + } + + /** Which neighbour an axis left through: -1, 0 or +1 cell. */ + private static long step(long local) { + if (local > GalacticCoord.HALF_CELL + CARRY_MARGIN) { + return 1L; + } + return local < -GalacticCoord.HALF_CELL - CARRY_MARGIN ? -1L : 0L; + } + + /** The local offset inside the destination cell for one axis. */ + private static long placed(long local) { + long crossed = step(local); + if (crossed > 0L) { + // Left through the +face: arrive just inside the neighbour's -face. + return -GalacticCoord.HALF_CELL + REENTRY_DEPTH; + } + if (crossed < 0L) { + return GalacticCoord.HALF_CELL - REENTRY_DEPTH; + } + return Math.max(-GalacticCoord.HALF_CELL, Math.min(GalacticCoord.HALF_CELL - 1L, local)); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/space/DescentShell.java b/src/main/java/zmaster587/advancedRocketry/space/DescentShell.java index be6f60e98..d0f79e57f 100644 --- a/src/main/java/zmaster587/advancedRocketry/space/DescentShell.java +++ b/src/main/java/zmaster587/advancedRocketry/space/DescentShell.java @@ -1,5 +1,6 @@ package zmaster587.advancedRocketry.space; +import zmaster587.advancedRocketry.util.AstronomicalBodyHelper; import zmaster587.advancedRocketry.universe.SystemBody; /** @@ -33,10 +34,44 @@ private DescentShell() { * adds the atmosphere's own depth; nothing else has to change, which is the whole reason this * method exists rather than the constant being read at each call site.

    */ + /** + * How high above {@code body}'s centre its atmosphere ends — the surface a descent triggers at. + * + *

    It is the body's own radius plus an atmosphere, and that is a change of kind. This + * used to ignore its argument and return a flat {@code DESCENT_RADIUS_BLOCKS} = 512, chosen when a + * body had no size at all. Once bodies got a real radius that constant became 1/50 of an Earth + * (25 513 blocks) and 1/548 of a Jupiter (280 643): the boundary a descent fires at lay deep INSIDE + * the world it belongs to, so a pilot flew through the whole bulk before anything happened and + * {@link #distanceToShell} — the number an approach read-out is built on — described a sphere + * nowhere near where the world ends.

    + * + *

    The atmosphere fraction is measured, not chosen: the Kármán line stands at 100 km over + * an Earth radius of 6 371 km, i.e. 1.57 % above the surface, and that ratio is what + * {@link #ATMOSPHERE_FRACTION} states. A world twice the size gets a shell twice as far out, + * which is the property the flat constant could not have.

    + * + *

    A body with no radius keeps the flat radius, and that is not a fallback but the right + * answer: a belt or a station slot is not a sphere, has no surface to stand above, and the constant + * is then a proximity radius rather than an atmosphere.

    + */ public static long radiusAround(SystemBody body) { - return ShipEntryController.DESCENT_RADIUS_BLOCKS; + double radiusEarths = (body == null) ? 0d : body.radiusEarths(); + if (!(radiusEarths > 0d)) { + return ShipEntryController.DESCENT_RADIUS_BLOCKS; + } + double surfaceBlocks = radiusEarths * AstronomicalBodyHelper.EARTH_RADIUS_BLOCKS; + long shell = Math.round(surfaceBlocks * (1d + ATMOSPHERE_FRACTION)); + // Never below the flat radius: a body small enough that its atmosphere is thinner than the old + // proximity sphere still has to be approachable at the scale a ship manoeuvres in. + return Math.max(ShipEntryController.DESCENT_RADIUS_BLOCKS, shell); } + /** + * How far a world's atmosphere reaches above its surface, as a fraction of its radius — the Kármán + * line, 100 km over Earth's 6 371 km. + */ + public static final double ATMOSPHERE_FRACTION = 100d / 6371d; + /** * How far a ship at {@code distanceToCentre} blocks still has to travel before it crosses * {@code body}'s atmosphere — clamped at zero, because inside the shell there is nothing left diff --git a/src/main/java/zmaster587/advancedRocketry/space/GalacticCoord.java b/src/main/java/zmaster587/advancedRocketry/space/GalacticCoord.java index a75f0c379..7636293a3 100644 --- a/src/main/java/zmaster587/advancedRocketry/space/GalacticCoord.java +++ b/src/main/java/zmaster587/advancedRocketry/space/GalacticCoord.java @@ -14,9 +14,8 @@ * *

    The sector grid is the bubble grid: a cell is one {@link #CELL}-block cube, so two * positions with equal sector triples share a cell (and, once loaded, the same world). The local - * offset is kept canonical in {@code [-HALF_CELL, HALF_CELL)}, i.e. within ±2M blocks of the - * cell centre, so every local coordinate stays inside the range where 1.12.2 entity doubles, chunks - * and lighting are crisp. Cell-centre content is at local {@code (0,0,0)}.

    + * offset is kept canonical in {@code [-HALF_CELL, HALF_CELL)}, i.e. within ±16M blocks of the + * cell centre. Cell-centre content is at local {@code (0,0,0)}.

    * *

    The sector triple is a cell NAME, not a place. A cell rides the body it belongs * to, so {@code absolute = sector * CELL + local} is the STATIC-frame reading — true for a void cell @@ -30,8 +29,28 @@ */ public final class GalacticCoord { - /** Edge length of one cell / sector, in blocks. The sector grid is the bubble grid. */ - public static final long CELL = 4_000_000L; + /** + * Edge length of one cell / sector, in blocks. The sector grid is the bubble grid. + * + *

    Why 32M and not the 4M this started at. The old size rested on one sentence — that + * entity doubles, chunks and lighting degrade past ~±2M in 1.12.2 — and all three named + * mechanisms were measured CLEAN out to 24M, on a real player and a real flying ship: walking + * distance, collision stand-off, standing, client/server agreement, camera-step granularity and a + * sub-block position round trip, each against an origin control in the same run. The wall that + * actually existed was a mod constant (the physics mod's reserved shipyard quadrant), and it is + * moved out of the way by {@code ShipChunkAllocator.CHUNK_X_START}, which this size is paired + * with: the two must move together or a pose past the old quadrant is silently cancelled.

    + * + *

    16M of half-cell against 24M measured clean is 1.5× margin. What is NOT covered: + * vanilla documents sound-positioning degradation at 2²⁴ = 16 777 216, which the far + * shell of this cell crosses — accepted knowingly, and cosmetic.

    + * + *

    The size is what lets a system fit inside its own cell at the chart metric the mod already + * ships ({@code AstronomicalBodyHelper.METRES_PER_CHART_BLOCK}): at 250 m/block Jupiter's outer + * moons sit ~7.5M blocks out, which is under half of this half-cell and nearly four times the + * old one.

    + */ + public static final long CELL = 32_000_000L; /** Half a cell; the canonical local offset lives in {@code [-HALF_CELL, HALF_CELL)}. */ public static final long HALF_CELL = CELL / 2L; @@ -88,10 +107,11 @@ public static GalacticCoord ofAbsolute(long absX, long absY, long absZ) { public int localY() { return localY; } public int localZ() { return localZ; } - /** Absolute X in blocks. May overflow {@code long} at extreme sector magnitudes (see class doc). */ - public long absoluteX() { return sectorX * CELL + localX; } - public long absoluteY() { return sectorY * CELL + localY; } - public long absoluteZ() { return sectorZ * CELL + localZ; } + // absoluteX/Y/Z — sector * CELL + local — are gone. A sector index reaches 9.2e18 while the + // product overflows at 2.9e11, so they could NAME a position they could not express, silently, + // over seven orders of magnitude. Nothing materialises a single global block absolute any more: + // a distance comes from the sector delta plus the offset delta (staticFrameDistanceTo below, or + // AbsolutePos for a position at a tick), which is exact nearby and cannot overflow far away. /** {@code true} iff {@code other} is in the same cell (equal sector triple) as this coordinate. */ public boolean sameCell(GalacticCoord other) { diff --git a/src/main/java/zmaster587/advancedRocketry/space/ShipEntryController.java b/src/main/java/zmaster587/advancedRocketry/space/ShipEntryController.java index f587fb39a..0145b25fb 100644 --- a/src/main/java/zmaster587/advancedRocketry/space/ShipEntryController.java +++ b/src/main/java/zmaster587/advancedRocketry/space/ShipEntryController.java @@ -45,12 +45,44 @@ public final class ShipEntryController { public static final long DESCENT_RADIUS_BLOCKS = 512L; /** - * Entry spawn-ring distance from the launch body's POI (blocks, cell-local). MUST stay - * strictly greater than {@link #DESCENT_RADIUS_BLOCKS} — the entry↔descent hysteresis - * contract (an entering ship never spawns inside the descent trigger). {@code tunable}. + * Entry spawn-ring distance from the launch body's POI (blocks, cell-local), for a body with no + * size of its own. MUST stay strictly greater than {@link #DESCENT_RADIUS_BLOCKS} — the + * entry↔descent hysteresis contract (an entering ship never spawns inside the descent + * trigger). {@code tunable}. + * + *

    For a body that HAS a radius the ring follows the shell instead of this constant — see + * {@link #entryRingAround}. The hysteresis is a relation between the two, not a pair of numbers, + * and it stopped being expressible as a pair the moment the shell started depending on the body.

    */ public static final long ENTRY_RING_BLOCKS = DESCENT_RADIUS_BLOCKS * 2L; + /** + * The ring an entering ship spawns on around {@code body} — always strictly outside that body's + * descent shell, so a ship that has just entered is never already inside the trigger it is about + * to fly towards. + */ + public static long entryRingAround(zmaster587.advancedRocketry.universe.SystemBody body) { + long shell = zmaster587.advancedRocketry.space.DescentShell.radiusAround(body); + return Math.max(ENTRY_RING_BLOCKS, shell * 2L); + } + + /** + * The same ring for a body known only by ADDRESS — the entry path holds a coordinate, not the + * body object, so the body is resolved through the registry and the flat ring is used when there + * is nothing there to resolve (an unplaced launch, the config home anchor). + */ + public static long entryRingAround(GalacticCoord bodyAddress) { + if (bodyAddress == null) { + return ENTRY_RING_BLOCKS; + } + long widest = ENTRY_RING_BLOCKS; + for (zmaster587.advancedRocketry.universe.SystemBody b + : zmaster587.advancedRocketry.universe.UniverseRegistry.bodiesAtOnServer(bodyAddress)) { + widest = Math.max(widest, entryRingAround(b)); + } + return widest; + } + /** Ticks a ship waits after a refused/failed entry before the ceiling check may re-trigger. */ private static final int RETRY_COOLDOWN_TICKS = 100; @@ -297,7 +329,7 @@ private GalacticCoord resolveEntryCoord(int launchDimId, UUID shipId) { if (body == null) { body = GalacticCoord.ORIGIN; } - return StandoffRing.pointAround(body, ENTRY_RING_BLOCKS, shipId.hashCode()); + return StandoffRing.pointAround(body, entryRingAround(body), shipId.hashCode()); } /** Advance every in-flight entry one tick (the shared crossing settle loop). */ diff --git a/src/main/java/zmaster587/advancedRocketry/space/ShipTransitManager.java b/src/main/java/zmaster587/advancedRocketry/space/ShipTransitManager.java index adcfcfd39..9d97bc8c4 100644 --- a/src/main/java/zmaster587/advancedRocketry/space/ShipTransitManager.java +++ b/src/main/java/zmaster587/advancedRocketry/space/ShipTransitManager.java @@ -378,6 +378,8 @@ private static final class PendingReseat { private final LongSupplier clock; /** Offline-progress gate; {@code null} = always advance (state-machine unit tests). */ private OfflineProgress offlineProgress; + /** Performs a jump short enough to skip hyperspace; {@code null} = none wired, see the branch. */ + private DirectCrosser directCrosser; /** Arrival placement policy; {@code null} = arrive exactly on the aimed coordinate. */ private ArrivalPlacement arrivalPlacement; /** @@ -421,6 +423,39 @@ public boolean beginTransit(String shipId, GalacticCoord origin, int originSlotD if (transits.containsKey(shipId)) { return false; // already in transit } + long speed = Math.max(1L, speedBlocksPerTick); + long now = clock.getAsLong(); + // The flight is priced ONCE, here, through both cells' frames as they stand at departure. + // A jump is a commitment: the pilot saw a forecast at the console and the drive spent its + // burst against it, so re-pricing mid-flight because the destination kept orbiting would + // charge him for a decision he could not have made differently. + // + // It is also read BEFORE anything is allocated or cut, because the price is what chooses the + // mechanism: a leg short enough to be over before it presents itself is performed as one + // crossing instead (see DIRECT_CROSSING_MAX_TICKS). Everything the hyperspace path sets up — + // the lane, the crew capture, the floor snapshot — is work the direct path must not do. + double distance = (frames == null ? CellFrames.STATIC : frames) + .distanceBetween(origin, target, now); + if (isDirectCrossing(distance, speed)) { + if (directCrosser == null) { + // Nothing is wired to perform one, so the jump is flown the long way. Said out loud: + // a mechanism that silently does not exist is indistinguishable from one that was not + // chosen, and this branch is exactly where a wiring mistake would hide. + LOGGER.warn("[SPACE] jump for ship {} qualifies as a direct crossing ({} ticks) but no " + + "direct crosser is wired - flying it through hyperspace instead", + shipId, zmaster587.advancedRocketry.hyperdrive.JumpSpeed + .transitTicks(distance, speed)); + } else { + boolean crossed = directCrosser.crossDirect(shipId, origin, originSlotDim, + originAnchor, target); + LOGGER.info("[SPACE] direct crossing {} for ship {} {} -> {} ({} blocks, {} ticks of " + + "flight it does not need)", + crossed ? "began" : "REFUSED", shipId, origin.cellKey(), target.cellKey(), + (long) Math.ceil(distance), zmaster587.advancedRocketry.hyperdrive.JumpSpeed + .transitTicks(distance, speed)); + return crossed; + } + } HyperspaceTiles.Tile tile = tiles.allocate(); // Capture the seated crew BEFORE the depart crossing cuts the seat blocks (a post-cut capture finds // nothing). captureCrew stashes the full crew inside the crosser (keyed by shipId) for the reseat at @@ -455,14 +490,6 @@ public boolean beginTransit(String shipId, GalacticCoord origin, int originSlotD } // Refcount handoff, half 1: the ship has left the origin cell. space.dematerialize(origin); - long speed = Math.max(1L, speedBlocksPerTick); - long now = clock.getAsLong(); - // The flight is priced ONCE, here, through both cells' frames as they stand at departure. - // A jump is a commitment: the pilot saw a forecast at the console and the drive spent its - // burst against it, so re-pricing mid-flight because the destination kept orbiting would - // charge him for a decision he could not have made differently. - double distance = (frames == null ? CellFrames.STATIC : frames) - .distanceBetween(origin, target, now); long distanceBlocks = (long) Math.ceil(distance); // The ETA goes through the same law the console's forecast quotes, so the flight the pilot // was shown is the flight he gets. @@ -910,6 +937,58 @@ public enum Phase { private static final long DEPARTING_TICKS = 60L; private static final long ARRIVING_TICKS = 100L; + /** + * At or below this many ticks a jump is not flown at all — it is performed as a single cell→cell + * crossing, with no hyperspace leg. Derived, not chosen: {@link #phaseOf} reads a flight as + * departing, then cruising, then arriving, so a flight shorter than the two windows together never + * reports {@code CRUISING} at all. It is leaving, then it is arriving, and there was no flight in + * between. That is the point at which the mechanism's own presentation degenerates, and it is + * therefore the point at which the mechanism should stop being used. + * + *

    Because it is a sum of the two windows rather than a third number beside them, moving either + * window moves this with it. Written down separately, the three would drift.

    + * + *

    The crossing's own cost cannot invert the rule for any value: a hyperspace jump performs the + * crossing TWICE (depart and arrive) plus the spool and the flight, so the comparison is {@code C} + * against {@code spool + 2C + transitTicks} and {@code C} appears on both sides.

    + */ + public static final long DIRECT_CROSSING_MAX_TICKS = DEPARTING_TICKS + ARRIVING_TICKS; + + /** + * Would a jump of {@code distanceBlocks} at {@code speedBlocksPerTick} be performed as a direct + * crossing rather than flown through hyperspace? + * + *

    This is the only place that decides. The pilot's forecast at the console and the + * departure itself both call it, because a jump that is quoted as one mechanism and executed as the + * other is a lie the pilot cannot check. The rule keys on the COMPUTED DURATION and deliberately + * not on the route: with a fast enough drive an interstellar leg is also over in a tick, and a + * route-shaped rule ("in-system is direct") would then be wrong in the interesting case.

    + */ + public static boolean isDirectCrossing(double distanceBlocks, long speedBlocksPerTick) { + return zmaster587.advancedRocketry.hyperdrive.JumpSpeed + .transitTicks(distanceBlocks, speedBlocksPerTick) <= DIRECT_CROSSING_MAX_TICKS; + } + + /** + * Performs a jump short enough not to need hyperspace, as ONE cell→cell crossing. Kept behind + * a seam for the same reason {@link Crosser} is: the branch above must be decidable in a test with + * no world under it. Production is {@link CellCrossingController#requestDirectJump}. + */ + public interface DirectCrosser { + /** + * Cut the ship named {@code shipId} out of {@code origin} (slot {@code originSlotDim}, anchor + * {@code originAnchor}) and paste it into {@code target}, settling the ledger straight there. + * {@code false} = the crossing did not start, and the caller reports a failed jump. + */ + boolean crossDirect(String shipId, GalacticCoord origin, int originSlotDim, + BlockPos originAnchor, GalacticCoord target); + } + + /** Install the direct-crossing seam. {@code null} means short jumps fly through hyperspace and say so. */ + public void setDirectCrosser(DirectCrosser crosser) { + this.directCrosser = crosser; + } + /** Install the offline-progress gate (config mode + online check). {@code null} restores always-advance. */ public void setOfflineProgress(OfflineProgress policy) { this.offlineProgress = policy; diff --git a/src/main/java/zmaster587/advancedRocketry/space/SkyNebulaeProducer.java b/src/main/java/zmaster587/advancedRocketry/space/SkyNebulaeProducer.java new file mode 100644 index 000000000..39eb0b5a0 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/space/SkyNebulaeProducer.java @@ -0,0 +1,226 @@ +package zmaster587.advancedRocketry.space; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import zmaster587.advancedRocketry.network.PacketSystemBodiesSync.RenderNebula; +import zmaster587.advancedRocketry.universe.IGalaxyGenerator; +import zmaster587.advancedRocketry.universe.Nebula; +import zmaster587.advancedRocketry.universe.UniverseRegistry; +import zmaster587.advancedRocketry.universe.UniverseScale; + +/** + * Server-side producer for the nebula half of the cell sky: turns the clouds seated around a cell into + * the DIRECTIONS and apparent SIZES a client draws. + * + *

    A cloud is the only landmark the universe layer has. A star cluster is invisible from outside it — + * it can be identified only by counting stars, which no player will ever do — and a cloud is the thing + * that makes a region recognisable at a glance, from a long way off. That is what this feed is for.

    + * + *

    A direction, never a position

    + *

    A cloud is light years across and hundreds of light years away, so it does not move on the sky when + * a ship crosses a cell: a cell is about 4·10⁻⁴ light years. Its bearing is therefore computed from the + * CELL and not from a ship inside it, unlike the bodies beside it, and nothing here is a place that can + * be flown to — a nebula has no cell name by design (attribution reads names, not matter).

    + * + *

    What is dropped, and it is not silent

    + *

    Two bounds, both LOD and both stated: a cloud smaller than {@link #MIN_ANGULAR_RADIUS} on the sky + * is a smudge and is left out, and at most {@link #MAX_PER_CELL} are sent, largest first. The cap + * drops the SMALLEST, so what is lost is always what would have been least visible — but a caller that + * needs to know how much was dropped can compare against {@link #countAround}.

    + */ +public final class SkyNebulaeProducer { + + /** + * How far out clouds are gathered, in light years. A cluster lattice cell is 300 ly, so this is a + * few cluster cells each way; a cloud tens of light years across still subtends more than a degree + * at this range, and past it the angular filter below would drop it anyway. + */ + public static final double SKY_REACH_LY = 1_000d; + + /** + * The smallest a cloud may look and still be worth drawing, in radians (~0.6°, a little wider than + * the Moon from Earth). Below it a nebula is a few pixels of haze that cannot be a landmark. + */ + public static final double MIN_ANGULAR_RADIUS = 0.01d; + + /** How many clouds one cell's sky may carry. Largest first; the sky is a backdrop, not a catalogue. */ + public static final int MAX_PER_CELL = 12; + + /** + * What each cell's sky showed last time it was asked, keyed {@code seed|cellKey}. + * + *

    Derived data and never a dependency: every entry can be recomputed from {@code (seed, cell)} + * alone, and {@link #reset()} restores the empty map rather than nulling anything. It exists + * because the answer is CONSTANT — a cloud is hundreds of light years away and a cell is 4·10⁻⁴ of + * one across, so re-deriving it once a second per loaded cell would burn a few thousand hashes and + * a heap of short-lived clusters to arrive at the same list.

    + */ + private static final Map> CACHE = new LinkedHashMap<>(); + + /** How many cells the cache keeps. Oldest out first; a pool of live cells is far smaller than this. */ + private static final int CACHE_LIMIT = 64; + + private SkyNebulaeProducer() { + } + + /** Drop the per-cell cache (server stop, or a generator/seed change under a test). */ + public static void reset() { + synchronized (CACHE) { + CACHE.clear(); + } + } + + /** + * The clouds visible from {@code cell}, as render records, largest first. + * + * @param generator the seam the clouds come from; a generator with no clusters answers empty + * @param seed the world seed the generator is deterministic in + */ + public static List around(IGalaxyGenerator generator, long seed, + GalacticCoord cell) { + if (generator == null || cell == null) { + return Collections.emptyList(); + } + List found = generator.nebulaeAround(seed, cell, SKY_REACH_LY); + if (found == null || found.isEmpty()) { + return Collections.emptyList(); + } + GalacticCoord c = cell.cellCentre(); + // Measured by the generator that produced these clouds, not by a global: a sky drawn under + // one schema's metric and clouds seated under another's would not line up. + zmaster587.advancedRocketry.universe.IUniverseLaws laws = generator.laws(); + double observerX = laws.lightYearsForCells(c.sectorX()); + double observerY = laws.lightYearsForCells(c.sectorY()); + double observerZ = laws.lightYearsForCells(c.sectorZ()); + + List out = new ArrayList<>(); + for (Nebula nebula : found) { + RenderNebula drawn = renderOf(nebula, observerX, observerY, observerZ); + if (drawn != null) { + out.add(drawn); + } + } + // Largest first, so the cap below can only ever drop the least visible. + Collections.sort(out, new Comparator() { + @Override + public int compare(RenderNebula a, RenderNebula b) { + return Float.compare(b.angularRadius, a.angularRadius); + } + }); + return out.size() <= MAX_PER_CELL ? out : new ArrayList<>(out.subList(0, MAX_PER_CELL)); + } + + /** How many clouds are seated in reach of {@code cell} before any LOD filter — what was dropped. */ + public static int countAround(IGalaxyGenerator generator, long seed, GalacticCoord cell) { + if (generator == null || cell == null) { + return 0; + } + List found = generator.nebulaeAround(seed, cell, SKY_REACH_LY); + return found == null ? 0 : found.size(); + } + + /** + * One cloud as seen from an observer, or {@code null} when it is too small on the sky to draw. + * + *

    The half-angle is {@code asin(radius / distance)}, so a cloud OPENS as a ship closes on it, and + * a viewer inside one gets a right angle — the cloud is all around him, which is the honest limit + * rather than an overflow. The direction is then arbitrary and the sky is filled either way, so the + * degenerate zero-distance case keeps a fixed axis instead of a NaN.

    + */ + public static RenderNebula renderOf(Nebula nebula, double observerXLy, double observerYLy, + double observerZLy) { + if (nebula == null) { + return null; + } + double dx = nebula.centreXLy() - observerXLy; + double dy = nebula.centreYLy() - observerYLy; + double dz = nebula.centreZLy() - observerZLy; + double distance = Math.sqrt(dx * dx + dy * dy + dz * dz); + + double angularRadius; + double nx; + double ny; + double nz; + if (distance <= nebula.radiusLy()) { + // Inside it: the cloud fills the sky, and which way its centre lies stops mattering. + angularRadius = Math.PI / 2d; + double length = distance < 1.0E-9d ? 0d : distance; + nx = length == 0d ? 0d : dx / length; + ny = length == 0d ? 1d : dy / length; + nz = length == 0d ? 0d : dz / length; + } else { + angularRadius = Math.asin(nebula.radiusLy() / distance); + if (angularRadius < MIN_ANGULAR_RADIUS) { + return null; + } + nx = dx / distance; + ny = dy / distance; + nz = dz / distance; + } + return new RenderNebula((float) nx, (float) ny, (float) nz, (float) angularRadius, + nebula.appearance().ordinal(), (float) nebula.peakDensity()); + } + + /** + * The clouds of every materialized cell, keyed by the slot dim that cell is bound to — the same + * keying the bodies beside them use, and read from the same bindings. + * + *

    A live cell with no cloud gets a present-and-EMPTY entry, exactly as the bodies feed does: + * "present and empty" is what clears a stale sky, where "absent" would leave one standing.

    + */ + public static Map> buildByDim(Map loadedCells, + IGalaxyGenerator generator, long seed) { + Map> byDim = new LinkedHashMap<>(); + if (loadedCells == null) { + return byDim; + } + for (Map.Entry bound : loadedCells.entrySet()) { + Integer slotDim = bound.getValue(); + GalacticCoord cell = GalacticCoord.fromCellKey(bound.getKey()); + if (slotDim == null || slotDim == SpaceManager.UNBOUND_SLOT || cell == null) { + continue; + } + byDim.put(slotDim, cached(generator, seed, cell)); + } + return byDim; + } + + /** {@link #around} through the per-cell cache. */ + private static List cached(IGalaxyGenerator generator, long seed, + GalacticCoord cell) { + String key = seed + "|" + cell.cellCentre().cellKey(); + synchronized (CACHE) { + List hit = CACHE.get(key); + if (hit != null) { + return hit; + } + } + List computed = around(generator, seed, cell); + synchronized (CACHE) { + if (CACHE.size() >= CACHE_LIMIT) { + java.util.Iterator oldest = CACHE.keySet().iterator(); + if (oldest.hasNext()) { + oldest.next(); + oldest.remove(); + } + } + CACHE.put(key, computed); + } + return computed; + } + + /** The live per-slot-dim clouds from the production bindings + the installed generator. */ + public static Map> currentByDim(net.minecraft.server.MinecraftServer server) { + UniverseRegistry reg = UniverseRegistry.get(server); + SpaceManager space = SpaceSubsystem.space(); + if (reg == null || space == null) { + return new LinkedHashMap<>(); + } + return buildByDim(space.loadedCells(), UniverseRegistry.getGenerator(), reg.worldSeed()); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/space/SpaceSubsystem.java b/src/main/java/zmaster587/advancedRocketry/space/SpaceSubsystem.java index 6da97d75f..076d12625 100644 --- a/src/main/java/zmaster587/advancedRocketry/space/SpaceSubsystem.java +++ b/src/main/java/zmaster587/advancedRocketry/space/SpaceSubsystem.java @@ -67,6 +67,7 @@ public final class SpaceSubsystem { public final ShipTransitManager transit; public final ShipEntryController entry; public final DescentController descent; + public final CellCrossingController cellCrossings; private int gcTickCounter; /** Set by the pool-pressure eviction listener; consumed on the next server tick to run an extra GC. */ private boolean pressureGcRequested; @@ -117,6 +118,26 @@ public SpaceSubsystem(SlotBinder binder, java.util.function.LongSupplier clock, SpaceSubsystem::launchBodyAddress, useClock); this.descent = new DescentController(this.manager, this.ledger, new VSShipCrossingOps(), new VSDescentPasteResolver(), useClock); + this.cellCrossings = new CellCrossingController(this.manager, this.ledger, new VSShipCrossingOps(), + useClock); + // A jump too short to be worth a hyperspace leg is performed by the same machinery that carries + // a ship across a cell face — one crossing, ledger straight to the destination, no lane and no + // mid-flight. The transit manager decides WHICH jumps those are; this hands it the means. + this.transit.setDirectCrosser((shipId, origin, originSlotDim, originAnchor, target) -> { + // The transit manager keys ships by STRING, the ledger and the crossing by UUID. Not every + // string is one: a fixture may depart under a synthetic name, and a crossing cannot look + // that up. Refuse it here rather than throw out of a departure the pilot has paid for. + java.util.UUID durableId; + try { + durableId = java.util.UUID.fromString(shipId); + } catch (IllegalArgumentException notADurableId) { + AdvancedRocketry.logger.warn("[SPACE] direct crossing refused for ship '{}': it is not " + + "a durable id, so nothing can resolve it in the ledger", shipId); + return false; + } + return this.cellCrossings.requestDirectJump(originSlotDim, originAnchor, durableId, + origin, target); + }); } /** The live subsystem, or {@code null} when none is attached (before server start, or on a client). */ @@ -209,6 +230,12 @@ public static ShipEntryController entry() { return current == null ? null : current.entry; } + /** The live cell-to-cell crossing controller (seam carries AND short jumps), or {@code null} + * when no subsystem is attached. */ + public static CellCrossingController cellCrossings() { + return current == null ? null : current.cellCrossings; + } + /** The live descent controller, or {@code null} when no subsystem is attached. */ public static DescentController descent() { return current == null ? null : current.descent; diff --git a/src/main/java/zmaster587/advancedRocketry/space/SpaceSubsystemEvents.java b/src/main/java/zmaster587/advancedRocketry/space/SpaceSubsystemEvents.java index 7c569d745..c4f7ec1e8 100644 --- a/src/main/java/zmaster587/advancedRocketry/space/SpaceSubsystemEvents.java +++ b/src/main/java/zmaster587/advancedRocketry/space/SpaceSubsystemEvents.java @@ -72,6 +72,8 @@ public void onServerTick(TickEvent.ServerTickEvent event) { live.entry.tick(); // Advance in-flight DESCENTS (the inverse crossing, same async re-seat + settle). live.descent.tick(); + // Advance in-flight CELL-SEAM carries (a ship that flew out of its cell into the next one). + live.cellCrossings.tick(); // Rebroadcast the per-slot render bodies (throttled) so the slot-world sky (BoundarySky) // tracks each settled ship's direction to the bodies of its cell. SystemBodiesProducer.onBroadcastTick(FMLCommonHandler.instance().getMinecraftServerInstance()); diff --git a/src/main/java/zmaster587/advancedRocketry/space/SystemBodiesProducer.java b/src/main/java/zmaster587/advancedRocketry/space/SystemBodiesProducer.java index 57013356e..9cb174320 100644 --- a/src/main/java/zmaster587/advancedRocketry/space/SystemBodiesProducer.java +++ b/src/main/java/zmaster587/advancedRocketry/space/SystemBodiesProducer.java @@ -8,7 +8,9 @@ import zmaster587.advancedRocketry.api.Constants; import zmaster587.advancedRocketry.network.PacketSystemBodiesSync; import zmaster587.advancedRocketry.network.PacketSystemBodiesSync.RenderBody; +import zmaster587.advancedRocketry.network.PacketSystemBodiesSync.RenderNebula; import zmaster587.advancedRocketry.universe.SystemBody; +import zmaster587.advancedRocketry.util.AstronomicalBodyHelper; import zmaster587.advancedRocketry.universe.SystemBodyKind; import zmaster587.advancedRocketry.universe.UniverseRegistry; import zmaster587.libVulpes.network.PacketHandler; @@ -119,15 +121,30 @@ 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(). + boolean descendable = b.kind().canDescend(); // The shell is sent per body, from the ONE place that sizes it. A body that // cannot be descended to has none, and zero is what says so: the client must - // not have to know which kinds have a shell to render a range correctly. - long shell = b.isDescendTarget() ? DescentShell.radiusAround(b) : 0L; + // not have to know which kinds have a shell to render a range correctly. It is + // gated on the SAME predicate as the flag beside it — a body advertised as + // descendable while carrying a zero shell would draw a boundary of no radius, + // which is the unvisited-planet bug above coming back through the other field. + long shell = descendable ? DescentShell.radiusAround(b) : 0L; + // The body's own size, converted ONCE here from the universe layer's Earth radii + // into the chart blocks the client draws in. A body with no radius of its own (a + // belt, a station slot) sends zero, which is what says "not a sphere". + long radiusBlocks = Math.round(b.radiusEarths() + * AstronomicalBodyHelper.EARTH_RADIUS_BLOCKS); bodies.add(new RenderBody(b.kind().ordinal(), dir.dx(), dir.dy(), dir.dz(), - renderDimIdOf(b), b.isDescendTarget(), shell)); + renderDimIdOf(b), descendable, shell, radiusBlocks, + RenderBody.NO_PARENT)); } } - byDim.put(slotDim, bodies); + byDim.put(slotDim, linkMoonsToTheirParents(found, bodies)); } return byDim; } @@ -190,7 +207,8 @@ public static Map> currentByDim(MinecraftServer server /** Build the live packet from the production cell bindings + universe registry, or an empty packet. */ public static PacketSystemBodiesSync currentPacket(MinecraftServer server) { - return PacketSystemBodiesSync.forDims(currentByDim(server)); + return PacketSystemBodiesSync.forDims(currentByDim(server), + SkyNebulaeProducer.currentByDim(server)); } /** @@ -206,7 +224,8 @@ public static PacketSystemBodiesSync currentPacket(MinecraftServer server) { * stale sky, where an absent one would leave it standing. A player who is not in a slot world at * all is sent nothing.

    */ - private static void broadcastTo(EntityPlayerMP player, Map> byDim) { + private static void broadcastTo(EntityPlayerMP player, Map> byDim, + Map> nebulaeByDim) { if (player == null) { return; } @@ -218,9 +237,15 @@ private static void broadcastTo(EntityPlayerMP player, Map clouds = nebulaeByDim == null ? null : nebulaeByDim.get(dim); Map> one = new LinkedHashMap<>(); one.put(dim, bodies); - PacketHandler.sendToPlayer(PacketSystemBodiesSync.forDims(one), player); + Map> oneSky = new LinkedHashMap<>(); + oneSky.put(dim, clouds == null ? Collections.emptyList() : clouds); + PacketHandler.sendToPlayer(PacketSystemBodiesSync.forDims(one, oneSky), player); } /** Login send: give a joining player the sky of the dimension he arrived in. */ @@ -229,7 +254,8 @@ public static void sendToPlayer(EntityPlayerMP player) { return; } try { - broadcastTo(player, currentByDim(FMLCommonHandler.instance().getMinecraftServerInstance())); + MinecraftServer server = FMLCommonHandler.instance().getMinecraftServerInstance(); + broadcastTo(player, currentByDim(server), SkyNebulaeProducer.currentByDim(server)); } catch (Throwable t) { AdvancedRocketry.logger.warn("[SPACE] system-bodies login send failed", t); } @@ -249,16 +275,53 @@ public static void onBroadcastTick(MinecraftServer server) { } try { Map> byDim = currentByDim(server); + Map> nebulaeByDim = SkyNebulaeProducer.currentByDim(server); for (EntityPlayerMP player : server.getPlayerList().getPlayers()) { - broadcastTo(player, byDim); + broadcastTo(player, byDim, nebulaeByDim); } } catch (Throwable t) { AdvancedRocketry.logger.warn("[SPACE] system-bodies broadcast failed", t); } } - /** Reset the broadcast cadence (server stop). */ + /** Reset the broadcast cadence and the sky's derived caches (server stop). */ public static void reset() { tickCounter = 0; + SkyNebulaeProducer.reset(); + } + + /** + * Re-emit {@code bodies} with each MOON pointing at the body it belongs to. + * + *

    Resolved from the invariant the universe layer already holds rather than from a new + * identity: a moon shares its parent's CELL, and a cell holds at most one REAL body (moons + * excepted, which is exactly why they can share one). So the parent of a moon is the non-moon + * body of the same cell — and if there is none, the moon says so with {@link + * RenderBody#NO_PARENT} instead of pointing at a neighbour. A wrong parent would draw a moon + * orbiting a world it has nothing to do with, which is worse than an unparented moon.

    + */ + private static List linkMoonsToTheirParents(List source, + List bodies) { + if (source == null || source.size() != bodies.size()) { + return bodies; + } + Map primaryByCell = new LinkedHashMap<>(); + for (int i = 0; i < source.size(); i++) { + SystemBody b = source.get(i); + if (b.kind() != SystemBodyKind.MOON) { + primaryByCell.put(b.name().cellKey(), i); + } + } + List linked = new ArrayList<>(bodies.size()); + for (int i = 0; i < bodies.size(); i++) { + SystemBody b = source.get(i); + RenderBody r = bodies.get(i); + Integer parent = b.kind() == SystemBodyKind.MOON + ? primaryByCell.get(b.name().cellKey()) : null; + linked.add(parent == null ? r + : new RenderBody(r.kindOrdinal, r.localX, r.localY, r.localZ, r.dimId, + r.descendTarget, r.boundaryRadius, r.radiusBlocks, parent)); + } + return linked; } } diff --git a/src/main/java/zmaster587/advancedRocketry/tile/TileAdvancedFlightComputer.java b/src/main/java/zmaster587/advancedRocketry/tile/TileAdvancedFlightComputer.java index f7814f948..7cb80fc1a 100644 --- a/src/main/java/zmaster587/advancedRocketry/tile/TileAdvancedFlightComputer.java +++ b/src/main/java/zmaster587/advancedRocketry/tile/TileAdvancedFlightComputer.java @@ -498,10 +498,24 @@ public void update() { if (ledger != null && cell != null) { double[] pose = VSIntegration.getShipWorldPosition(world, getPos()); if (pose != null) { - // A ship reports its position WITHIN its cell. It may not rename the cell by moving: - // the name is the world it is in, the slot it is bound to and the ledger row that - // protects that cell from collection, and none of those follow a pose over a cell - // face. A pose outside the local range is therefore saturated, not carried. + // FLYING OUT OF THE CELL. A ship far enough past its face is carried into the + // neighbour it left through - the crossing cuts this tile out of the world, so + // nothing below may run on this tick. The margin that "far enough" means, and the + // depth the ship arrives at, are the seam's; this call site only owns the ORDER: + // the carry is asked BEFORE the position is reported, because a report that + // saturates is what a ship gets when the carry was refused, not what it gets while + // one is available. + zmaster587.advancedRocketry.space.CellCrossingController seamCtl = + zmaster587.advancedRocketry.space.SpaceSubsystem.cellCrossings(); + if (seamCtl != null && seamCtl.requestCarry(world.provider.getDimension(), + getPos(), shipId, cell, pose)) { + return; + } + // The carry did not happen (none was needed, or the pool refused one). A ship + // reports its position WITHIN its cell: the name is the world it is in, the slot it + // is bound to and the ledger row that protects that cell from collection, and none + // of those follow a pose over a cell face on their own. So a pose outside the local + // range is saturated - wrong by the overshoot, but naming a cell that exists. ledger.updatePosition(shipId, zmaster587.advancedRocketry.space.CellWorldMapper .coordOfPoseWithin(cell, pose[0], pose[1], pose[2])); // Only a SETTLED ship can be at its cell's edge by flying there. A ship mid-crossing @@ -517,9 +531,10 @@ public void update() { .poseEscapesCell(pose[0], pose[1], pose[2])) { cellEdgeReported = true; zmaster587.advancedRocketry.AdvancedRocketry.logger.warn( - "[SPACE] ship {} reached the edge of cell {} (pose {},{},{}) - its position " - + "is held at the boundary. Leaving a neighbourhood is a jump, not a " - + "flight.", + "[SPACE] ship {} is outside cell {} (pose {},{},{}) and was not carried " + + "into the neighbour - its position is held at the boundary. " + + "Either it has not yet passed the carry margin, or the carry was " + + "refused (no free slot); the seam logs a refusal when it is one.", shipId, cellKey, pose[0], pose[1], pose[2]); } } @@ -570,10 +585,24 @@ public void update() { double distance = Math.sqrt(shipCoord.staticFrameDistanceSqTo( body.addressAt(zmaster587.advancedRocketry.space.SpaceSubsystem .spaceClock()))); - if (zmaster587.advancedRocketry.space.DescentController - .shouldTriggerDescent(true, distance, radius) - && descentCtl.requestDescent(world.provider.getDimension(), - getPos(), shipId, body.dimId())) { + if (!zmaster587.advancedRocketry.space.DescentController + .shouldTriggerDescent(true, distance, radius)) { + continue; + } + // A procedural body has no dimension until somebody flies down to it, so + // the world is minted HERE — once the ship is genuinely close enough to + // descend. The scan above must never allocate a dimension. + 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; @@ -764,7 +793,11 @@ private java.util.List descendT java.util.List found = new java.util.ArrayList<>(); for (zmaster587.advancedRocketry.universe.SystemBody b : reg.bodiesAt(shipCoord)) { - if (b.isDescendTarget()) { + // "Can a ship land here", not "does a world already exist". A procedural body has no + // dimension until a descent mints one, so filtering on isDescendTarget() would hide + // every world nobody has visited — and this list is the ONLY gate the descent loop + // sees, so the principle has to live here rather than at the call site. + if (b.kind().canDescend()) { found.add(b); } } @@ -852,7 +885,7 @@ private void refreshHudDrive(long now) { } long capacity = drive.capacitorCapacity(); hudDriveCharge = capacity <= 0 ? 0f - : (float) Math.min(1.0, (double) drive.capacitorCharge(now) / (double) capacity); + : (float) Math.min(1.0, (double) drive.capacitorCharge() / (double) capacity); zmaster587.advancedRocketry.navigation.ShipNavigation nav = new zmaster587.advancedRocketry.navigation.ShipNavigation(world, getPos(), shipId); zmaster587.advancedRocketry.tile.TileNavigationComputer computer = nav.findNavComputer(); diff --git a/src/main/java/zmaster587/advancedRocketry/tile/TileNavigationComputer.java b/src/main/java/zmaster587/advancedRocketry/tile/TileNavigationComputer.java index a9c355cc6..f2a30e888 100644 --- a/src/main/java/zmaster587/advancedRocketry/tile/TileNavigationComputer.java +++ b/src/main/java/zmaster587/advancedRocketry/tile/TileNavigationComputer.java @@ -690,7 +690,7 @@ private String computeForecast() { out.append(LibVulpes.proxy.getLocalizedString("msg.navcomputer.drivepower")) .append(' ').append(stats.drivePower()).append('\n'); out.append(LibVulpes.proxy.getLocalizedString("msg.navcomputer.burst")) - .append(' ').append(drive.capacitorCharge(now)) + .append(' ').append(drive.capacitorCharge()) .append('/').append(stats.burstCost()).append('\n'); // What the bank IS, beside what is in it. A charge of 0/40000 reads as "wait" whether // the ship has no capacitor at all or one that can never hold that much, and a pilot who @@ -698,7 +698,7 @@ private String computeForecast() { out.append(LibVulpes.proxy.getLocalizedString("msg.navcomputer.capacitors")) .append(' ').append(drive.capacitors().size()) .append(" (").append(drive.capacitorCapacity()).append(")\n"); - long cooldown = drive.cooldownTicks(now); + long cooldown = drive.cooldownTicks(); if (cooldown > 0L) { out.append(LibVulpes.proxy.getLocalizedString("msg.navcomputer.cooldown")) .append(' ').append(cooldown / 20L).append("s\n"); diff --git a/src/main/java/zmaster587/advancedRocketry/tile/hyperdrive/TileGravityDampener.java b/src/main/java/zmaster587/advancedRocketry/tile/hyperdrive/TileGravityDampener.java index ff19401d5..d98145e40 100644 --- a/src/main/java/zmaster587/advancedRocketry/tile/hyperdrive/TileGravityDampener.java +++ b/src/main/java/zmaster587/advancedRocketry/tile/hyperdrive/TileGravityDampener.java @@ -41,7 +41,14 @@ public boolean isPowered() { return energy.getEnergyStored() >= POWERED_THRESHOLD; } - /** The exit speed this dampener fully absorbs for everyone it covers. */ + /** + * The exit speed this dampener fully absorbs for everyone it covers. + * + *

    A fraction of a BASELINE arrival rather than an absolute number of blocks per tick: what the + * balance actually promises is "a couple of these cover the ship a novice flies", and the speed law + * multiplies every arrival by the drive's generation — so an absolute figure would have detached + * from that promise the first time a tier moved.

    + */ public long absorbedSpeed() { return DriveTuning.DAMPENER_ABSORBED_SPEED; } diff --git a/src/main/java/zmaster587/advancedRocketry/tile/hyperdrive/TileHyperdriveGenerator.java b/src/main/java/zmaster587/advancedRocketry/tile/hyperdrive/TileHyperdriveGenerator.java index 3bcb3cb81..33c8ece54 100644 --- a/src/main/java/zmaster587/advancedRocketry/tile/hyperdrive/TileHyperdriveGenerator.java +++ b/src/main/java/zmaster587/advancedRocketry/tile/hyperdrive/TileHyperdriveGenerator.java @@ -8,6 +8,7 @@ import zmaster587.advancedRocketry.api.AdvancedRocketryBlocks; import zmaster587.advancedRocketry.hyperdrive.ComponentScan; +import zmaster587.advancedRocketry.hyperdrive.DriveTier; import zmaster587.advancedRocketry.hyperdrive.DriveTuning; import zmaster587.advancedRocketry.hyperdrive.ShipDriveStats; import zmaster587.advancedRocketry.tile.TileShipComponent; @@ -36,8 +37,20 @@ public class TileHyperdriveGenerator extends TileShipComponent { * number written at assembly time eventually would. */ public ShipDriveStats stats() { - return ShipDriveStats.ofPower( - DriveTuning.GENERATOR_BASE_POWER + coilCount() * DriveTuning.POWER_PER_COIL); + return ShipDriveStats.ofPower(DriveTuning.powerForCoils(coilCount()), tier()); + } + + /** + * Which generation of drive this block is. + * + *

    One generator block, one tier, so this is a property of the BLOCK and not of the build — a + * later generation is a different machine a player installs, which is what puts him back at a + * handful of coils and makes the new tier's efficiency something he feels. Only the first + * generation has a block today; the seam is here so that adding the next one is a block and a + * recipe rather than a change to the speed law.

    + */ + public DriveTier tier() { + return DriveTier.baseline(); } /** How many coils are welded to this generator. */ diff --git a/src/main/java/zmaster587/advancedRocketry/tile/hyperdrive/TileJumpCapacitor.java b/src/main/java/zmaster587/advancedRocketry/tile/hyperdrive/TileJumpCapacitor.java index a6b16e9b7..deedcd7d4 100644 --- a/src/main/java/zmaster587/advancedRocketry/tile/hyperdrive/TileJumpCapacitor.java +++ b/src/main/java/zmaster587/advancedRocketry/tile/hyperdrive/TileJumpCapacitor.java @@ -1,8 +1,13 @@ package zmaster587.advancedRocketry.tile.hyperdrive; +import javax.annotation.Nullable; + import net.minecraft.block.Block; import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.util.EnumFacing; import net.minecraft.util.math.BlockPos; +import net.minecraftforge.energy.CapabilityEnergy; +import net.minecraftforge.energy.IEnergyStorage; import zmaster587.advancedRocketry.api.AdvancedRocketryBlocks; import zmaster587.advancedRocketry.hyperdrive.CapacitorCharge; @@ -15,27 +20,73 @@ * *

    A jump does not need a lot of energy over time so much as a great deal of it in one instant, * which is why this is a separate machine standing beside the generator rather than a bigger battery - * inside it. Cells decide how much it holds; heat sinks decide how fast it recovers — and the - * cooldown a pilot feels between jumps is nothing but that recovery, so there is no timer here and - * no thermal state to keep.

    + * inside it. Cells decide how much it holds; heat sinks decide how fast it can ACCEPT charge. + * The cooldown a pilot feels between jumps is how long his own power plant takes to refill it, so + * there is no timer here and no thermal state to keep.

    + * + *

    The energy comes from the SHIP — it is not manufactured here

    + * + *

    This is a Forge Energy receiver like any other machine: reactors, solar arrays and cables push + * into it. It refuses EXTRACTION through the capability on purpose — a jump bank is not a battery for + * the rest of the vessel, and only the drive's own burst may take from it. So the biggest single cost + * in the hyperdrive family is paid for out of generation the player built, which is what makes + * "sustained generation aboard" a pressure rather than a sentence in a design document.

    * - *

    It never ticks. The charge is arithmetic over the SPACE clock, so a capacitor aboard a - * ship that spent a month in hyperspace, or parked in a cell nobody loaded, is exactly as charged as - * one that sat in a busy chunk the whole time. Only the level at the last real event, and when that - * event was, are ever written down.

    + *

    RETRACTED, and the retraction is the point of this class's history. It used to hold no + * energy at all: the level was a closed form of the world clock, + * {@code min(capacity, c0 + rate·(t − since))}, with the rate conjured by welding heat sinks on. That + * bought one property — a capacitor aboard a ship in an unloaded cell was exactly as charged as one in + * a busy chunk — and the property was only defensible while the energy was FREE. An unloaded ship's + * reactors are not running either, so charging through an absence was creating energy from nothing a + * second time, more quietly. The fairness it was reaching for belongs to whatever powers the ship, not + * to its buffer.

    */ public class TileJumpCapacitor extends TileShipComponent { static final String KIND_CELL = "cell"; static final String KIND_SINK = "sink"; - private static final String NBT_BASE_CHARGE = "capBaseCharge"; - private static final String NBT_SINCE = "capSince"; + private static final String NBT_CHARGE = "capCharge"; + + /** What is actually in the bank, in Forge Energy units. Never above {@link #capacity()}. */ + private long charge; + + /** + * The face the ship's grid pushes into. Capacity and accept rate are read from the BUILD on every + * call rather than fixed at construction: a cell pulled out mid-flight has to make the bank + * smaller the moment it is pulled, exactly as a coil pulled out makes the ship slower. + */ + private final IEnergyStorage port = new IEnergyStorage() { + @Override + public int receiveEnergy(int maxReceive, boolean simulate) { + return (int) acceptCharge(maxReceive, simulate); + } + + @Override + public int extractEnergy(int maxExtract, boolean simulate) { + return 0; // a jump bank is not the ship's battery; only the drive's burst takes from it + } - /** The charge as of {@link #since} — the level at the last thing that actually happened. */ - private long baseCharge; - /** Space-clock tick of that event. Everything after it is computed, never accumulated. */ - private long since; + @Override + public int getEnergyStored() { + return (int) Math.min(Integer.MAX_VALUE, charge); + } + + @Override + public int getMaxEnergyStored() { + return (int) Math.min(Integer.MAX_VALUE, capacity()); + } + + @Override + public boolean canExtract() { + return false; + } + + @Override + public boolean canReceive() { + return true; + } + }; /** How much this bank holds when full. */ public long capacity() { @@ -44,46 +95,77 @@ public long capacity() { + scan.count(KIND_CELL) * DriveTuning.CAPACITY_PER_CELL; } - /** How fast it refills. Heat sinks are the whole of the cooling system. */ - public long chargeRate() { + /** + * How much charge this bank can take in one tick — a THROUGHPUT limit, not a supply. Heat sinks + * are what let a buffer swallow a large inflow without cooking; they do not make the energy, and + * a bank with every sink in the world fills at nothing if nothing is feeding it. + */ + public long acceptRate() { ComponentScan.Result scan = scan(); - return DriveTuning.CAPACITOR_BASE_CHARGE_RATE - + scan.count(KIND_SINK) * DriveTuning.CHARGE_RATE_PER_SINK; + return DriveTuning.CAPACITOR_BASE_ACCEPT_RATE + + scan.count(KIND_SINK) * DriveTuning.ACCEPT_RATE_PER_SINK; } - /** The charge at world-clock tick {@code now}. */ - public long chargeAt(long now) { - return CapacitorCharge.at(baseCharge, since, chargeRate(), capacity(), now); + /** What is in the bank right now. */ + public long charge() { + return Math.min(capacity(), Math.max(0L, charge)); } /** - * Ticks until this bank holds {@code needed}, or {@code -1} when it never will because it cannot - * hold that much. This is the cooldown, and it is a consequence of the build rather than a - * number of its own. + * Take up to {@code amount} of charge from whatever is feeding this bank, bounded by the room left + * and by {@link #acceptRate()}. Returns how much was taken. + * + *

    The RULE lives here and the Forge Energy port is three lines of delegation on top of it, so + * "how much a bank will swallow" is a property of the machine rather than of one adapter — and it + * can be asked about without a capability registry standing up around it.

    + * + * @param simulate report what would be taken without taking it */ - public long ticksUntil(long needed, long now) { - return CapacitorCharge.ticksUntil(baseCharge, since, chargeRate(), capacity(), now, needed); + public long acceptCharge(long amount, boolean simulate) { + if (amount <= 0L) { + return 0L; + } + long room = Math.max(0L, capacity() - charge()); + long accepted = Math.min(Math.min(room, acceptRate()), amount); + if (accepted <= 0L) { + return 0L; + } + if (!simulate) { + charge = charge() + accepted; + markDirty(); + } + return accepted; } /** - * Take {@code amount} out of the bank at {@code now}. Returns how much was actually drawn, which - * is all of it or nothing: half a burst does not open half a window. + * Ticks until this bank holds {@code needed} if it is fed at its full accept rate, or + * {@code -1} when it never will because it cannot hold that much. + * + *

    A BEST CASE, and the honest name for it is a forecast: what the bank could do, not what the + * ship will actually deliver. Whether the inflow is there is the power plant's business, and a + * pilot who has under-built his reactors waits longer than this says.

    */ - public long discharge(long amount, long now) { - long available = chargeAt(now); + public long ticksUntilAtFullInflow(long needed) { + return CapacitorCharge.ticksToReach(charge(), capacity(), acceptRate(), needed); + } + + /** + * Take {@code amount} out of the bank. Returns how much was actually drawn, which is all of it or + * nothing: half a burst does not open half a window. + */ + public long discharge(long amount) { + long available = charge(); if (amount <= 0L || available < amount) { return 0L; } - baseCharge = available - amount; - since = now; + charge = available - amount; markDirty(); return amount; } - /** Fill the bank to the brim as of {@code now}. Used by fixtures and by creative-mode charging. */ - public void fill(long now) { - baseCharge = capacity(); - since = now; + /** Fill the bank to the brim. Used by fixtures and by creative-mode charging. */ + public void fill() { + charge = capacity(); markDirty(); } @@ -106,18 +188,32 @@ public String kindAt(BlockPos at) { }, DriveTuning.MAX_CAPACITOR_COMPONENTS); } + @Override + public boolean hasCapability(net.minecraftforge.common.capabilities.Capability capability, + @Nullable EnumFacing facing) { + return capability == CapabilityEnergy.ENERGY || super.hasCapability(capability, facing); + } + + @Override + @Nullable + public T getCapability(net.minecraftforge.common.capabilities.Capability capability, + @Nullable EnumFacing facing) { + if (capability == CapabilityEnergy.ENERGY) { + return CapabilityEnergy.ENERGY.cast(port); + } + return super.getCapability(capability, facing); + } + @Override public NBTTagCompound writeToNBT(NBTTagCompound nbt) { super.writeToNBT(nbt); - nbt.setLong(NBT_BASE_CHARGE, baseCharge); - nbt.setLong(NBT_SINCE, since); + nbt.setLong(NBT_CHARGE, charge); return nbt; } @Override public void readFromNBT(NBTTagCompound nbt) { super.readFromNBT(nbt); - baseCharge = nbt.getLong(NBT_BASE_CHARGE); - since = nbt.getLong(NBT_SINCE); + charge = nbt.getLong(NBT_CHARGE); } } diff --git a/src/main/java/zmaster587/advancedRocketry/tile/multiblock/TileObservatory.java b/src/main/java/zmaster587/advancedRocketry/tile/multiblock/TileObservatory.java index e5cd4daaf..190661e7a 100644 --- a/src/main/java/zmaster587/advancedRocketry/tile/multiblock/TileObservatory.java +++ b/src/main/java/zmaster587/advancedRocketry/tile/multiblock/TileObservatory.java @@ -60,6 +60,9 @@ public class TileObservatory extends TileMultiPowerConsumer implements IModularInventory, IDataInventory, IGuiCallback { + private static final org.apache.logging.log4j.Logger LOGGER = + org.apache.logging.log4j.LogManager.getLogger("AdvancedRocketry|Observatory"); + private final java.util.Map savedDataBusNbt = new java.util.HashMap<>(); final static int openTime = 100; final static int observationTime = 1000; @@ -108,6 +111,7 @@ public class TileObservatory extends TileMultiPowerConsumer implements IModularI private static final byte START_SCAN = 19; private static final byte ABORT_SCAN = 20; private static final byte PASSIVE_SWEEP = 21; + private static final byte TOGGLE_WHOLE_SYSTEM = 22; /** Progress id of the region-scan bar; the machine's own bar keeps id 0. */ private static final int PROGRESS_SCAN = 1; /** @@ -139,14 +143,36 @@ public class TileObservatory extends TileMultiPowerConsumer implements IModularI private RegionScan activeScan; /** How many addresses the last finished scan wrote — what the operator gets told he learned. */ private int lastScanDiscoveries; + /** + * How many of the last scan's looks a cloud stood in the way of. The crystal still gained their + * coordinates; what it did NOT gain is what is at them, and the operator is told which. + */ + private int lastScanObscured; /** Which way the operator has the instrument pointed, as an index into {@link #SCAN_DIRECTIONS}. */ private int scanDirection; - /** How far out, in sectors, he has it aimed. Clamped to the configured reach when it is used. */ + /** How far out, in STEPS, he has it aimed. Clamped to the configured reach when it is used. */ private int scanDistance = 1; + /** + * What one step of aim is worth in light years — how the operator's pick is turned into a length + * he can recognise. Derived from the SERVER's galaxy generator and synced, never computed on the + * client: a client attached to a pack whose star spacing it does not hold would quote its own. + */ + private double stepLightYears; /** Client-side only: which way the distance button just pressed wants to move the aim. */ private int pendingDistanceDelta; /** Watching the neighbourhood rather than a distant patch. The two modes are exclusive. */ private boolean passive; + /** + * Whether a detection is followed all the way to the system's BODIES, or only its address is + * written down. + * + *

    An operational choice with a cost, so it lives on the instrument rather than in the + * configuration: characterising every find fills a crystal many times faster and is what an + * operator wants over known sky, while a deep pointing into sky nobody has been to is a list of + * places worth flying to. Default on, because that is what the survey did before it could tell + * the two questions apart.

    + */ + private boolean characteriseWholeSystem = true; public TileObservatory() { openProgress = 0; @@ -299,6 +325,12 @@ public void update() { } if (!world.isRemote) { + // Once per load: the stride is the installed generator's, and that is fixed for a world. + if (stepLightYears <= 0d) { + stepLightYears = zmaster587.advancedRocketry.universe.UniverseScale + .lightYearsForCells(RegionScan.Tuning.fromConfig().strideCells()); + markDirty(); + } completeRegionScanIfDue(); } @@ -390,9 +422,12 @@ protected void writeNetworkData(NBTTagCompound nbt) { nbt.setTag("regionScan", scan); } nbt.setInteger("lastScanDiscoveries", lastScanDiscoveries); + nbt.setInteger("lastScanObscured", lastScanObscured); nbt.setInteger("scanDirection", scanDirection); nbt.setInteger("scanDistance", scanDistance); + nbt.setDouble("scanStepLy", stepLightYears); nbt.setBoolean("scanPassive", passive); + nbt.setBoolean("scanWholeSystem", characteriseWholeSystem); } @Override @@ -412,9 +447,12 @@ protected void readNetworkData(NBTTagCompound nbt) { if (arr != null) for (int v : arr) printedButtonsThisSeed.add(v); activeScan = nbt.hasKey("regionScan") ? RegionScan.readFromNBT(nbt.getCompoundTag("regionScan")) : null; lastScanDiscoveries = nbt.getInteger("lastScanDiscoveries"); + lastScanObscured = nbt.getInteger("lastScanObscured"); scanDirection = nbt.getInteger("scanDirection"); scanDistance = Math.max(1, nbt.getInteger("scanDistance")); + stepLightYears = nbt.getDouble("scanStepLy"); passive = nbt.getBoolean("scanPassive"); + characteriseWholeSystem = !nbt.hasKey("scanWholeSystem") || nbt.getBoolean("scanWholeSystem"); if (world != null && world.isRemote && prevSeed != lastSeed) { zmaster587.advancedRocketry.AdvancedRocketry.proxy.clearObservatoryScrollCache(); @@ -438,9 +476,11 @@ public NBTTagCompound writeToNBT(NBTTagCompound nbt) { nbt.setTag("regionScan", scan); } nbt.setInteger("lastScanDiscoveries", lastScanDiscoveries); + nbt.setInteger("lastScanObscured", lastScanObscured); nbt.setInteger("scanDirection", scanDirection); nbt.setInteger("scanDistance", scanDistance); nbt.setBoolean("scanPassive", passive); + nbt.setBoolean("scanWholeSystem", characteriseWholeSystem); return nbt; } @@ -456,9 +496,11 @@ public void readFromNBT(NBTTagCompound nbt) { activeScan = nbt.hasKey("regionScan") ? RegionScan.readFromNBT(nbt.getCompoundTag("regionScan")) : null; lastScanDiscoveries = nbt.getInteger("lastScanDiscoveries"); + lastScanObscured = nbt.getInteger("lastScanObscured"); scanDirection = nbt.getInteger("scanDirection"); scanDistance = Math.max(1, nbt.getInteger("scanDistance")); passive = nbt.getBoolean("scanPassive"); + characteriseWholeSystem = !nbt.hasKey("scanWholeSystem") || nbt.getBoolean("scanWholeSystem"); } @@ -678,7 +720,7 @@ public List getModules(int ID, EntityPlayer player) { modules.add(new ModuleText(8, 70, LibVulpes.proxy.getLocalizedString("msg.observetory.scan.distance") - + " " + scanDistance, 0x2d2d2d, false)); + + " " + scanDistance + aimInLightYears(), 0x2d2d2d, false)); modules.add(new ModuleButton(100, 66, 4, "-", this, zmaster587.libVulpes.inventory.TextureResources.buttonBuild, LibVulpes.proxy.getLocalizedString("msg.observetory.scan.distance.tooltip"), 18, 18)); @@ -707,6 +749,15 @@ public List getModules(int ID, EntityPlayer player) { this, zmaster587.libVulpes.inventory.TextureResources.buttonBuild, LibVulpes.proxy.getLocalizedString("msg.observetory.scan.mode.tooltip"), 40, 18)); + // What a detection is followed up with. An operational choice with a cost, so it is a + // control on the instrument and not a setting in a file: over known sky an operator wants + // every body named, and into sky nobody has visited he wants a list of places to fly to. + modules.add(new ModuleButton(166, 66, 9, + LibVulpes.proxy.getLocalizedString(characteriseWholeSystem + ? "msg.observetory.scan.detail.full" : "msg.observetory.scan.detail.coords"), + this, zmaster587.libVulpes.inventory.TextureResources.buttonBuild, + LibVulpes.proxy.getLocalizedString("msg.observetory.scan.detail.tooltip"), 40, 18)); + modules.add(new ModuleText(8, 116, scanStatusText(), 0x2d2d2d, false)); modules.add(new ModuleText(8, 128, LibVulpes.proxy.getLocalizedString("msg.observetory.scan.keepcrystal"), @@ -762,13 +813,14 @@ public int getMaxDistance() { /** * Aim the instrument at a region and start looking. Server side; one observation at a time. * - *

    The distance is in galactic sectors and is clamped to the configured reach rather than - * refused — asking to see farther than the instrument can gets you the instrument's reach.

    + *

    The distance is in STEPS — one step is one star's territory — and is clamped to the + * configured reach rather than refused: asking to see farther than the instrument can gets you + * the instrument's reach.

    * * @return {@code false} when the machine is already looking somewhere, or when it does not know * where it is standing and so has nothing to aim FROM */ - public boolean beginRegionScan(int dirX, int dirY, int dirZ, int distanceSectors) { + public boolean beginRegionScan(int dirX, int dirY, int dirZ, int distanceSteps) { if (world == null || world.isRemote) { return false; } @@ -778,14 +830,38 @@ public boolean beginRegionScan(int dirX, int dirY, int dirZ, int distanceSectors } // Re-aiming mid-sweep is allowed and costs only the cell in flight: every cell already // resolved is already written to the crystal, so there is nothing else to lose. - activeScan = RegionScan.directed(origin, dirX, dirY, dirZ, distanceSectors, - world.getTotalWorldTime(), RegionScan.Tuning.fromConfig()); + RegionScan aimed = buildScan(() -> RegionScan.directed(origin, dirX, dirY, dirZ, distanceSteps, + world.getTotalWorldTime(), RegionScan.Tuning.fromConfig())); + if (aimed == null) { + return false; + } + activeScan = aimed; passive = false; lastScanDiscoveries = 0; + lastScanObscured = 0; markDirty(); return true; } + /** + * Build a survey, or refuse to start one — a configuration that describes a region no survey can + * walk is reported and declined, never started half-way. + * + *

    {@code RegionScan} refuses such a region rather than clamping its look count, because a + * clamped count reports the sweep complete with most of the region never visited. Here that + * refusal has to become an operator-visible "the machine did not start" plus a line in the log + * naming the setting, since the alternative is a tile that throws out of a GUI action.

    + */ + private RegionScan buildScan(java.util.function.Supplier build) { + try { + return build.get(); + } catch (IllegalArgumentException refused) { + LOGGER.error("the observatory at " + pos + " cannot start a survey: " + + refused.getMessage() + " Check the telescopeScan* settings."); + return null; + } + } + /** Stop looking. Free — an aim the operator regrets must not have to be waited out. */ public boolean abortRegionScan() { if (world == null || world.isRemote || activeScan == null) { @@ -811,15 +887,16 @@ public boolean beginPassiveSweep() { if (origin == null) { return false; } - int radius = Math.max(0, ARConfiguration.getCurrentConfig().telescopePassiveRadiusSectors); - GalacticCoord lo = GalacticCoord.ofSectorLocal(origin.sectorX() - radius, - origin.sectorY() - radius, origin.sectorZ() - radius, 0L, 0L, 0L); - GalacticCoord hi = GalacticCoord.ofSectorLocal(origin.sectorX() + radius, - origin.sectorY() + radius, origin.sectorZ() + radius, 0L, 0L, 0L); - activeScan = RegionScan.box(lo, hi, radius, world.getTotalWorldTime(), - RegionScan.Tuning.fromConfig()); + int radius = Math.max(0, ARConfiguration.getCurrentConfig().telescopePassiveRadiusSteps); + RegionScan sweep = buildScan(() -> RegionScan.local(origin, radius, + world.getTotalWorldTime(), RegionScan.Tuning.fromConfig())); + if (sweep == null) { + return false; + } + activeScan = sweep; passive = true; lastScanDiscoveries = 0; + lastScanObscured = 0; markDirty(); return true; } @@ -835,22 +912,82 @@ public int scanDirectionIndex() { return Math.floorMod(scanDirection, SCAN_DIRECTIONS.length); } - /** How far out the operator has the instrument aimed, in sectors. */ + /** How far out the operator has the instrument aimed, in steps of one star's territory. */ public int getScanDistance() { return scanDistance; } + /** Whether a detection is followed all the way to the system's bodies, or only its address. */ + public boolean isCharacterisingWholeSystem() { + return characteriseWholeSystem; + } + + /** How far the current aim reaches, in light years, or zero before the server has said. */ + public double getAimLightYears() { + return scanDistance * stepLightYears; + } + + /** + * The aim as a length, in brackets — because a bare "3" says nothing about the sky. Empty until + * the server has told this tile what a step is worth, so the client never invents the number. + */ + private String aimInLightYears() { + double ly = getAimLightYears(); + if (ly <= 0d) { + return ""; + } + // The unit travels with the translation: a bracket reading "ly" is English, and this GUI + // already speaks two languages. + return String.format(LibVulpes.proxy.getLocalizedString("msg.observetory.scan.lightyears"), ly); + } + /** Whether the instrument is watching its own neighbourhood rather than a distant patch. */ public boolean isPassive() { return passive; } + /** + * How many of the looks in this batch a cloud stands in the way of. + * + *

    Counted beside the resolve rather than inside it, because what the OPERATOR is owed and what + * the CRYSTAL is written with are different things: the crystal gains an address either way, and + * the operator needs to know the difference between "there is nothing out that way" and "I cannot + * see through that".

    + */ + private static int countObscured(UniverseRegistry registry, GalacticCoord origin, RegionScan scan, + int from, int count) { + if (registry == null || origin == null || scan == null) { + return 0; + } + int obscured = 0; + double limit = TelescopeScan.limitMagnitude(); + for (int index = from; index < from + count && index < scan.totalCells(); index++) { + // The DETECTIONS and not the cells: empty sky is not a hidden sky, and neither is a star + // the instrument never registered. What the operator is being told about is the band in + // between - bright enough to see, too dim through the dust to make anything out. + for (TelescopeScan.Detection hit + : TelescopeScan.detect(registry, scan.cellAt(index), origin, limit)) { + if (TelescopeScan.isObscuredAt(hit.extinctionMagnitudes())) { + obscured++; + } + } + } + return obscured; + } + /** What the tab tells the operator the instrument is doing right now. */ private String scanStatusText() { if (activeScan != null) { return LibVulpes.proxy.getLocalizedString("msg.observetory.scan.looking") + " " + activeScan.cellsDone() + "/" + activeScan.totalCells(); } + // The dust is reported BEFORE the count of what was found: a survey that came back with + // coordinates and no bodies has a reason, and an operator who is not told it reads the + // instrument as broken. + if (lastScanObscured > 0) { + return LibVulpes.proxy.getLocalizedString("msg.observetory.scan.obscured") + + " " + lastScanObscured; + } if (lastScanDiscoveries > 0) { return LibVulpes.proxy.getLocalizedString("msg.observetory.scan.found") + " " + lastScanDiscoveries; @@ -939,8 +1076,16 @@ private void completeRegionScanIfDue() { extractData(cost, DataType.DISTANCE, EnumFacing.UP, true); } - lastScanDiscoveries += TelescopeScan.resolveBatch(UniverseRegistry.get(world), activeScan, - activeScan.cellsDone(), cells, crystal, now, TelescopeScan.dimensionNames()); + // The look is resolved FROM here, so a cloud standing between this instrument and what it is + // aimed at can cost the look its detail. Counted while we are at it: an operator whose + // survey came back with coordinates and no bodies must be told it was the dust, or the + // feature is indistinguishable from an instrument that found nothing. + GalacticCoord origin = scanOrigin(); + UniverseRegistry registry = UniverseRegistry.get(world); + lastScanObscured += countObscured(registry, origin, activeScan, activeScan.cellsDone(), cells); + lastScanDiscoveries += TelescopeScan.resolveBatch(registry, activeScan, + activeScan.cellsDone(), cells, crystal, now, TelescopeScan.dimensionNames(), origin, + characteriseWholeSystem); activeScan = instant ? activeScan.completed(now) : activeScan.advanced(now, cells); if (activeScan.isComplete()) { activeScan = null; @@ -998,6 +1143,9 @@ public void onInventoryButtonPressed(int buttonId) { if (buttonId == 8) { PacketHandler.sendToServer(new PacketMachine(this, PASSIVE_SWEEP)); } + if (buttonId == 9) { + PacketHandler.sendToServer(new PacketMachine(this, TOGGLE_WHOLE_SYSTEM)); + } } @@ -1054,7 +1202,7 @@ else if (id == PICK_DIRECTION || id == PICK_DISTANCE) { if (id == PICK_DIRECTION) { scanDirection = (scanDirectionIndex() + 1) % SCAN_DIRECTIONS.length; } else { - int reach = Math.max(1, ARConfiguration.getCurrentConfig().telescopeScanRangeSectors); + int reach = RegionScan.Tuning.fromConfig().maxRangeSteps(); scanDistance = Math.max(1, Math.min(reach, scanDistance + nbt.getInteger("d"))); } markDirty(); @@ -1063,6 +1211,14 @@ else if (id == PICK_DIRECTION || id == PICK_DISTANCE) { player.openGui(LibVulpes.instance, GuiHandler.guiId.MODULARNOINV.ordinal(), getWorld(), pos.getX(), pos.getY(), pos.getZ()); } + else if (id == TOGGLE_WHOLE_SYSTEM) { + characteriseWholeSystem = !characteriseWholeSystem; + markDirty(); + IBlockState st = world.getBlockState(pos); + world.notifyBlockUpdate(pos, st, st, 2); + player.openGui(LibVulpes.instance, GuiHandler.guiId.MODULARNOINV.ordinal(), + getWorld(), pos.getX(), pos.getY(), pos.getZ()); + } else if (id == START_SCAN || id == ABORT_SCAN || id == PASSIVE_SWEEP) { if (id == ABORT_SCAN) { abortRegionScan(); 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/BodyDerivationV0.java b/src/main/java/zmaster587/advancedRocketry/universe/BodyDerivationV0.java new file mode 100644 index 000000000..d4613e027 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/BodyDerivationV0.java @@ -0,0 +1,79 @@ +package zmaster587.advancedRocketry.universe; + +import zmaster587.advancedRocketry.api.dimension.solar.StellarBody; +import zmaster587.advancedRocketry.space.GalacticCoord; + +/** + * Schema version 0's body derivation — every law exactly as {@link PlanetDerivation} states it. + * + *

    A pure forwarder, and deliberately so: the arithmetic stays in one place, where its constants are + * documented next to the observations they come from, and this class is only the handle a schema holds + * it by. A version 2 is a second implementation of {@link IBodyDerivation}, not an edit here. + * + *

    Stateless, so one instance serves every world. + */ +public final class BodyDerivationV0 implements IBodyDerivation { + + public static final BodyDerivationV0 INSTANCE = new BodyDerivationV0(); + + private BodyDerivationV0() { + } + + @Override + public double metallicityOf(long seed, GalacticCoord anchor) { + return PlanetDerivation.metallicityOf(seed, anchor); + } + + @Override + public int referenceDistance(StellarBody star) { + return PlanetDerivation.referenceDistance(star); + } + + @Override + public int orbitalDistanceOf(long seed, GalacticCoord anchor, int index, int count, + StellarBody star) { + return PlanetDerivation.orbitalDistanceOf(seed, anchor, index, count, star); + } + + @Override + public double innerOrbit(StellarBody star) { + return PlanetDerivation.innerOrbit(star); + } + + @Override + public double outerOrbit(StellarBody star) { + return PlanetDerivation.outerOrbit(star); + } + + @Override + public int bareTemperature(StellarBody star, int orbitalDistance) { + return PlanetDerivation.bareTemperature(star, orbitalDistance); + } + + @Override + public boolean tidallyLockedAt(StellarBody star, int orbitalDistance) { + return PlanetDerivation.tidallyLockedAt(star, orbitalDistance); + } + + @Override + public boolean isGiantAt(long seed, GalacticCoord anchor, int index, int bareTemperatureK) { + return PlanetDerivation.isGiantAt(seed, anchor, index, bareTemperatureK); + } + + @Override + public BodyProfile derive(long seed, GalacticCoord anchor, GalacticCoord bodyCell, int variant, + StellarBody star, boolean moon, int orbitalDistance) { + return PlanetDerivation.derive(seed, anchor, bodyCell, variant, star, moon, orbitalDistance); + } + + @Override + public BodyProfile deriveRogue(long seed, GalacticCoord bodyCell, int variant, + double giantFraction) { + return PlanetDerivation.deriveRogue(seed, bodyCell, variant, giantFraction); + } + + @Override + public int residualTemperature(double massEarths, double radiusEarths) { + return PlanetDerivation.residualTemperature(massEarths, radiusEarths); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/universe/BodyEphemeris.java b/src/main/java/zmaster587/advancedRocketry/universe/BodyEphemeris.java index 08e4e4eca..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 @@ -72,6 +79,28 @@ 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; + } + + /** + * 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; @@ -84,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/BodyProfile.java b/src/main/java/zmaster587/advancedRocketry/universe/BodyProfile.java new file mode 100644 index 000000000..306d6daa4 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/BodyProfile.java @@ -0,0 +1,162 @@ +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; + 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, int rotationalPeriodTicks) { + 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; + this.rotationalPeriodTicks = Math.max(1, rotationalPeriodTicks); + } + + /** 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. */ + /** 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; + } + + /** 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/CellFrame.java b/src/main/java/zmaster587/advancedRocketry/universe/CellFrame.java index 7419b4788..142f44365 100644 --- a/src/main/java/zmaster587/advancedRocketry/universe/CellFrame.java +++ b/src/main/java/zmaster587/advancedRocketry/universe/CellFrame.java @@ -62,9 +62,14 @@ public boolean isStatic() { public void writeToNBT(NBTTagCompound nbt) { NBTTagCompound sub = new NBTTagCompound(); - sub.setLong("bx", base.x()); - sub.setLong("by", base.y()); - sub.setLong("bz", base.z()); + // The base is written as a sector triple plus an in-cell offset, for the same reason the type + // holds one: a single block absolute cannot express the coordinates the sector grid can name. + sub.setLong("bsx", base.sectorX()); + sub.setLong("bsy", base.sectorY()); + sub.setLong("bsz", base.sectorZ()); + sub.setLong("blx", base.localX()); + sub.setLong("bly", base.localY()); + sub.setLong("blz", base.localZ()); law.writeToNBT(sub); // nested sub-tag "ephemeris" nbt.setTag("frame", sub); } @@ -79,7 +84,9 @@ public static CellFrame readFromNBT(NBTTagCompound nbt, GalacticCoord name) { return staticAt(name); } NBTTagCompound sub = nbt.getCompoundTag("frame"); - return new CellFrame(AbsolutePos.of(sub.getLong("bx"), sub.getLong("by"), sub.getLong("bz")), + return new CellFrame(AbsolutePos.ofSectorLocal( + sub.getLong("bsx"), sub.getLong("bsy"), sub.getLong("bsz"), + sub.getLong("blx"), sub.getLong("bly"), sub.getLong("blz")), BodyEphemeris.readFromNBT(sub)); } 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..946c238e8 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/CellHash.java @@ -0,0 +1,64 @@ +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.

    + * + *

    Public because it belongs to every SCHEMA, not to one generator. A released world model is + * kept reproducible forever, and a later version that changes what it draws still has to draw it out of + * the same mixer — a second copy of this arithmetic in another package would be a second thing to keep + * in step, which is exactly what the paragraph above forbids.

    + */ +public final class CellHash { + + private CellHash() { + } + + /** Mix {@code seed}, the triple {@code (a,b,c)} and {@code salt} into a uniform 64-bit value. */ + public 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. */ + public 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.

    + */ + public 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)}. */ + public static double norm(long h) { + return (h >>> 11) * 0x1.0p-53; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/universe/ClusterField.java b/src/main/java/zmaster587/advancedRocketry/universe/ClusterField.java new file mode 100644 index 000000000..1348ea17c --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/ClusterField.java @@ -0,0 +1,220 @@ +package zmaster587.advancedRocketry.universe; + +import java.util.Optional; + +/** + * Where the star clusters are: the seat one level BELOW the star lattice, inside a galaxy. + * + *

    Space is partitioned into cluster cells {@code CLUSTER_SPACING_LY} across, measured in COARSE + * super-cells; at most one cluster per cell, seated with a margin of its own radius so it never + * straddles a face. That containment is what keeps "which cluster is this super-cell in" a single hash + * lookup with one answer, exactly as it does one and two levels up.

    + * + *

    Plus one cluster that is not on the lattice at all: every galaxy has a nucleus at its own + * centre. It is not a special case in the code either — it is a cluster of a different type, + * seated at a known place instead of a drawn one.

    + * + *

    A cluster only exists where its galaxy has stars: occupancy is scaled by the same density profile + * that placed the systems, so clusters thin out and stop where the galaxy does.

    + */ +public final class ClusterField { + + // Its own salt space again, clear of the galaxy tier's and of the generator's. + private static final long SALT_CLUSTER_OCC = 0x201L; + private static final long SALT_CLUSTER_TYPE = 0x202L; + private static final long SALT_CLUSTER_RADIUS = 0x203L; + private static final long SALT_CLUSTER_OX = 0x204L; + private static final long SALT_CLUSTER_OY = 0x205L; + private static final long SALT_CLUSTER_OZ = 0x206L; + private static final long SALT_NUCLEUS_RADIUS = 0x207L; + + private final GalaxyGenConfig config; + /** The metric this field measures with — its schema's, not a global one. */ + private final IUniverseLaws laws; + private final GalaxyField galaxies; + private final long spacingSuperCells; + + /** + * @param galaxies the tier above — what a cluster cell's occupancy is scaled by. A cluster inside a + * galaxy is scaled by that galaxy's profile; one out in the void is scaled by the + * ejecta halo, which is how a globular can be intergalactic without a second rule + */ + public ClusterField(GalaxyGenConfig config, GalaxyField galaxies, IUniverseLaws laws) { + this.laws = (laws == null) ? UniverseLawsV0.INSTANCE : laws; + this.config = (config == null) ? GalaxyGenConfig.defaults() : config; + this.galaxies = (galaxies == null) ? new GalaxyField(this.config, this.laws) : galaxies; + this.spacingSuperCells = Math.max(1L, + superCellsForLightYears(GalaxyGenConfig.CLUSTER_SPACING_LY, this.config.minSpacing)); + } + + /** + * The cluster this coarse super-cell belongs to, or empty when it is ordinary field. + * + *

    {@code galaxy} is the galaxy the super-cell is INSIDE, and it may be {@code null}: a cluster + * out in the intergalactic void is a real object — a globular thrown clear of the galaxy it + * formed around, still bound to itself. It used to be refused by construction here, on the + * reasoning that there would be no stars out there to gather; what that missed is that a cluster + * does not gather the field, it BRINGS its own. Its occupancy is scaled by the material at its own + * cell, which out there is the ejecta halo — so intergalactic globulars thin out with the void and + * cluster near the galaxies that threw them, on the same one function that places everything else.

    + * + *

    A galaxy-less cluster has no NUCLEUS, and that is not a special case either: a nucleus is the + * cluster at a galaxy's own centre, and there is no galaxy here to have one.

    + */ + public Optional clusterAt(long seed, Galaxy galaxy, long supX, long supY, long supZ) { + Optional nucleus = nucleusOf(seed, galaxy); + if (nucleus.isPresent() && nucleus.get().containsSuperCell(supX, supY, supZ)) { + return nucleus; + } + long cx = Math.floorDiv(supX, spacingSuperCells); + long cy = Math.floorDiv(supY, spacingSuperCells); + long cz = Math.floorDiv(supZ, spacingSuperCells); + Optional seated = clusterAtIndex(seed, galaxy, cx, cy, cz); + if (seated.isPresent() && seated.get().containsSuperCell(supX, supY, supZ)) { + return seated; + } + return Optional.empty(); + } + + /** The galactic nucleus: the richest cluster, at the galaxy's own centre. */ + public Optional nucleusOf(long seed, Galaxy galaxy) { + if (galaxy == null) { + return Optional.empty(); + } + // Keyed on the galaxy's own CENTRE, not on its lattice index: a cube holds a primary and its + // satellites, and they share that index — so keying on it would give every galaxy in a group the + // same nucleus, and a satellite's core would be sized by its primary's draw. + double u = CellHash.norm(CellHash.of(seed, galaxy.centre().sectorX(), + galaxy.centre().sectorY(), galaxy.centre().sectorZ(), SALT_NUCLEUS_RADIUS)); + GalaxyGenConfig.ClusterType type = GalaxyGenConfig.NUCLEUS; + double radiusLy = type.minRadiusLy + u * (type.maxRadiusLy - type.minRadiusLy); + long s = config.minSpacing; + return Optional.of(new StarCluster(type, nucleusSubdivisionFor(galaxy), + Math.floorDiv(galaxy.centre().sectorX(), s), + Math.floorDiv(galaxy.centre().sectorY(), s), + Math.floorDiv(galaxy.centre().sectorZ(), s), + superCellsForLightYears(radiusLy, config.minSpacing))); + } + + /** + * How finely a galaxy's NUCLEUS divides the lattice — scaled to the galaxy it is the centre of, + * never taken flat from the table. + * + *

    Every other cluster's contrast is measured against the FIELD, whose density is real and the same + * everywhere; a nucleus's is a statement about its own galaxy's POPULATION, so it cannot be one + * number. The table's {@code k} is the real figure for a reference-sized galaxy (10⁷× the field + * at 10¹¹ stars), and a galaxy's population goes as its radius cubed, so {@code k} goes as the + * radius: {@code k = k_ref · R / R_ref}. That holds the nucleus at a constant FRACTION of + * whatever it is the centre of.

    + * + *

    Measured, and the reason this is derived at all: the flat {@code k = 215} put ~4·10⁷ stars + * inside a 6-light-year core of a 921-light-year dwarf that holds ~10⁷ altogether — a nucleus + * four times its own galaxy. It is the same error the table's {@code k} was once held down to avoid, + * one level lower, and it became reachable the moment satellite galaxies made small galaxies common. + * A dwarf's nucleus comes out at {@code k = 4}, i.e. barely a concentration, which is what a real + * dwarf spheroidal has.

    + */ + private static int nucleusSubdivisionFor(Galaxy galaxy) { + double scaled = GalaxyGenConfig.NUCLEUS.subdivision + * galaxy.radiusLy() / UniverseScale.REFERENCE_GALAXY_RADIUS_LY; + return (int) Math.max(1L, Math.min(GalaxyGenConfig.NUCLEUS.subdivision, Math.round(scaled))); + } + + /** + * The cluster seated in cluster cell {@code (cx, cy, cz)}, or empty. + * + *

    Occupancy is scaled by the material at the cell, so clusters live where material lives: the + * galaxy's own profile inside one, and the ejecta halo outside — one function, not a second rule. + * {@code galaxy} may be {@code null} for a cluster cell out in the void.

    + */ + public Optional clusterAtIndex(long seed, Galaxy galaxy, long cx, long cy, long cz) { + long s = config.minSpacing; + // The cluster cell's centre, as a sector triple, so the profile is read at a fixed point. + long centreSuper = spacingSuperCells / 2L; + long sectorX = (cx * spacingSuperCells + centreSuper) * s; + long sectorY = (cy * spacingSuperCells + centreSuper) * s; + long sectorZ = (cz * spacingSuperCells + centreSuper) * s; + // Inside a galaxy the caller has already resolved which one, so read it directly rather than + // walking the cube again; out in the void there is nothing resolved and the halo is the answer. + double profile = galaxy != null + ? galaxy.densityAtSector(sectorX, sectorY, sectorZ) + : galaxies.materialAtSector(seed, sectorX, sectorY, sectorZ).total(); + if (!(profile > 0d)) { + return Optional.empty(); + } + if (CellHash.norm(CellHash.of(seed, cx, cy, cz, SALT_CLUSTER_OCC)) + >= Math.min(1d, GalaxyGenConfig.CLUSTER_DENSITY * profile)) { + return Optional.empty(); + } + + // Out in the void only a SELF-BOUND cluster is seated: an open cluster disperses in a few + // hundred million years and a molecular cloud never was bound, so neither survives the + // crossing it would have had to make to be out here. Expressed as a constraint on the DRAW + // rather than a clamp on its result, which is the same shape the satellite-size and + // authored-galaxy floors use. + GalaxyGenConfig.ClusterType type = pickType(CellHash.of(seed, cx, cy, cz, SALT_CLUSTER_TYPE), + galaxy == null); + if (type == null) { + return Optional.empty(); // no type qualifies — an honest answer, not an error + } + double radiusFraction = CellHash.norm(CellHash.of(seed, cx, cy, cz, SALT_CLUSTER_RADIUS)); + double radiusLy = type.minRadiusLy + radiusFraction * (type.maxRadiusLy - type.minRadiusLy); + long radius = superCellsForLightYears(radiusLy, config.minSpacing); + + // Seated with a margin of its own radius, so a cluster never straddles a cluster-cell face and + // the ownership question stays a single lookup. + long margin = Math.min(radius, Math.max(0L, (spacingSuperCells - 1L) / 2L)); + long band = Math.max(1L, spacingSuperCells - 2L * margin); + long ox = margin + Math.floorMod(CellHash.of(seed, cx, cy, cz, SALT_CLUSTER_OX), band); + long oy = margin + Math.floorMod(CellHash.of(seed, cx, cy, cz, SALT_CLUSTER_OY), band); + long oz = margin + Math.floorMod(CellHash.of(seed, cx, cy, cz, SALT_CLUSTER_OZ), band); + // The type's own k: an open cluster's and a globular's contrast is measured against the FIELD, + // whose density is real and uniform, so it needs no scaling to the galaxy it sits in. + return Optional.of(new StarCluster(type, type.subdivision, cx * spacingSuperCells + ox, + cy * spacingSuperCells + oy, cz * spacingSuperCells + oz, radius)); + } + + /** The cluster-lattice edge, in coarse super-cells. */ + public long spacingSuperCells() { + return spacingSuperCells; + } + + /** A length in light years as a whole number of coarse super-cells, at least one. */ + private long superCellsForLightYears(double lightYears, long superCellEdgeCells) { + long cells = laws.cellsForLightYears(lightYears); + return Math.max(1L, cells / Math.max(1L, superCellEdgeCells)); + } + + /** + * A weighted draw over the cluster table, or {@code null} when nothing qualifies. + * + * @param selfBoundOnly restrict to the types that survive outside a galaxy — the weights of the + * rest are then not merely skipped but EXCLUDED from the total, so the + * qualifying types keep their relative abundance instead of the draw falling + * through to whichever one happens to be last + */ + private GalaxyGenConfig.ClusterType pickType(long h, boolean selfBoundOnly) { + long total = 0L; + for (GalaxyGenConfig.ClusterType t : config.clusterTypes) { + if (!selfBoundOnly || t.selfBound) { + total += t.weight; + } + } + if (total <= 0L) { + return null; + } + long r = Math.floorMod(h, total); + GalaxyGenConfig.ClusterType last = null; + for (GalaxyGenConfig.ClusterType t : config.clusterTypes) { + if (selfBoundOnly && !t.selfBound) { + continue; + } + last = t; + if (r < t.weight) { + return t; + } + r -= t.weight; + } + return last; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/universe/ClusteredGalaxyGenerator.java b/src/main/java/zmaster587/advancedRocketry/universe/ClusteredGalaxyGenerator.java index 639a4e218..71896e1d5 100644 --- a/src/main/java/zmaster587/advancedRocketry/universe/ClusteredGalaxyGenerator.java +++ b/src/main/java/zmaster587/advancedRocketry/universe/ClusteredGalaxyGenerator.java @@ -3,13 +3,18 @@ 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.AbsolutePos; +import zmaster587.advancedRocketry.space.BlockDelta; import zmaster587.advancedRocketry.space.GalacticCoord; +import zmaster587.advancedRocketry.util.AstronomicalBodyHelper; /** * A deterministic, addon-default {@link IGalaxyGenerator} producing a CLUSTERED procedural galaxy @@ -19,23 +24,33 @@ *

    Every answer is a pure function of {@code (seed, cell)} — no state, no RNG — so a scan and a later jump * agree and a re-materialised cell regenerates identically. The scheme, all O(1) per query:

    *
      + *
    1. {@link GalaxyField} seats the GALAXIES — one per {@link GalaxyGenConfig#galaxySpacing}-cube, each + * with a centre, a type, a radius, an orientation and a density profile;
    2. *
    3. partition space into {@link GalaxyGenConfig#minSpacing}-cube super-cells — at most one system * each (the minimum-spacing guarantee);
    4. - *
    5. a coarse blob field grouped {@link GalaxyGenConfig#clusterScale} super-cells wide masks - * galaxy from void ({@link GalaxyGenConfig#voidFraction});
    6. - *
    7. inside a galaxy, a super-cell hosts a system with probability {@link GalaxyGenConfig#density}, seated - * at a hash-chosen cell within the super-cell.
    8. + *
    9. a super-cell hosts a system with probability {@link GalaxyGenConfig#density} scaled by the + * owning galaxy's profile at that point, seated at a hash-chosen cell within the super-cell. + * Outside every galaxy the profile is zero, so the intergalactic void is what the profile leaves + * empty rather than a second rule.
    10. *
    * - *

    A procedural system is a bare star (type/size sampled by weight from the seed) with a synthetic - * negative id — it is never in the catalogue and never a dimension, so the id cannot collide with a real - * star-id ({@code 0..N}) or a dim id. Planet CONTENT is a separate concern; this generator places stars only.

    + *

    The galaxy tier replaces an independent per-blob Bernoulli mask. Drawn per cell above the + * site-percolation threshold, that mask produced one unbounded sponge rather than galaxies: no centre, + * no radius, no orientation, and no answer to which galaxy a point was in.

    + * + *

    A procedural system is one or more stars (type and size sampled by weight from the seed) with + * synthetic negative ids — never in the catalogue and never a dimension, so an id cannot collide + * with a real star id ({@code 0..N}) or a dim id. About half of systems hold a companion, and a + * companion is a star in its own right: it has its own id, its own orbit about the primary, and its own + * cell, so a world can be bound to it and every world here is lit by all of them.

    */ public final class ClusteredGalaxyGenerator implements IGalaxyGenerator { - // Distinct salts so the independent hash draws (blob mask, occupancy, per-axis offset, star type/size/id) - // never correlate with each other. - private static final long SALT_BLOB = 0x1L; + // Distinct salts so the independent hash draws (occupancy, per-axis offset, star type/size/id) never + // correlate with each other. + // 0x1 was SALT_BLOB, the galaxy-vs-void blob mask. Retired: which galaxy a super-cell is in, and + // how dense that galaxy is there, is now GalaxyField's answer. The number stays burned so a future + // draw cannot silently inherit an old galaxy's stream. private static final long SALT_OCC = 0x2L; private static final long SALT_OX = 0x3L; private static final long SALT_OY = 0x4L; @@ -43,47 +58,238 @@ public final class ClusteredGalaxyGenerator implements IGalaxyGenerator { private static final long SALT_TYPE = 0x6L; private static final long SALT_SIZE = 0x7L; private static final long SALT_ID = 0x8L; + private static final long SALT_MULTIPLICITY = 0x9L; + private static final long SALT_COMPANION_COUNT = 0xAL; + private static final long SALT_COMPANION_TYPE = 0xBL; + private static final long SALT_COMPANION_SIZE = 0xCL; + private static final long SALT_COMPANION_SEP = 0xDL; + private static final long SALT_COMPANION_ANG = 0xEL; private static final long SYNTHETIC_ID_RANGE = 2_000_000_000L; // ids in [-2_000_000_000, -1] + // ─── Multiplicity ────────────────────────────────────────────────────────── + // Roughly half of real stars are not alone, and a system that can only ever be one star is a + // model that cannot express the commonest thing in the sky. Every number here is a balance knob; + // what is NOT a knob is that multiplicity belongs inside ONE system — a near-pair of lattice + // seats would be two unrelated systems with two names, two frames and no gravitational relation. + + /** Fraction of systems that hold more than one star. */ + private static final double MULTIPLE_FRACTION = 0.45d; + /** How many companions a multiple system holds, by falling probability: 1, then 2, then 3. */ + private static final double[] COMPANION_COUNT_WEIGHTS = {0.75d, 0.20d, 0.05d}; + /** + * Id slots reserved per system, so a primary and its companions can never collide with each + * other however the hash falls. A system's stars take consecutive ids inside its own slot. + */ + private static final int ID_SLOTS_PER_SYSTEM = 1 + COMPANION_COUNT_WEIGHTS.length; + + /** + * Separation band for a companion, in orbital-distance units — 0.01 AU to 2 000 AU, drawn + * log-uniformly, which is roughly how real separations are distributed over that range. + * + *

    The floor IS one cell's worth of orbit ({@link AstronomicalBodyHelper#MIN_ADDRESSABLE_ORBIT_UNITS}), + * so a companion always gets a cell of its own to be addressed by — derived rather than written + * down, because it was written down as {@code 1} and quietly stopped meaning "one cell" when the + * cell grew, at which point every tightest-band companion landed in the primary's cell, lost the + * seat race and was dropped. The ceiling is a quarter of the guaranteed clear space around a + * system, which is what lets that clear space state "no two unrelated stars come this close" + * without a binary ever being mistaken for one.

    + */ + private static final int COMPANION_MIN_SEPARATION = + AstronomicalBodyHelper.MIN_ADDRESSABLE_ORBIT_UNITS; + private static final int COMPANION_MAX_SEPARATION = 200_000; + /** + * A retinue cannot survive inside a companion's orbit, nor a companion inside the retinue's: a + * body between roughly a third of the separation and three times it is on an unstable orbit. So a + * separation drawn into the planets' band is pushed to whichever side of it is nearer, and the + * system comes out either circumbinary or widely separated — never impossible. + */ + private static final double STABILITY_FACTOR = 3d; + // Procedural in-system content (bodiesFor). All tunable. Per amendment A#1a each body gets its OWN cell // at a sector offset from the anchor (snapped to that cell's centre); the neighbourhood radius is bounded // 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; - /** Neighbourhood margin (cells) kept clear of the super-cell boundary. */ + // 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 UNBOUND draw: a second, independent roll on the same lattice cell, so a cube the star draw + // passed over may still hold something. Its own salts, so the two rolls cannot correlate — a shared + // stream would make "no star here" and "a rogue here" the same coin toss read twice. + private static final long SALT_ROGUE_OCC = 0x19L; + private static final long SALT_ROGUE_TYPE = 0x1AL; + private static final long SALT_ROGUE_ID = 0x1BL; + private static final long SALT_ROGUE_MOONCOUNT = 0x1CL; + private static final long SALT_ROGUE_MOONANG = 0x1DL; + private static final long SALT_ROGUE_MOONRAD = 0x1EL; + + // ─── 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. */ + /** + * How far a moon orbits, in PARENT RADII — the band real satellite systems occupy, and the only + * form of this number that survives a body having a size. + * + *

    It used to be an absolute length ({@code MOON_MIN_ORBIT}..{@code +MOON_ORBIT_SPAN} units of + * 200 blocks, i.e. 4 000–26 000 blocks) chosen when a planet had no radius at all. Once bodies got + * one, an Earth stood 25 513 blocks across and a Jupiter 280 643 — so essentially every moon was + * seated INSIDE its parent, and a giant's by an order of magnitude. A multiple cannot express that + * failure: 2.5 radii is outside the surface whatever the body turns out to be.

    + */ + private static final double MOON_MIN_PARENT_RADII = 2.5d; + private static final double MOON_MAX_PARENT_RADII = 12d; + + 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 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 static final org.apache.logging.log4j.Logger LOGGER = + org.apache.logging.log4j.LogManager.getLogger("AdvancedRocketry|Universe"); + + /** + * Hard ceiling on what one region query returns. Not a balance number: a nucleus divides each + * coarse cell fifteen thousand ways, so a box that looks small in super-cells can hold millions of + * systems and an unbounded enumeration would hang the caller. + */ + private static final int MAX_SYSTEMS_PER_REGION_QUERY = 20_000; + private final GalaxyGenConfig config; + private final IBodyDerivation derivation; + private final IUniverseLaws laws; + private final GalaxyField galaxies; + private final ClusterField clusters; + private final NebulaField nebulae; private final long totalStarWeight; + private final List rogueTypes; + private final long totalRogueWeight; + /** + * The stock generator: version 1's body derivation. Kept so every existing call site and test + * reads unchanged; a schema that means something else says so with the constructor below. + */ public ClusteredGalaxyGenerator(GalaxyGenConfig config) { + this(config, BodyDerivationV0.INSTANCE, UniverseLawsV0.INSTANCE); + } + + /** The same field with a stated derivation, measuring by version 1's laws. */ + public ClusteredGalaxyGenerator(GalaxyGenConfig config, IBodyDerivation derivation) { + this(config, derivation, UniverseLawsV0.INSTANCE); + } + + /** + * The full form: a field that derives its bodies by {@code derivation} and measures by + * {@code laws} — the two halves a later schema version differs in. + */ + public ClusteredGalaxyGenerator(GalaxyGenConfig config, IBodyDerivation derivation, + IUniverseLaws laws) { + this.derivation = (derivation == null) ? BodyDerivationV0.INSTANCE : derivation; + this.laws = (laws == null) ? UniverseLawsV0.INSTANCE : laws; this.config = (config == null) ? GalaxyGenConfig.defaults() : config; + this.galaxies = new GalaxyField(this.config, this.laws); + this.clusters = new ClusterField(this.config, this.galaxies, this.laws); + this.nebulae = new NebulaField(this.config, this.clusters, this.laws); long w = 0L; // accumulate in long so a few near-Integer.MAX weights cannot overflow the sum for (GalaxyGenConfig.StarType t : this.config.starTypes) { w += t.weight; } this.totalStarWeight = Math.max(1L, w); + this.rogueTypes = this.config.rogue.types; + long rw = 0L; + for (GalaxyGenConfig.RogueType t : this.rogueTypes) { + rw += t.weight; + } + this.totalRogueWeight = Math.max(1L, rw); + } + + @Override + public IBodyDerivation derivation() { + return derivation; + } + + @Override + public IUniverseLaws laws() { + return laws; + } + + @Override + public java.util.Optional tuning() { + return java.util.Optional.of(config); } public GalaxyGenConfig config() { return config; } + /** The galaxies this generator places its systems in — the tier above the star lattice. */ + public GalaxyField galaxies() { + return galaxies; + } + + /** The star clusters that refine the lattice — the tier below it. */ + public ClusterField clusters() { + return clusters; + } + + /** + * The clouds those clusters are wrapped in. Diffuse matter, so it names nothing and places + * nothing — it is what makes a cluster visible from outside, and the seam any later consequence + * of flying into one would be written against. + */ + public NebulaField nebulae() { + return nebulae; + } + @Override - public Optional systemAt(long seed, GalacticCoord coord) { - long sx = coord.sectorX(); - long sy = coord.sectorY(); - long sz = coord.sectorZ(); - long s = config.minSpacing; - Optional g = systemForSuperCell(seed, - Math.floorDiv(sx, s), Math.floorDiv(sy, s), Math.floorDiv(sz, s)); + public Optional systemAt(long seed, GalacticCoord coord) { + Optional g = systemForLattice(seed, + latticeAt(seed, coord.sectorX(), coord.sectorY(), coord.sectorZ())); if (g.isPresent() && g.get().cell.sameCell(coord)) { return Optional.of(g.get().system); } @@ -93,11 +299,17 @@ public Optional systemAt(long seed, GalacticCoord coord) { /** * {@inheritDoc} * - *

    Cost is O(super-cell volume of the box). Callers MUST pass a bounded region — a telescope scan is - * range-limited by config — not a galactic-scale box.

    + *

    Cost is O(super-cell volume of the box), times {@code k³} for any part of it inside a star + * cluster. Callers MUST pass a bounded region — a telescope scan is range-limited by config — not a + * galactic-scale box.

    + * + *

    The result is capped, and a cap that fires is LOGGED. A nucleus subdivides each coarse + * cell fifteen thousand ways, so a box that looks small in super-cells can hold millions of + * systems; silently returning the first few would read as "that is all there is", which is the one + * outcome worse than a slow scan.

    */ @Override - public Map systemsInRegion(long seed, GalacticCoord min, GalacticCoord max) { + public Map systemsInRegion(long seed, GalacticCoord min, GalacticCoord max) { long s = config.minSpacing; long loX = Math.min(min.sectorX(), max.sectorX()); long hiX = Math.max(min.sectorX(), max.sectorX()); @@ -106,23 +318,50 @@ public Map systemsInRegion(long seed, GalacticCoord m long loZ = Math.min(min.sectorZ(), max.sectorZ()); long hiZ = Math.max(min.sectorZ(), max.sectorZ()); - Map out = new HashMap<>(); - for (long supX = Math.floorDiv(loX, s); supX <= Math.floorDiv(hiX, s); supX++) { - for (long supY = Math.floorDiv(loY, s); supY <= Math.floorDiv(hiY, s); supY++) { - for (long supZ = Math.floorDiv(loZ, s); supZ <= Math.floorDiv(hiZ, s); supZ++) { - Optional g = systemForSuperCell(seed, supX, supY, supZ); - if (!g.isPresent()) { - continue; - } - GalacticCoord c = g.get().cell; - if (c.sectorX() >= loX && c.sectorX() <= hiX - && c.sectorY() >= loY && c.sectorY() <= hiY - && c.sectorZ() >= loZ && c.sectorZ() <= hiZ) { - out.put(c, g.get().system); + Map out = new HashMap<>(); + boolean capped = false; + for (long supX = Math.floorDiv(loX, s); supX <= Math.floorDiv(hiX, s) && !capped; supX++) { + for (long supY = Math.floorDiv(loY, s); supY <= Math.floorDiv(hiY, s) && !capped; supY++) { + for (long supZ = Math.floorDiv(loZ, s); supZ <= Math.floorDiv(hiZ, s) && !capped; supZ++) { + LocalField local = localFieldAt(seed, supX, supY, supZ); + int k = local.subdivision; + // Only the sub-cells the query box actually reaches. A system seated in a + // sub-cell is placed INSIDE it, so this is exactly the same answer as walking all + // k³ and filtering — and it is the difference between a bounded query and a + // 10⁷-cell walk, because a galactic nucleus divides one coarse cell that finely. + long iLo = subIndex(offsetInCoarse(loX, supX, s), s, k); + long iHi = subIndex(offsetInCoarse(hiX, supX, s), s, k); + long jLo = subIndex(offsetInCoarse(loY, supY, s), s, k); + long jHi = subIndex(offsetInCoarse(hiY, supY, s), s, k); + long mLo = subIndex(offsetInCoarse(loZ, supZ, s), s, k); + long mHi = subIndex(offsetInCoarse(hiZ, supZ, s), s, k); + for (long i = iLo; i <= iHi && !capped; i++) { + for (long j = jLo; j <= jHi && !capped; j++) { + for (long m = mLo; m <= mHi && !capped; m++) { + Optional g = systemForLattice(seed, + Lattice.of(supX, supY, supZ, i, j, m, k, s, local.ownField, + local.dilution(), local.material)); + if (!g.isPresent()) { + continue; + } + GalacticCoord c = g.get().cell; + if (c.sectorX() >= loX && c.sectorX() <= hiX + && c.sectorY() >= loY && c.sectorY() <= hiY + && c.sectorZ() >= loZ && c.sectorZ() <= hiZ) { + out.put(c, g.get().system); + capped = out.size() >= MAX_SYSTEMS_PER_REGION_QUERY; + } + } + } } } } } + if (capped) { + LOGGER.warn("systemsInRegion stopped at " + MAX_SYSTEMS_PER_REGION_QUERY + " systems for the" + + " box " + min.cellKey() + " .. " + max.cellKey() + "; there are more. This region" + + " crosses a dense star cluster - narrow the query."); + } return out; } @@ -134,54 +373,485 @@ public List bodiesFor(long seed, GalacticCoord systemCoord) { return Collections.emptyList(); } GalacticCoord cell = anchorOpt.get(); - Optional sys = systemAt(seed, cell); + Optional sys = systemAt(seed, cell); if (!sys.isPresent()) { return Collections.emptyList(); } - int starId = sys.get().starId(); + int systemId = sys.get().systemId(); + if (!sys.get().star().isPresent()) { + // A system whose primary is not a star: no companions, no zone, no orbits — the whole + // second half of the retinue law is about distances FROM a star. What it can still have is + // moons, so that is what it gets. + return rogueBodiesFor(seed, cell, systemId, config.rogue.giantFraction); + } + StellarBody star = sys.get().star().get(); 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, systemId)); - // 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.) - long s = config.minSpacing; - long maxRadiusCells = Math.max(1L, 3L * s / 8L - NEIGHBOURHOOD_MARGIN_CELLS); - double maxRadiusBlocks = (double) maxRadiusCells * GalacticCoord.CELL; - double minRadiusBlocks = GalacticCoord.CELL; + // 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. + // + // The room is the LOCAL lattice cell's, not the coarse one's. A system inside a star cluster + // sits on a finer lattice, so it has less of it and keeps fewer named bodies — which is the + // same rule as everywhere else, applied to the level it is defined on. + Lattice lattice = latticeAt(seed, cell.sectorX(), cell.sectorY(), cell.sectorZ()); + long s = lattice.minEdge(); + 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 + // 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()); + + // Every star of the system is a body in it. A companion that existed only on the StellarBody + // would light the worlds here and appear in no sky, on no chart and at no address — which is + // the shape of "expressible in storage, meaningless everywhere else" this whole seam removes. + // It is seated from ITS OWN elements, never from a fresh draw: the star object and the body + // that stands for it have to be the same statement, or one system holds a companion in two + // places at once. + for (StellarBody companion : star.getSubStars()) { + double periodTicks = AstronomicalBodyHelper.TICKS_PER_DAY + * AstronomicalBodyHelper.getOrbitalPeriod(companion.getOrbitalDistance(), + star.getMass()); + Seat seat = claimSeat(cell, lattice, taken, companion.getOrbitalDistance(), + companion.getBaseTheta(), 0d, periodTicks); + if (seat == null) { + continue; + } + bodies.add(new SystemBody(seat.cell, + CellFrame.of(AbsolutePos.ofCellName(cell.cellCentre()), seat.law), + BodyEphemeris.STATIC, SystemBodyKind.STAR, Constants.INVALID_PLANET, + companion.getId(), companion.getOrbitalDistance()) + .withRadius(AstronomicalBodyHelper.starRadiusEarths(companion))); + } - int count = 1 + (int) Math.floorMod( - hash(seed, cell.sectorX(), cell.sectorY(), cell.sectorZ(), SALT_BODYCOUNT), MAX_PROC_PLANETS); + appendRetinue(bodies, seed, cell, star, systemId, lattice, taken, outerBound, + retinueSize(seed, cell)); + return bodies; + } + + /** + * The bodies of a system anchored on a STARLESS world — the rogue itself, and whatever it kept. + * + *

    It is a short list on purpose. There is no belt, because an unbound world carries no disc: a + * belt is material that never accreted in a star's own gravity well, and this world left that well + * behind. There is no orbit and no zone, so nothing here is placed by distance from anything.

    + * + *

    Moons it may keep, and few. Whatever unbound a planet from its star pulled far harder + * on the loosely-held satellites than on the tight ones, so a rogue arrives out here with the + * inner few and nothing else — the same ceiling a rocky world has, applied whatever its bulk, + * rather than the ceiling its mass would otherwise buy it.

    + */ + private List rogueBodiesFor(long seed, GalacticCoord cell, int systemId, + double giantFraction) { + List bodies = new ArrayList<>(); + BodyProfile profile = derivation.deriveRogue(seed, cell, 0, giantFraction); + // It does not move inside its own system: it IS the system, so its frame is the anchor's. + bodies.add(SystemBody.fixedAt(cell, SystemBodyKind.ROGUE_PLANET, Constants.INVALID_PLANET, + systemId).withRadius(profile.radiusEarths())); + + double u = CellHash.norm(CellHash.ofCell(seed, cell, SALT_ROGUE_MOONCOUNT)); + int moons = (int) (Math.pow(u, MOON_COUNT_BIAS) * (MAX_MOONS_ROCKY + 1)); + if (moons > MAX_MOONS_ROCKY) { + moons = MAX_MOONS_ROCKY; + } + CellFrame frame = CellFrame.staticAt(cell); + for (int j = 1; j <= moons; j++) { + int moonOrbit = moonOrbitUnits(profile.radiusEarths(), + CellHash.norm(CellHash.ofBody(seed, cell, j, SALT_ROGUE_MOONRAD))); + double theta = CellHash.norm(CellHash.ofBody(seed, cell, j, SALT_ROGUE_MOONANG)) + * 2d * Math.PI; + double periodTicks = AstronomicalBodyHelper.TICKS_PER_DAY + * AstronomicalBodyHelper.getMoonOrbitalPeriod(moonOrbit, + (float) Math.max(0.05d, profile.massEarths())); + BodyEphemeris law = BodyEphemeris.orbit(moonOrbit, theta, 0d, false, periodTicks, + SystemContent.MOON_UNIT_BLOCKS); + // A moon of a rogue is starless too, so it is derived the same way its parent was, one + // variant along — never through the star-lit law with a star that is not there. + BodyProfile moonProfile = derivation.deriveRogue(seed, cell, j, giantFraction); + bodies.add(new SystemBody(cell, frame, law, SystemBodyKind.MOON, + Constants.INVALID_PLANET, systemId, SystemBody.ORBIT_UNKNOWN) + .withRadius(moonProfile.radiusEarths())); + } + return bodies; + } + + /** + * Append a system's RETINUE — its worlds, their moons and its belts — to {@code bodies}. + * + *

    Extracted so an AUTHORED system can have one too. The legacy random generator used to fill an + * authored star's system at world creation from {@code new Random(System.currentTimeMillis())}, + * which meant two saves of one seed differed and every fix had to be made twice, in two models + * that answered the same question differently. This is the one model, and an authored system now + * reaches it through {@link #authoredRetinueFor} with the pack's own body count as the bound.

    + * + * @param taken cells already claimed — an authored system passes the cells its authored worlds + * hold, so a derived body can never land on one + * @param count how many major bodies to attempt; the drawn orbits still decide how many FIT + */ + private void appendRetinue(List bodies, long seed, GalacticCoord cell, StellarBody star, + int starId, Lattice lattice, Set taken, double outerBound, + int count) { + int outermostOrbit = 0; + int innermostGiantOrbit = 0; 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); - 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); - // 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; + // 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 = derivation.orbitalDistanceOf(seed, cell, i, count, star); + if (orbit > outerBound) { + continue; // outside this system's clear space — a bound of the layout, not a failure + } + if (!orbitIsStableAmong(star.getSubStars(), orbit)) { + continue; // too near one of this system's other stars for any orbit to survive + } + Seat seat = seatBody(seed, cell, i, orbit, star, lattice, 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 = derivation.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. + 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, kind, Constants.INVALID_PLANET, starId)); + // The body carries its OWN size. Nothing downstream can recover it: a procedural world + // has no dimension until a descent mints one, and the render feed reaches a client with + // no registry to ask. + bodies.add(new SystemBody(seat.cell, bodyFrame, BodyEphemeris.STATIC, profile.kind(), + Constants.INVALID_PLANET, starId, orbit) + .withRadius(profile.radiusEarths())); + outermostOrbit = Math.max(outermostOrbit, orbit); + if (profile.kind() == SystemBodyKind.GAS_GIANT + && (innermostGiantOrbit == 0 || orbit < innermostGiantOrbit)) { + innermostGiantOrbit = orbit; + } + 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 + // 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, lattice, + 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. + // + // 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, + derivation.innerOrbit(star) * 2d); + addBelt(bodies, seed, cell, (int) Math.min(outerBelt, outerBound), star, lattice, starId, taken, + count + 2); + } + + /** + * The retinue an AUTHORED system gets: derived from {@code (seed, anchor)} like every other, and + * bounded by the pack's own {@code numPlanets} rather than by the generator's draw. + * + *

    What this preserves from the generator it replaces: a pack that asks for twelve worlds around + * its star still gets twelve. What it changes, deliberately: the worlds are the same two saves + * running from the same seed, because the clock is no longer an input.

    + * + * @param takenCells the cells the system's AUTHORED bodies already occupy, so nothing derived + * lands on one + */ + public List authoredRetinueFor(long seed, GalacticCoord anchor, StellarBody star, + int starId, int count, Set takenCells) { + List bodies = new ArrayList<>(); + if (star == null || anchor == null || count <= 0) { + return bodies; + } + GalacticCoord cell = anchor.cellCentre(); + Lattice lattice = latticeAt(seed, cell.sectorX(), cell.sectorY(), cell.sectorZ()); + Set taken = new HashSet<>(); + taken.add(cell.cellKey()); + if (takenCells != null) { + taken.addAll(takenCells); } + appendRetinue(bodies, seed, cell, star, starId, lattice, taken, + maxNamedOrbitUnits(lattice.minEdge()), count); 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)); + } + + /** + * 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 double maxNamedOrbitUnits(long s) { + long reachCells = Math.max(1L, laws.seatMarginCells(s) - NEIGHBOURHOOD_MARGIN_CELLS); + return Math.min(UniverseScale.MAX_NAMED_ORBIT_UNITS, + laws.orbitUnitsForCells(reachCells)); + } + + /** + * Claim a free cell for a body orbiting at {@code orbit}, or {@code null} when the neighbourhood has + * no room left. + * + *

    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 Seat seatBody(long seed, GalacticCoord anchor, int index, int orbit, + StellarBody star, Lattice lattice, Set taken) { + double baseAngle = CellHash.norm(CellHash.ofBody(seed, anchor, index, SALT_BODYANG)) * 2d * Math.PI; + // 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()); + return claimSeat(anchor, lattice, taken, orbit, baseAngle, phiDegrees, periodTicks); + } + + /** Walk the ring from {@code baseAngle} until a free cell turns up, or give up. */ + private static Seat claimSeat(GalacticCoord anchor, Lattice lattice, Set taken, int orbit, + double baseAngle, double phiDegrees, double periodTicks) { + for (int attempt = 0; attempt < NUDGE_ATTEMPTS; attempt++) { + 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 = clampIntoLattice( + anchor.plusLocal(at0.dx(), at0.dy(), at0.dz()).cellCentre(), lattice); + if (taken.add(addr.cellKey())) { + 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, Lattice lattice, int starId, Set taken, + int index) { + int clamped = Math.max(1, orbit); + Seat seat = seatBody(seed, anchor, index, clamped, star, lattice, 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)); + } + } + + /** + * 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.

    + */ + + /** + * A moon's orbit, in {@link SystemContent#MOON_UNIT_BLOCKS} units, drawn as a multiple of its + * PARENT's radius. + * + * @param parentRadiusEarths the parent's radius; a body with none stated falls back to one Earth, + * which is what an unstated bulk describes everywhere else in this layer + * @param u the draw, in [0, 1) + */ + private static int moonOrbitUnits(double parentRadiusEarths, double u) { + double radiusBlocks = Math.max(0.05d, parentRadiusEarths) * AstronomicalBodyHelper.EARTH_RADIUS_BLOCKS; + double factor = MOON_MIN_PARENT_RADII + u * (MOON_MAX_PARENT_RADII - MOON_MIN_PARENT_RADII); + long units = Math.round(radiusBlocks * factor / (double) SystemContent.MOON_UNIT_BLOCKS); + return (int) Math.max(1L, Math.min(Integer.MAX_VALUE, units)); + } + + private void addMoons(List bodies, long seed, GalacticCoord anchor, GalacticCoord parent, + 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)); + int moons = (int) (Math.pow(u, MOON_COUNT_BIAS) * (max + 1)); + if (moons > max) { + moons = max; + } + // 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 = moonOrbitUnits(parentProfile.radiusEarths(), + CellHash.norm(CellHash.ofBody(seed, parent, j, SALT_MOONRAD))); + double theta = CellHash.norm(CellHash.ofBody(seed, parent, j, SALT_MOONANG)) * 2d * Math.PI; + double periodTicks = AstronomicalBodyHelper.TICKS_PER_DAY + * AstronomicalBodyHelper.getMoonOrbitalPeriod(moonOrbit, (float) parentMass); + BodyEphemeris law = BodyEphemeris.orbit(moonOrbit, theta, 0d, false, periodTicks, + SystemContent.MOON_UNIT_BLOCKS); + // 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. + // A moon's size comes from the SAME derivation a descent will realize it with, so the + // moon a pilot sees from orbit is the moon he lands on. + BodyProfile moonProfile = derivation.derive(seed, anchor, parent, j, star, true, + parentOrbit); + bodies.add(new SystemBody(parent, parentFrame, law, SystemBodyKind.MOON, + Constants.INVALID_PLANET, starId, parentOrbit) + .withRadius(moonProfile.radiusEarths())); + } + } + + /** + * 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) { + if (star == null) { + // Nothing lights this system, so nothing about the body follows from a distance: it is the + // starless derivation or it is a body whose physics would be read off a star that is not + // there. A moon of a rogue takes the same branch, which is right — it is starless too. + return derivation.deriveRogue(seed, body.name(), variant, config.rogue.giantFraction); + } + return derivation.derive(seed, anchor.cellCentre(), body.name(), variant, star, + body.kind() == SystemBodyKind.MOON, body.orbitalDistance()); + } + + /** + * {@inheritDoc} + * + *

    Cost is the number of CLUSTER cells the reach crosses, not its volume in cells — the clouds + * are enumerated on the cluster lattice they are derived from. Outside a galaxy the answer is + * empty by construction: clusters are seated inside galaxies, and a cloud is a cluster's own gas.

    + */ + @Override + public List nebulaeAround(long seed, GalacticCoord cell, double radiusLy) { + if (cell == null || !(radiusLy > 0d)) { + return Collections.emptyList(); + } + GalacticCoord c = cell.cellCentre(); + // The galaxy the observer is INSIDE, so a cell in a satellite sees the satellite's clouds. + Optional galaxy = galaxies.galaxyContainingSector(seed, c.sectorX(), c.sectorY(), + c.sectorZ()); + if (!galaxy.isPresent()) { + return Collections.emptyList(); + } + long s = config.minSpacing; + long reachSuper = Math.max(1L, laws.cellsForLightYears(radiusLy) / s); + long supX = Math.floorDiv(c.sectorX(), s); + long supY = Math.floorDiv(c.sectorY(), s); + long supZ = Math.floorDiv(c.sectorZ(), s); + return nebulae.nebulaeInRegion(seed, galaxy.get(), supX - reachSuper, supY - reachSuper, + supZ - reachSuper, supX + reachSuper, supY + reachSuper, supZ + reachSuper); + } + + /** + * {@inheritDoc} + * + *

    The galaxy is resolved at the OBSERVER's end. Over the ranges a look spans — a survey's + * horizon is ~100 ly against a galaxy thousands across — both ends share one galaxy; a sight line + * that genuinely left one would be looking at another galaxy, which is a different feature.

    + */ + @Override + public double columnDensityBetween(long seed, GalacticCoord from, GalacticCoord to) { + if (from == null || to == null) { + return 0d; + } + GalacticCoord a = from.cellCentre(); + Optional galaxy = galaxies.galaxyContainingSector(seed, a.sectorX(), a.sectorY(), + a.sectorZ()); + return galaxy.isPresent() ? nebulae.columnDensityBetween(seed, galaxy.get(), from, to) : 0d; + } + @Override public Optional anchorAt(long seed, GalacticCoord cell) { + // The SEAT and not the system: this is the hottest question in the game — every address + // resolution, every descent check and every look of a survey goes through it — and it does + // not need to know what stands at the seat in order to say where the seat is. + return seatForLattice(seed, latticeAt(seed, cell.sectorX(), cell.sectorY(), cell.sectorZ())); + } + + @Override + public List anchorsInTerritory(long seed, GalacticCoord cell, int limit) { long s = config.minSpacing; - Optional g = systemForSuperCell(seed, - Math.floorDiv(cell.sectorX(), s), Math.floorDiv(cell.sectorY(), s), - Math.floorDiv(cell.sectorZ(), s)); - return g.isPresent() ? Optional.of(g.get().cell) : Optional.empty(); + long supX = Math.floorDiv(cell.sectorX(), s); + long supY = Math.floorDiv(cell.sectorY(), s); + long supZ = Math.floorDiv(cell.sectorZ(), s); + LocalField local = localFieldAt(seed, supX, supY, supZ); + int k = local.subdivision; + long seats = (long) k * k * k; + if (k <= 1 || seats > Math.max(1, limit)) { + // Either there is nothing to enumerate, or there is far too much: a cluster nucleus + // divides one territory thousands of ways, and a census of it is not a look through a + // telescope. Sampling it is what a survey has always done there, and it stays a find. + return IGalaxyGenerator.super.anchorsInTerritory(seed, cell, limit); + } + List anchors = new ArrayList<>(); + for (long i = 0; i < k; i++) { + for (long j = 0; j < k; j++) { + for (long m = 0; m < k; m++) { + seatForLattice(seed, Lattice.of(supX, supY, supZ, i, j, m, k, s, local.ownField, + local.dilution(), local.material)).ifPresent(anchors::add); + } + } + } + return anchors; } @Override @@ -189,70 +859,583 @@ public int minSpacingCells() { return config.minSpacing; } - /** Per-axis clamp of a body's cell into its anchor's super-cell box (margin when the box allows it). */ - private static GalacticCoord clampIntoSuperCell(GalacticCoord bodyCell, GalacticCoord anchor, long s) { - long margin = (s > 2L * NEIGHBOURHOOD_MARGIN_CELLS) ? NEIGHBOURHOOD_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); + @Override + public Optional declarationOriginOf(long seed, GalaxyKey key) { + return galaxies.declarationOriginOf(seed, key); + } + + @Override + public double guaranteedAuthoredReachLy() { + return UniverseScale.GUARANTEED_AUTHORED_REACH_LY; + } + + /** + * Per-axis clamp of a body's cell into its anchor's own LATTICE cell (margin when the box allows + * it), so a system's neighbourhood cannot reach into a neighbour's however far an orbit runs. + * + *

    Against the lattice cell rather than a spacing, because inside a star cluster the cell is a + * sub-cell whose bounds are not a multiple of its own edge — dividing to find the box would put + * the box somewhere else entirely.

    + */ + private static GalacticCoord clampIntoLattice(GalacticCoord bodyCell, Lattice lattice) { + long cx = clampAxis(bodyCell.sectorX(), lattice.lowX, lattice.edgeX); + long cy = clampAxis(bodyCell.sectorY(), lattice.lowY, lattice.edgeY); + long cz = clampAxis(bodyCell.sectorZ(), lattice.lowZ, lattice.edgeZ); if (cx == bodyCell.sectorX() && cy == bodyCell.sectorY() && cz == bodyCell.sectorZ()) { return bodyCell; } return GalacticCoord.ofSectorLocal(cx, cy, cz, 0L, 0L, 0L); } - private static long clampAxis(long sector, long anchorSector, long s, long margin) { - long sup = Math.floorDiv(anchorSector, s); - long lo = sup * s + margin; - long hi = sup * s + s - 1L - margin; + private static long clampAxis(long sector, long low, long edge) { + long margin = (edge > 2L * NEIGHBOURHOOD_MARGIN_CELLS) ? NEIGHBOURHOOD_MARGIN_CELLS : 0L; + long lo = low + margin; + long hi = low + edge - 1L - margin; if (sector < lo) { return lo; } 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); + /** + * WHERE a lattice cell's system sits, without working out what it is — the occupancy draws and + * the seat, and not one body, star or name. + * + *

    This is the difference between asking "is anything there" and "what is there", and it is + * the same split the survey is built on one layer up. Every draw below decides the seat by the + * cell's own hash, and none of them depends on what the system turns out to BE — a star and an + * unbound world seated in the same cube sit in the same place — so the answer here is exactly + * the cell {@link #systemForLattice} would report, at a fraction of the cost. Fabricating a + * system means drawing its type, its bulk and its companions, and a survey that fabricated one + * per seat it merely walked past spent nine tenths of its time on systems it then discarded.

    + */ + private Optional seatForLattice(long seed, Lattice lattice) { + double bound = Math.max(lattice.material.bound, lattice.ownField); + if (bound > 0d && CellHash.norm(lattice.hash(seed, SALT_OCC)) + < Math.min(1d, config.density * bound / lattice.dilution)) { + return Optional.of(seatIn(seed, lattice)); + } + double profile = Math.max(lattice.material.total(), lattice.ownField); + if (!(profile > 0d)) { + return Optional.empty(); + } + double occupancy = Math.min(1d, + config.density * config.rogue.abundance * profile / lattice.dilution); + if (CellHash.norm(lattice.hash(seed, SALT_ROGUE_OCC)) >= occupancy) { + return Optional.empty(); + } + return Optional.of(seatIn(seed, lattice)); } - /** 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)); - if (blob < config.voidFraction) { - return Optional.empty(); + /** The single system a lattice cell hosts (its cell coordinate + fabricated system), or empty. */ + private Optional systemForLattice(long seed, Lattice lattice) { + // OCCUPANCY IS DECIDED IN THE GALAXY'S OWN FRAME, so the profile does the drawing: the disc, + // the bulge and the arms place the stars. An independent per-cell draw could only ever produce + // a uniform fog, which is what made "which galaxy is this?" a question with no answer. + // + // Evaluated at the lattice cell's CENTRE — a point fixed by the partition, not by any draw, so + // the probability a cube is occupied cannot depend on where its seat would have landed. And + // evaluated at t = 0 and never again: a time-dependent occupancy would pop systems in and out + // of existence. Systems drift afterwards at their galaxy's own omega(r), which is the shear. + GalaxyField.Material material = lattice.material; + // A cluster out in the void supplies its own field, because k³ times the halo is still nothing + // and an intergalactic globular has to be a globular. Inside a galaxy ownField is zero and the + // profile speaks, so this is the same number it always was everywhere anything already exists. + double bound = Math.max(material.bound, lattice.ownField); + // Keyed by the cell's LOW CORNER, which is globally unique whatever lattice it belongs to — + // a coarse index would collide with a fine one wherever a cluster refines the field. + // ONE SUB-SEAT'S SHARE, not the territory's. Every territory is divided uniformly, so what + // is drawn here is its k-cubed-th part; summed back over the seats it is the same field at + // the same mean separation, and the only thing that has moved is the texture. + boolean star = bound > 0d + && CellHash.norm(lattice.hash(seed, SALT_OCC)) + < Math.min(1d, config.density * bound / lattice.dilution); + if (!star) { + // THE SECOND DRAW, on the cube the first one passed over. Stars need a galaxy to form in; + // an unbound world does not, so out in the void this is the only roll there is, and inside + // a galaxy it is what makes free-floating worlds as numerous as the sky says they are. + // + // It reads material.total(), which is the bound profile inside a galaxy and the ejecta halo + // outside it — so the void's population is what the galaxies have thrown out, on one + // continuous function, rather than a second rule with a density of its own. + return rogueForLattice(seed, lattice, Math.max(material.total(), lattice.ownField)); } - if (norm(hash(seed, supX, supY, supZ, SALT_OCC)) >= config.density) { + // 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 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. + // + // It is read off the LOCAL edge, so inside a cluster the floor shrinks with the lattice: stars + // in a globular core really do stand closer than a wide binary, and a system there loses outer + // bodies by the same rule that has always applied. + return Optional.of(new Generated(seatIn(seed, lattice), fabricate(seed, lattice))); + } + + /** + * What an UNBOUND seat holds, or empty when this cube holds nothing at all. + * + *

    A weighted draw over {@link GalaxyGenConfig#defaultRogueTypes()}, so relative abundance lives + * in a table exactly as it does for star types, galaxy types and cluster types. Two outcomes + * today: a starless world, which is what the void is mostly made of, and a whole STAR SYSTEM that + * was thrown out of its galaxy — rare enough that meeting one out here is an event.

    + * + *

    A rogue star is fabricated by {@link #fabricate}, unchanged, and it is not marked as anything: + * rogue-ness is a statement about WHERE a star stands and not about what it is, so a system out in + * the void is an ordinary system with an ordinary retinue, and the only thing that makes it a find + * is its address.

    + * + * @param profile the material at this cell — the galaxy's own where there is one, its ejecta where + * there is not + */ + private Optional rogueForLattice(long seed, Lattice lattice, double profile) { + if (!(profile > 0d)) { + return Optional.empty(); // a galaxy cell with no galaxy in it: the deepest void, and empty + } + // The whole point of the division: this number saturated at exactly 1.000000 before the + // territory was divided, and the measured abundance of 21 was indistinguishable from 3. + double occupancy = Math.min(1d, + config.density * config.rogue.abundance * profile / lattice.dilution); + if (CellHash.norm(lattice.hash(seed, SALT_ROGUE_OCC)) >= occupancy) { return Optional.empty(); } + GalaxyGenConfig.RogueType type = pickRogueType(lattice.hash(seed, SALT_ROGUE_TYPE)); + if (type.primaryKind == SystemBodyKind.STAR) { + return Optional.of(new Generated(seatIn(seed, lattice), fabricate(seed, lattice))); + } + return Optional.of(new Generated(seatIn(seed, lattice), fabricateRogue(seed, lattice))); + } + + /** + * Where this lattice cell's system sits: 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 systems 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 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.

    + * + *

    It is read off the LOCAL edge, so inside a cluster the floor shrinks with the lattice: stars + * in a globular core really do stand closer than a wide binary, and a system there loses outer + * bodies by the same rule that has always applied.

    + */ + private GalacticCoord seatIn(long seed, Lattice lattice) { + return GalacticCoord.ofSectorLocal( + lattice.lowX + seatOffset(seed, lattice, SALT_OX, lattice.edgeX), + lattice.lowY + seatOffset(seed, lattice, SALT_OY, lattice.edgeY), + lattice.lowZ + seatOffset(seed, lattice, SALT_OZ, lattice.edgeZ), 0L, 0L, 0L); + } + + /** + * A system anchored on a starless world. Its id comes from the same synthetic negative range a + * procedural star's does, through a stream of its own — the id space names systems and does not + * care what kind of thing stands at one. + */ + private static PlanetarySystem fabricateRogue(long seed, Lattice lattice) { + int id = syntheticId(seed, lattice.lowX, lattice.lowY, lattice.lowZ, SALT_ROGUE_ID); + return PlanetarySystem.ofRogue(id, + "PGR-" + lattice.lowX + "." + lattice.lowY + "." + lattice.lowZ); // rogue + } + + private GalaxyGenConfig.RogueType pickRogueType(long h) { + long r = Math.floorMod(h, totalRogueWeight); + GalaxyGenConfig.RogueType last = null; + for (GalaxyGenConfig.RogueType t : rogueTypes) { + last = t; + if (r < t.weight) { + return t; + } + r -= t.weight; + } + return last; // the table is never empty + } + + /** Where the seat sits on one axis of its lattice cell, clear of the faces by the local margin. */ + private long seatOffset(long seed, Lattice lattice, long salt, long edge) { + long margin = laws.seatMarginCells(edge); + long band = Math.max(1L, edge - 2L * margin); + return margin + Math.floorMod(lattice.hash(seed, salt), band); + } + + /** + * One cell of the star lattice: a coarse super-cell, or one of the {@code k³} sub-cells a star + * cluster divides it into. + * + *

    Its bounds are PROPORTIONED rather than divided, so the fine lattice tiles a coarse cell of + * any edge exactly — a plain {@code s / k} would leave a remainder at the top of every coarse + * cell, and a remainder is a seam.

    + */ + private static final class Lattice { + final long lowX; + final long lowY; + final long lowZ; + final long edgeX; + final long edgeY; + final long edgeZ; + + /** See {@link LocalField#ownField} — what a cluster out in the void brings with it. */ + final double ownField; + + /** + * What share of its territory's occupancy this cell draws for — {@link LocalField#dilution()}. + * + *

    It rides on the cell rather than being looked up at the draw, because the draw happens in + * {@code systemForLattice}, which is handed a cell and nothing else. A cell that did not know + * how finely its own territory was divided would have to ask the field again for a fact the + * partition already decided, and the two answers could differ at the clamp.

    + */ + final double dilution; + + /** The material of the TERRITORY this cell belongs to — see {@link LocalField#material}. */ + final GalaxyField.Material material; + + private Lattice(long lowX, long lowY, long lowZ, long edgeX, long edgeY, long edgeZ, + double ownField, double dilution, GalaxyField.Material material) { + this.lowX = lowX; + this.lowY = lowY; + this.lowZ = lowZ; + this.edgeX = edgeX; + this.edgeY = edgeY; + this.edgeZ = edgeZ; + this.ownField = ownField; + this.dilution = dilution; + this.material = material; + } + + /** Sub-cell {@code (i, j, m)} of coarse super-cell {@code (supX, supY, supZ)}, at {@code k}. */ + static Lattice of(long supX, long supY, long supZ, long i, long j, long m, int k, long s, + double ownField, double dilution, GalaxyField.Material material) { + long baseX = supX * s; + long baseY = supY * s; + long baseZ = supZ * s; + long loI = Math.floorDiv(i * s, (long) k); + long loJ = Math.floorDiv(j * s, (long) k); + long loM = Math.floorDiv(m * s, (long) k); + return new Lattice(baseX + loI, baseY + loJ, baseZ + loM, + Math.max(1L, Math.floorDiv((i + 1L) * s, (long) k) - loI), + Math.max(1L, Math.floorDiv((j + 1L) * s, (long) k) - loJ), + Math.max(1L, Math.floorDiv((m + 1L) * s, (long) k) - loM), ownField, dilution, + material); + } + + /** Its draw for one field, keyed by the low corner — globally unique at any subdivision. */ + long hash(long seed, long salt) { + return CellHash.of(seed, lowX, lowY, lowZ, salt); + } + + /** Whether {@code sector} lies inside this cell on the axis whose low/edge are given. */ + static boolean within(long sector, long low, long edge) { + return sector >= low && sector < low + edge; + } + + boolean contains(long sectorX, long sectorY, long sectorZ) { + return within(sectorX, lowX, edgeX) && within(sectorY, lowY, edgeY) + && within(sectorZ, lowZ, edgeZ); + } + + /** + * The smallest of its three edges — what a system's room is measured against. The edges differ + * by at most one cell, and taking the smallest is what keeps a neighbourhood inside its cell on + * every axis rather than on the average of them. + */ + long minEdge() { + return Math.min(edgeX, Math.min(edgeY, edgeZ)); + } + } + + /** + * What the star lattice looks like at one coarse super-cell: how finely it is divided, and what + * field it is divided AGAINST. + * + *

    Membership of a cluster is a property of the COARSE cell, which is what keeps this an O(1) + * question with one answer — and what makes the fine lattice tile the coarse cells it replaces + * exactly.

    + */ + private static final class LocalField { + + /** How finely the field is divided, all the way down: the UNIFORM division times a cluster's. */ + final int subdivision; + /** + * The uniform division ALONE — the {@code k} every territory is divided by whether or not a + * cluster covers it, and therefore the number both occupancies are diluted by. + * + *

    It is carried rather than recomputed because it can be CLAMPED: a coarse cell too small + * to divide keeps a coarser lattice, and diluting by a division that did not happen would + * empty the sky by a factor of twenty-seven. The dilution and the division are one decision, + * so they travel together.

    + */ + final int uniform; + /** + * The field a cluster BRINGS with it, or zero where the surrounding profile already speaks. + * + *

    A cluster's density is expressed as a contrast — {@code k³} times whatever is around it — + * and inside a galaxy that is exactly right, because what is around it is the real solar + * neighbourhood. Out in the void it is a contrast against nearly nothing, and {@code k³} times + * nearly nothing is still nothing: an intergalactic globular would be named, addressable and + * empty. A globular does not gather the field it sits in; it arrived carrying its own.

    + */ + final double ownField; + + /** + * The galaxy's material at this TERRITORY's centre — what decides how much of it is occupied. + * + *

    Read once per territory and shared by every sub-seat inside it, and that is a statement + * about the model rather than a saving. The profile it comes from varies on the scale of a + * galaxy's disc, thousands of light years; a territory is three. Sampling it per sub-seat + * asked a smooth function twenty-seven times for the same answer — and it cost a survey a + * factor of twenty-seven on the one path a player waits for. What the original comment on + * this draw actually required is that the sampling point be fixed by the PARTITION rather + * than by where a seat would have landed, and a territory's centre is exactly that.

    + */ + final GalaxyField.Material material; + + LocalField(int subdivision, int uniform, double ownField, GalaxyField.Material material) { + this.subdivision = subdivision; + this.uniform = uniform; + this.ownField = ownField; + this.material = material; + } + + /** What one sub-seat's share of the territory's occupancy is: {@code uniform^3}. */ + double dilution() { + return (double) uniform * uniform * uniform; + } + } + + /** + * The field a cluster outside every galaxy supplies, on {@link Galaxy#densityAt}'s scale. + * + *

    One, and derived rather than picked: that profile is normalised at the sun-like galactic + * radius, so {@code 1} IS the density of an ordinary stellar neighbourhood. A globular thrown clear + * of its galaxy therefore holds what a globular inside one holds, which is the whole content of + * "it brought its own stars".

    + */ + private static final double INTERGALACTIC_CLUSTER_FIELD = 1d; + + /** + * How finely EVERY star territory is divided, before any cluster refines it further — the uniform + * lattice a free-floating population needs to be counted on. + * + *

    Derived from the rogue abundance, because that is the quantity that could not be represented + * without it. An abundance is a NUMBER DENSITY: so many unbound worlds per star. Mapping it onto + * the star lattice as an occupancy PROBABILITY bounded it at one, so every abundance past + * {@code 1/density} = 2.86 was unrepresentable and the measured 21 saturated the lattice to + * exactly 1.000000 — 21 was indistinguishable from 3, and from 300. Dividing the territory + * {@code k = ceil(abundance^(1/3))} ways per axis gives {@code k^3} seats each holding + * {@code density*abundance/k^3}, and the number is legible again: 0.272 per sub-seat, a mean of + * 7.35 per territory at the shipped abundance.

    + * + *

    The division is uniform — stars included — and both occupancies are diluted by + * {@code k^3}. Dividing only the unbound draw would put up to {@code k^3} anchors in one + * territory, and member-cell attribution would stop being single-valued: two cells of the same + * territory would belong to two different systems, which is what {@code anchorForCell} and every + * address in the game are built on. Dividing everything keeps the invariant's sentence literal — + * one anchor per lattice cell — and leaves the star field's DENSITY untouched: the same + * {@code density} spread over {@code k^3} times as many seats is the same number of stars, at the + * same mean separation, on a finer texture. What it costs is the MINIMUM separation two stars can + * have, which falls from a territory to a sub-cell — and that removes a lattice artefact rather + * than a guarantee, because the floor that matters ({@link UniverseScale#SEPARATION_FLOOR_AU}) + * still has six times the room it needs.

    + * + *

    Capped at {@link #MAX_UNIFORM_SUBDIVISION}, which is what a single telescope look can still + * enumerate — see {@link TelescopeScan#MAX_SEATS_PER_LOOK}. Past that a survey would be back to + * sampling the field, and an abundance nothing can report is no better represented than one + * nothing can store.

    + */ + private int uniformSubdivision() { + double abundance = Math.max(1d, config.rogue.abundance); + long k = (long) Math.ceil(Math.cbrt(abundance)); + return (int) Math.max(1L, Math.min(MAX_UNIFORM_SUBDIVISION, k)); + } + + /** + * The finest uniform division of a star territory, and the reason it is this number and not + * another: {@code 4^3 = 64} is {@link TelescopeScan#MAX_SEATS_PER_LOOK}, the most seats one look + * of a survey will enumerate before it goes back to sampling. The two are the same bound seen + * from the placement side and from the observing side, and neither may move alone. + */ + private static final int MAX_UNIFORM_SUBDIVISION = 4; + + private LocalField localFieldAt(long seed, long supX, long supY, long supZ) { + long s = config.minSpacing; + // The CONTAINING galaxy: a cluster inside a satellite belongs to the satellite, and its nucleus + // sits at the satellite's own centre. Absent out in the void, where a cluster may still sit. + long centreX = supX * s + s / 2L; + long centreY = supY * s + s / 2L; + long centreZ = supZ * s + s / 2L; + Optional galaxy = galaxies.galaxyContainingSector(seed, centreX, centreY, centreZ); + GalaxyField.Material material = galaxies.materialAtSector(seed, centreX, centreY, centreZ); + Optional cluster = clusters.clusterAt(seed, galaxy.orElse(null), supX, supY, supZ); + // Neither a cluster nor the uniform division can conjure room the coarse cell never had. + // Refining below the smallest cell a system can be more than a lone star in would not make a + // dense field — it would make a field of bare stars, which is the opposite of the thing. A + // spacing too tight to refine is a degenerate galaxy rather than an error, exactly as too + // tight a spacing already is. + long ceiling = Math.max(1L, s / UniverseScale.MIN_LATTICE_EDGE_CELLS); + int uniform = (int) Math.max(1L, Math.min(uniformSubdivision(), ceiling)); + if (!cluster.isPresent()) { + return new LocalField(uniform, uniform, 0d, material); + } + // The cluster's contrast rides ON TOP of the uniform division: it wants k^3 times the + // density, and it gets it by owning k^3 times as many of the same-sized seats. Multiplying + // rather than replacing is what keeps its contrast the same number it always was. + long k = Math.max(1L, Math.min((long) uniform * cluster.get().subdivision(), ceiling)); + return new LocalField((int) k, uniform, + galaxy.isPresent() ? 0d : INTERGALACTIC_CLUSTER_FIELD, material); + } + + /** The lattice cell a sector triple falls in. */ + private Lattice latticeAt(long seed, long sectorX, long sectorY, long sectorZ) { 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; - 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); - 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)); + long supX = Math.floorDiv(sectorX, s); + long supY = Math.floorDiv(sectorY, s); + long supZ = Math.floorDiv(sectorZ, s); + LocalField local = localFieldAt(seed, supX, supY, supZ); + int k = local.subdivision; + if (k <= 1) { + return Lattice.of(supX, supY, supZ, 0L, 0L, 0L, 1, s, local.ownField, local.dilution(), + local.material); + } + return Lattice.of(supX, supY, supZ, + subIndex(Math.floorMod(sectorX, s), s, k), + subIndex(Math.floorMod(sectorY, s), s, k), + subIndex(Math.floorMod(sectorZ, s), s, k), k, s, local.ownField, local.dilution(), + local.material); + } + + /** + * Where a region bound sits inside coarse super-cell {@code sup}, as an offset clamped into it — + * so a bound lying outside the cell reads as its nearest face rather than as a sub-index off the + * end of the lattice. + */ + private static long offsetInCoarse(long sector, long sup, long coarseEdge) { + long offset = sector - sup * coarseEdge; + return Math.min(coarseEdge - 1L, Math.max(0L, offset)); + } + + /** Which sub-cell an offset inside a coarse cell falls in, on one axis. */ + private static long subIndex(long offsetInCoarse, long coarseEdge, int k) { + long index = Math.floorDiv(offsetInCoarse * (long) k, Math.max(1L, coarseEdge)); + return Math.min((long) k - 1L, Math.max(0L, index)); + } + + // galaxyProfileAt — the CONTAINING galaxy's density at a sector triple — moved into + // GalaxyField.materialAtSector, which answers it together with the ejecta halo out in the void. + // The two are one walk over the cube, and that walk runs once per lattice cell of every placement + // query, so leaving this here would have meant resolving the cube twice for every star in the game. + + private PlanetarySystem fabricate(long seed, Lattice lattice) { + long supX = lattice.lowX; + long supY = lattice.lowY; + long supZ = lattice.lowZ; + 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); star.setSize((float) (type.minSize + sizeFrac * (type.maxSize - type.minSize))); - star.setId(syntheticId(seed, supX, supY, supZ)); + int primaryId = syntheticId(seed, supX, supY, supZ, SALT_ID); + star.setId(primaryId); star.setName("PGS-" + supX + "." + supY + "." + supZ); // procedurally-generated system - return new StarSystem(star); + addCompanions(seed, supX, supY, supZ, star, primaryId); + return PlanetarySystem.ofStar(star); + } + + /** + * Give this system the stars it has beyond the first. + * + *

    The generator had never produced one: its own javadoc said "a procedural system is a bare + * star", so every procedural system in the galaxy was single while about half of real stars are + * not. The type layer could always express a hierarchy; what was missing was anything that drew + * one, and an id space in which a companion could be addressed at all.

    + * + *

    Ids come from the system's own reserved slot, so a primary and its companions cannot collide + * with each other whatever the hash does. A companion is never larger than its primary — the + * primary is by definition the star its system is named for.

    + */ + private void addCompanions(long seed, long supX, long supY, long supZ, StellarBody primary, + int primaryId) { + if (CellHash.norm(CellHash.of(seed, supX, supY, supZ, SALT_MULTIPLICITY)) >= MULTIPLE_FRACTION) { + return; + } + int count = drawCompanionCount(CellHash.norm( + CellHash.of(seed, supX, supY, supZ, SALT_COMPANION_COUNT))); + GalacticCoord key = cellOf(supX, supY, supZ); + for (int i = 1; i <= count; i++) { + GalaxyGenConfig.StarType type = pickType( + CellHash.ofBody(seed, key, i, SALT_COMPANION_TYPE)); + double sizeFrac = CellHash.norm(CellHash.ofBody(seed, key, i, SALT_COMPANION_SIZE)); + float size = (float) (type.minSize + sizeFrac * (type.maxSize - type.minSize)); + + StellarBody companion = new StellarBody(); + companion.setTemperature(type.temperature); + companion.setSize(Math.min(size, primary.getSize())); + companion.setId(primaryId - i); // the system's own reserved slot; see ID_SLOTS_PER_SYSTEM + companion.setName(primary.getName() + "-" + (char) ('B' + i - 1)); + companion.setOrbitalDistance(drawSeparation( + CellHash.norm(CellHash.ofBody(seed, key, i, SALT_COMPANION_SEP)))); + companion.setBaseTheta( + CellHash.norm(CellHash.ofBody(seed, key, i, SALT_COMPANION_ANG)) * 2d * Math.PI); + primary.addSubStar(companion); + } + } + + /** How many companions, from a falling distribution over {@link #COMPANION_COUNT_WEIGHTS}. */ + private static int drawCompanionCount(double u) { + double acc = 0d; + for (int i = 0; i < COMPANION_COUNT_WEIGHTS.length; i++) { + acc += COMPANION_COUNT_WEIGHTS[i]; + if (u < acc) { + return i + 1; + } + } + return COMPANION_COUNT_WEIGHTS.length; + } + + /** + * A companion's separation: log-uniform across the band, bounded by the room the system has. + * + *

    It depends on NOTHING but its own draw. That is deliberate and it is what makes the system + * buildable at all: a star's zone is a function of the system's luminosity, and the luminosity is + * a function of where its stars stand, so a separation chosen to avoid the zone would be chosen + * against a zone that its own choice then moved. Measured 2026-08-14: one such pass left a + * companion at 177 AU inside planets running out to 180 AU, because pushing the other two + * companions inward had brightened the system fivefold and widened the very band being avoided. + * The dependency runs one way instead — stars first, and the retinue accommodates them.

    + */ + private static int drawSeparation(double u) { + double separation = COMPANION_MIN_SEPARATION + * Math.pow((double) COMPANION_MAX_SEPARATION / COMPANION_MIN_SEPARATION, u); + return (int) Math.max(COMPANION_MIN_SEPARATION, + Math.min(UniverseScale.MAX_NAMED_ORBIT_UNITS, Math.round(separation))); + } + + /** + * Whether a planet at {@code orbit} could survive among these stars: not between roughly a third + * of a companion's separation and three times it, where neither a circumbinary nor a satellite + * orbit is stable. + */ + private static boolean orbitIsStableAmong(Iterable companions, int orbit) { + for (StellarBody companion : companions) { + double separation = companion.getOrbitalDistance(); + if (orbit > separation / STABILITY_FACTOR && orbit < separation * STABILITY_FACTOR) { + return false; + } + } + return true; + } + + /** The super-cell index triple as a coordinate, for the per-index hash draws. */ + private static GalacticCoord cellOf(long supX, long supY, long supZ) { + return GalacticCoord.ofSectorLocal(supX, supY, supZ, 0L, 0L, 0L); } private GalaxyGenConfig.StarType pickType(long h) { @@ -268,35 +1451,26 @@ private GalaxyGenConfig.StarType pickType(long h) { return last; // config.starTypes is never empty } - 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; + /** + * The primary's synthetic id: negative, so it can never collide with a catalogued star id + * ({@code 0..N}) or a dim id, and spaced {@link #ID_SLOTS_PER_SYSTEM} apart so a system's + * companions have ids of their own below it that belong to no other system. + * + * @param salt which population is being named. A rogue system draws from the SAME space as a star + * through a different stream, so its id is as distinct from a star's as two stars' ids + * are from each other — no more and no less + */ + private static int syntheticId(long seed, long supX, long supY, long supZ, long salt) { + long slot = Math.floorMod(CellHash.of(seed, supX, supY, supZ, salt), + SYNTHETIC_ID_RANGE / ID_SLOTS_PER_SYSTEM); + return -(1 + (int) (slot * ID_SLOTS_PER_SYSTEM)); } private static final class Generated { final GalacticCoord cell; - final StarSystem system; + final PlanetarySystem system; - Generated(GalacticCoord cell, StarSystem system) { + Generated(GalacticCoord cell, PlanetarySystem system) { this.cell = cell; this.system = system; } diff --git a/src/main/java/zmaster587/advancedRocketry/universe/ConeWalk.java b/src/main/java/zmaster587/advancedRocketry/universe/ConeWalk.java new file mode 100644 index 000000000..4581cd10d --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/ConeWalk.java @@ -0,0 +1,319 @@ +package zmaster587.advancedRocketry.universe; + +import net.minecraft.nbt.NBTTagCompound; + +import zmaster587.advancedRocketry.space.GalacticCoord; + +/** + * The patch of sky one pointing covers: a CONE with its apex at the instrument, a direction and a + * half-angle — enumerated as an indexed list of look points so a survey can be walked, paused and + * resumed. + * + *

    Why a cone and not a box. A box of coordinates has no observer: it has two corners and + * no idea where it is being looked at from. Everything awkward about surveying one — what a stride + * means, why a distant region is a different shape of work from a near one — comes from a shape that + * does not know where its viewer stands. A cone has an apex, so "how far along the sight line" and + * "how far off axis" are different questions with different answers, which is what an instrument + * actually distinguishes.

    + * + *

    The walk is shell by shell. Look points sit on a lattice of {@code stride} cells: one + * step along the axis per shell, and inside each shell a disc of the same spacing whose radius grows + * as {@code s·stride·tan(halfAngle)}. So a pointing is narrow near the instrument and wide far away, + * which is the whole geometric content of "a patch of sky" — the same angular patch subtends more + * space the farther out you read it.

    + * + *

    Indexed, not iterated. {@link #lookAt(int)} answers any index without walking the ones + * before it, because a survey outlives the chunk it started in: it stores how many looks it has done + * and resumes there. The per-shell counts are exact — a disc is counted as a disc and not as the + * square around it — so a survey's progress describes the sky it covers rather than the bookkeeping + * around it.

    + * + *

    Immutable. The NBT shape is a same-version save contract: a pointing outlives its chunk.

    + */ +public final class ConeWalk { + + private static final String KEY_APEX = "apex"; + private static final String KEY_DIR_X = "dx"; + private static final String KEY_DIR_Y = "dy"; + private static final String KEY_DIR_Z = "dz"; + private static final String KEY_HALF_ANGLE = "halfAngle"; + private static final String KEY_REACH = "reachCells"; + private static final String KEY_STRIDE = "stride"; + + /** + * The most shells one pointing may hold. + * + *

    A REPRESENTATION bound and not a balance one: every shell owns an entry in the prefix table + * this class builds at construction, so the table is what is being bounded. A million shells is + * already 4 MB of index for a survey whose look count passed what an {@code int} cursor can carry + * long before — the two limits are refused together, and the message says which.

    + */ + private static final int MAX_SHELLS = 1_000_000; + + private final GalacticCoord apex; + private final double dirX; + private final double dirY; + private final double dirZ; + private final double halfAngleRadians; + private final long reachCells; + private final long strideCells; + + /** The disc basis: two unit vectors across the axis, so a shell is enumerated in its own plane. */ + private final double uX; + private final double uY; + private final double uZ; + private final double vX; + private final double vY; + private final double vZ; + + /** {@code shellStart[s]} is the index of shell {@code s}'s first look; the last entry is the total. */ + private final int[] shellStart; + + private ConeWalk(GalacticCoord apex, double dirX, double dirY, double dirZ, + double halfAngleRadians, long reachCells, long strideCells) { + this.apex = apex; + double length = Math.sqrt(dirX * dirX + dirY * dirY + dirZ * dirZ); + this.dirX = dirX / length; + this.dirY = dirY / length; + this.dirZ = dirZ / length; + this.halfAngleRadians = halfAngleRadians; + this.reachCells = Math.max(0L, reachCells); + this.strideCells = Math.max(1L, strideCells); + + // A vector not parallel to the axis, chosen by which component of the axis is SMALLEST: any + // fixed helper is parallel to some axis, and the cross product with it degenerates exactly + // there. Picking the smallest component guarantees at least a 1/sqrt(3) separation. + double hx = 0d; + double hy = 0d; + double hz = 0d; + double ax = Math.abs(this.dirX); + double ay = Math.abs(this.dirY); + double az = Math.abs(this.dirZ); + if (ax <= ay && ax <= az) { + hx = 1d; + } else if (ay <= az) { + hy = 1d; + } else { + hz = 1d; + } + double cx = this.dirY * hz - this.dirZ * hy; + double cy = this.dirZ * hx - this.dirX * hz; + double cz = this.dirX * hy - this.dirY * hx; + double cl = Math.sqrt(cx * cx + cy * cy + cz * cz); + this.uX = cx / cl; + this.uY = cy / cl; + this.uZ = cz / cl; + this.vX = this.dirY * uZ - this.dirZ * uY; + this.vY = this.dirZ * uX - this.dirX * uZ; + this.vZ = this.dirX * uY - this.dirY * uX; + + this.shellStart = buildShells(); + } + + /** + * Aim a pointing from {@code apex} along {@code (dirX, dirY, dirZ)}. + * + * @param halfAngleRadians how wide the patch of sky is, from the axis to the edge + * @param reachCells how far the pointing carries — derived from what the instrument can + * SEE (see {@link StellarMagnitude#instrumentReachLightYears}), never a + * horizon of its own + * @param strideCells the spacing of the look lattice — one star's territory + * @throws IllegalArgumentException when there is no apex, no direction, or the pointing holds + * more looks than a survey cursor can index + */ + public static ConeWalk aimed(GalacticCoord apex, double dirX, double dirY, double dirZ, + double halfAngleRadians, long reachCells, long strideCells) { + if (apex == null) { + throw new IllegalArgumentException("a pointing needs an instrument to be aimed from"); + } + if (dirX * dirX + dirY * dirY + dirZ * dirZ <= 0d) { + throw new IllegalArgumentException("a pointing with no direction does not name a patch of sky"); + } + // Clamped rather than refused: an operator who asks for a hemisphere gets the widest patch the + // geometry can mean, and one who asks for zero gets the single sight line, which is a pointing + // with no width and still a pointing. + double half = Math.max(0d, Math.min(Math.PI / 2d - 1e-6d, halfAngleRadians)); + return new ConeWalk(apex.cellCentre(), dirX, dirY, dirZ, half, reachCells, strideCells); + } + + /** + * The prefix table of shell starts — and the place a pointing too large to walk is REFUSED. + * + *

    Refused and never clamped, for the reason a region survey already states: a walk cursor is an + * {@code int}, so a pointing with more looks than one can index would report itself complete with + * most of the sky untouched, and progress would read 100 % over a survey that never happened. + * Silence is the one outcome worse than a slow survey.

    + */ + private int[] buildShells() { + long shells = reachCells / strideCells; + if (shells > MAX_SHELLS) { + throw new IllegalArgumentException("a pointing of " + shells + " shells cannot be walked" + + " (at most " + MAX_SHELLS + "): the instrument reaches " + reachCells + + " cells at a stride of " + strideCells + ". Lower the limiting magnitude."); + } + int n = (int) Math.max(0L, shells); + int[] start = new int[n + 1]; + long total = 0L; + for (int s = 1; s <= n; s++) { + start[s - 1] = (int) total; + total += discLooks(radiusOfShell(s)); + if (total > Integer.MAX_VALUE) { + throw new IllegalArgumentException("a pointing of half-angle " + + String.format("%.3f", Math.toDegrees(halfAngleRadians)) + " degrees over " + + reachCells + " cells holds more looks than a survey can index." + + " Narrow the aperture or lower the limiting magnitude."); + } + } + start[n] = (int) total; + return start; + } + + /** The disc radius of shell {@code s}, in STRIDES — {@code s·tan(halfAngle)}, floored to the lattice. */ + private int radiusOfShell(int s) { + double radius = s * Math.tan(halfAngleRadians); + return (int) Math.max(0d, Math.min(Integer.MAX_VALUE, Math.floor(radius))); + } + + /** How many lattice points a disc of {@code radius} strides holds — counted row by row, exactly. */ + private static long discLooks(int radius) { + long count = 0L; + for (int i = -radius; i <= radius; i++) { + count += 2L * rowHalfWidth(radius, i) + 1L; + } + return count; + } + + /** Half the width of the disc's row at offset {@code i} — {@code floor(sqrt(r² − i²))}. */ + private static int rowHalfWidth(int radius, int i) { + long r2 = (long) radius * radius - (long) i * i; + return r2 <= 0L ? 0 : (int) Math.sqrt((double) r2); + } + + /** How many looks the whole pointing holds. Never a clamped count standing in for a real one. */ + public int totalLooks() { + return shellStart[shellStart.length - 1]; + } + + /** How many shells deep the pointing goes — one step of {@link #strideCells()} each. */ + public int shells() { + return shellStart.length - 1; + } + + public GalacticCoord apex() { + return apex; + } + + public double halfAngleRadians() { + return halfAngleRadians; + } + + /** How far the pointing carries, in cells — the instrument's reach, not a configured horizon. */ + public long reachCells() { + return reachCells; + } + + public long strideCells() { + return strideCells; + } + + /** The unit direction the instrument is aimed along. */ + public double dirX() { + return dirX; + } + + public double dirY() { + return dirY; + } + + public double dirZ() { + return dirZ; + } + + /** + * The cell the look at {@code index} lands on, in the pointing's own order: shell by shell + * outwards, and inside a shell row by row across the disc. + * + *

    Outwards first is not cosmetic. A survey resolves its looks in this order and may be aborted + * at any point, so what a half-finished pointing has covered is a SHORTER cone rather than a + * scatter — the operator has surveyed the near sky and not a random sample of the far.

    + */ + public GalacticCoord lookAt(int index) { + int shell = shellFor(index); + int radius = radiusOfShell(shell); + int offset = index - shellStart[shell - 1]; + // Walk the disc's rows to place the offset. At most 2r+1 steps, against a look that costs a + // lattice draw — the cost is in what the look RESOLVES, never in finding where it points. + int i = -radius; + while (i <= radius) { + int width = 2 * rowHalfWidth(radius, i) + 1; + if (offset < width) { + break; + } + offset -= width; + i++; + } + int j = offset - rowHalfWidth(radius, i); + + double axial = (double) shell * strideCells; + double across = (double) i * strideCells; + double along = (double) j * strideCells; + return GalacticCoord.ofSectorLocal( + apex.sectorX() + Math.round(dirX * axial + uX * across + vX * along), + apex.sectorY() + Math.round(dirY * axial + uY * across + vY * along), + apex.sectorZ() + Math.round(dirZ * axial + uZ * across + vZ * along), + 0L, 0L, 0L); + } + + /** Which shell an index falls in, by binary search over the prefix table. Shells are 1-based. */ + private int shellFor(int index) { + if (index < 0 || index >= totalLooks()) { + throw new IndexOutOfBoundsException("look " + index + " of " + totalLooks()); + } + int lo = 1; + int hi = shells(); + while (lo < hi) { + int mid = (lo + hi + 1) >>> 1; + if (shellStart[mid - 1] <= index) { + lo = mid; + } else { + hi = mid - 1; + } + } + return lo; + } + + /** How far out shell {@code shell} stands, in cells — what a look at that depth costs to resolve. */ + public long axialCellsOfShell(int shell) { + return (long) shell * strideCells; + } + + public void writeToNBT(NBTTagCompound nbt) { + NBTTagCompound at = new NBTTagCompound(); + apex.writeToNBT(at); + nbt.setTag(KEY_APEX, at); + nbt.setDouble(KEY_DIR_X, dirX); + nbt.setDouble(KEY_DIR_Y, dirY); + nbt.setDouble(KEY_DIR_Z, dirZ); + nbt.setDouble(KEY_HALF_ANGLE, halfAngleRadians); + nbt.setLong(KEY_REACH, reachCells); + nbt.setLong(KEY_STRIDE, strideCells); + } + + /** The pointing stored in {@code nbt}, or {@code null} when nothing was stored. */ + public static ConeWalk readFromNBT(NBTTagCompound nbt) { + if (nbt == null || !nbt.hasKey(KEY_APEX)) { + return null; + } + return new ConeWalk(GalacticCoord.readFromNBT(nbt.getCompoundTag(KEY_APEX)), + nbt.getDouble(KEY_DIR_X), nbt.getDouble(KEY_DIR_Y), nbt.getDouble(KEY_DIR_Z), + nbt.getDouble(KEY_HALF_ANGLE), nbt.getLong(KEY_REACH), nbt.getLong(KEY_STRIDE)); + } + + @Override + public String toString() { + return "ConeWalk[" + apex.cellKey() + " -> (" + String.format("%.3f", dirX) + ", " + + String.format("%.3f", dirY) + ", " + String.format("%.3f", dirZ) + "), " + + String.format("%.3f", Math.toDegrees(halfAngleRadians)) + " deg, " + + shells() + " shells, " + totalLooks() + " looks]"; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/universe/Cosmology.java b/src/main/java/zmaster587/advancedRocketry/universe/Cosmology.java new file mode 100644 index 000000000..57ee5b942 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/Cosmology.java @@ -0,0 +1,72 @@ +package zmaster587.advancedRocketry.universe; + +import zmaster587.advancedRocketry.util.AstronomicalBodyHelper; + +/** + * The one law above every galaxy: how much bigger the universe is at tick {@code t} than it was when + * the world was made. + * + *

    {@code a(0) = 1} by definition — {@code t = 0} is world creation, so the universe's age is the + * save's age. That is not an approximation to something else; there is no other clock the universe + * layer could be measured against.

    + * + *

    Expansion is MONOTONE, where rotation is not

    + *

    A shear-separated target comes back: two systems at different galactic radii drift apart and then + * together again, because {@code theta} wraps. An expansion-separated one does not. {@code a(t)} only + * ever grows, so a galaxy that recedes past a drive's reach has receded permanently — and that is a + * stronger claim about a player's world than "the sky moves slowly", which is why it is written down + * here rather than left implicit in a formula.

    + * + *

    Which clock

    + *

    Everything here is per TICK through the ORBITAL CALENDAR: a year is + * {@link AstronomicalBodyHelper#DAYS_PER_YEAR} days because that is the period of a one-AU orbit about + * a one-solar-mass star, and every other rate in this layer is quoted against the same year. Reading a + * tick as a twentieth of a REAL second instead would put a planet's year and a galaxy's recession on + * two different clocks, and the two would disagree by a factor of 548.

    + * + *

    Scale

    + *

    The galaxy lattice is compressed against reality (see {@link UniverseScale}), and the Hubble + * constant is NOT compressed with it — it is the real one. The consequence is deliberate and physical: + * at 75 000 light years apart, neighbouring galaxies recede at about 1.6 km/s while their own peculiar + * velocities run in the hundreds. So this universe behaves like a bound GROUP, where peculiar motion + * dominates and expansion is the slow background — which is exactly what a real galaxy group does.

    + */ +public final class Cosmology { + + /** The Hubble constant in km/s per megaparsec — the measured one, uncompressed. */ + public static final double HUBBLE_KM_S_PER_MEGAPARSEC = 70d; + + /** Light years in one megaparsec — what carries the Hubble constant into this layer's unit. */ + public static final double LIGHT_YEARS_PER_MEGAPARSEC = 3_261_563.777d; + + /** + * The fractional rate at which every intergalactic separation grows, per tick. Derived, never + * written as a literal: it is the Hubble constant expressed in this layer's length and this + * layer's clock. + */ + public static final double HUBBLE_PER_TICK = + UniverseScale.lightYearsPerTick(HUBBLE_KM_S_PER_MEGAPARSEC) / LIGHT_YEARS_PER_MEGAPARSEC; + + /** + * The horizon a galaxy's drift is BOUNDED against, in ticks — about 870 years of world time, or a + * couple of real years of continuous play. + * + *

    A galaxy that wandered out of its own lattice cell would break three things at once: + * at-most-one-galaxy-per-cell, non-overlap, and the O(1) answer to "which galaxy is this point in", + * which reads the containing cell and nothing else. So a drawn velocity is clamped to keep the + * galaxy inside its cell for at least this long. At realistic speeds the clamp is five orders away + * from binding, which is the point of measuring it rather than asserting it.

    + */ + public static final long DRIFT_HORIZON_TICKS = 1_000_000_000L; + + private Cosmology() { + } + + /** + * How much bigger the universe is at {@code tick} than at world creation. {@code a(0) = 1}, and it + * only ever grows. + */ + public static double scaleFactorAt(long tick) { + return Math.exp(HUBBLE_PER_TICK * (double) tick); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/universe/EmptyGalaxyGenerator.java b/src/main/java/zmaster587/advancedRocketry/universe/EmptyGalaxyGenerator.java index 174d7820a..f0d9896ea 100644 --- a/src/main/java/zmaster587/advancedRocketry/universe/EmptyGalaxyGenerator.java +++ b/src/main/java/zmaster587/advancedRocketry/universe/EmptyGalaxyGenerator.java @@ -15,12 +15,12 @@ public final class EmptyGalaxyGenerator implements IGalaxyGenerator { @Override - public Optional systemAt(long seed, GalacticCoord coord) { + public Optional systemAt(long seed, GalacticCoord coord) { return Optional.empty(); } @Override - public Map systemsInRegion(long seed, GalacticCoord min, GalacticCoord max) { + public Map systemsInRegion(long seed, GalacticCoord min, GalacticCoord max) { return Collections.emptyMap(); } } diff --git a/src/main/java/zmaster587/advancedRocketry/universe/Fingerprint.java b/src/main/java/zmaster587/advancedRocketry/universe/Fingerprint.java new file mode 100644 index 000000000..612c4be93 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/Fingerprint.java @@ -0,0 +1,46 @@ +package zmaster587.advancedRocketry.universe; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Locale; + +/** + * How this layer turns a set of numbers into a short identity a save can carry. + * + *

    One place, because two digests of the same kind computed two ways are two things to keep in step, + * and a stamp that disagrees with itself between builds is worse than no stamp. + * + *

    Stable across JVMs and versions by construction. No {@link Object#hashCode()} anywhere + * (identity hashes and even {@code String.hashCode} are not promised across implementations), doubles + * rendered through {@link Double#doubleToLongBits} rather than formatted (no locale, no rounding, and + * the last bit is visible), and every caller renders its lists in a declared order — order is part of + * an identity whenever a weighted table is walked by it. + */ +final class Fingerprint { + + private Fingerprint() { + } + + /** A double as its exact bits — the only rendering that neither rounds nor asks about a locale. */ + static String bits(double v) { + return Long.toHexString(Double.doubleToLongBits(v)); + } + + /** 16 lowercase hex of SHA-256 — short enough to read out of a log, long enough not to collide. */ + static String hex16(String canonical) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] out = md.digest(canonical.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(16); + for (int i = 0; i < 8; i++) { + hex.append(String.format(Locale.ROOT, "%02x", out[i])); + } + return hex.toString(); + } catch (NoSuchAlgorithmException e) { + // SHA-256 is mandated by the Java platform. If it is genuinely absent the stamp cannot be + // computed, and a silent fallback would be a value that compares equal against everything. + throw new IllegalStateException("SHA-256 unavailable, cannot fingerprint the universe", e); + } + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/universe/GalacticAnchor.java b/src/main/java/zmaster587/advancedRocketry/universe/GalacticAnchor.java new file mode 100644 index 000000000..03ae99949 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/GalacticAnchor.java @@ -0,0 +1,79 @@ +package zmaster587.advancedRocketry.universe; + +import java.util.Optional; + +import zmaster587.advancedRocketry.space.GalacticCoord; + +/** + * Where an AUTHORED system is declared to be: a {@link GalaxyKey} plus a cell offset from that + * galaxy's centre. + * + *

    This is deliberately not a position. It is resolved into an absolute cell name ONCE, at + * {@code t = 0} — the reference angle — after which the system is named by that cell exactly like + * every other, and rotates with its galaxy exactly like a procedural one. So nothing downstream gains + * a second kind of address, and a coordinate a player wrote down keeps meaning what it meant.

    + * + *

    Immutable value type.

    + */ +public final class GalacticAnchor { + + private final GalaxyKey galaxy; + private final GalacticCoord local; + + private GalacticAnchor(GalaxyKey galaxy, GalacticCoord local) { + this.galaxy = galaxy; + this.local = local; + } + + public static GalacticAnchor of(GalaxyKey galaxy, GalacticCoord local) { + return new GalacticAnchor(galaxy == null ? GalaxyKey.HOME : galaxy, + local == null ? GalacticCoord.ORIGIN : local); + } + + /** An anchor in the home galaxy — what an unqualified declaration means. */ + public static GalacticAnchor inHome(GalacticCoord local) { + return of(GalaxyKey.HOME, local); + } + + public GalaxyKey galaxy() { + return galaxy; + } + + /** The offset from the galaxy's centre, as a cell triple. */ + public GalacticCoord local() { + return local; + } + + /** + * The absolute cell this anchor denotes, given where its galaxy's centre actually is. + * + *

    {@code centre} empty means the running generator has no galaxies at all — an authored-only + * universe. There the declaration IS the absolute cell, which is both the old behaviour and the + * only reading that can be right: with nothing to be local to, local and absolute coincide.

    + */ + public GalacticCoord resolve(Optional centre) { + if (!centre.isPresent()) { + return local; + } + GalacticCoord c = centre.get(); + return GalacticCoord.ofSectorLocal(c.sectorX() + local.sectorX(), + c.sectorY() + local.sectorY(), c.sectorZ() + local.sectorZ(), 0L, 0L, 0L); + } + + /** + * How far out this anchor sits from its galaxy's centre, in light years — what the guaranteed + * minimum radius is checked against. + */ + public double reachLy() { + IUniverseLaws laws = UniverseRegistry.getGenerator().laws(); + double x = laws.lightYearsForCells(local.sectorX()); + double y = laws.lightYearsForCells(local.sectorY()); + double z = laws.lightYearsForCells(local.sectorZ()); + return Math.sqrt(x * x + y * y + z * z); + } + + @Override + public String toString() { + return "GalacticAnchor[" + galaxy + " + " + local.cellKey() + "]"; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/universe/GalacticFrame.java b/src/main/java/zmaster587/advancedRocketry/universe/GalacticFrame.java new file mode 100644 index 000000000..f561de109 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/GalacticFrame.java @@ -0,0 +1,35 @@ +package zmaster587.advancedRocketry.universe; + +/** + * Which law carries a position through time — the intergalactic regime, in two states and no more. + * + *

    There is no "nowhere". Every point belongs to exactly one galaxy CELL; a galaxy occupies a + * small sphere inside its cell and the rest of that cell is void. So no coordinate ever carries a null + * galaxy and no call site needs a branch for a point that is in no galaxy at all — only for a point + * that is in the void OF one.

    + * + *

    The two states are physically different, not a convenience: matter bound to a galaxy co-rotates + * with it and does not expand, while matter in the void is carried by the Hubble flow. A craft parked + * in the void stays put relative to the void while the galaxies recede from it.

    + * + *

    The frame is LATCHED at the crossing, never re-derived per tick

    + *

    The boundary is a threshold, so anything hovering on it would flip frame every tick — and the + * frame decides both rotation and expansion. {@link GalaxyField#frameAt} answers the question ONCE, at + * a crossing; a moving craft stores the answer alongside the cell binding it already stores. Every + * position-keyed defect this tree has logged has the same shape: a decision re-derived from a + * coordinate instead of held as identity.

    + */ +public enum GalacticFrame { + + /** + * Bound to a galaxy: the position is an offset from the galaxy's CENTRE, it turns with the disc at + * {@code ω(r)}, and it does not expand. + */ + GALACTIC, + + /** + * Out in the void: the position is an offset from the galaxy CELL's origin and it is comoving — + * it scales with {@code a(t)} and does not rotate. + */ + COMOVING +} diff --git a/src/main/java/zmaster587/advancedRocketry/universe/Galaxy.java b/src/main/java/zmaster587/advancedRocketry/universe/Galaxy.java new file mode 100644 index 000000000..c7270e35f --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/Galaxy.java @@ -0,0 +1,469 @@ +package zmaster587.advancedRocketry.universe; + +import zmaster587.advancedRocketry.space.GalacticCoord; + +/** + * One galaxy: a seated object with a centre, a type, a size, an orientation and a density profile. + * + *

    It is a VALUE, produced on demand from {@code (seed, galaxy cell)} and stored nowhere — exactly + * as a {@link PlanetarySystem} is. Nothing here is persisted and no coordinate carries a galaxy index; the + * index is {@code sector / galaxySpacing}, a derived grouping of the sector space that already + * exists (see {@link GalaxyField#galaxyIndex}).

    + * + *

    What a galaxy is FOR

    + *
      + *
    • It draws the star field. A super-cell hosts a system with a probability scaled by + * {@link #densityAt} at that point, so the disc, the bulge and the arms place the stars + * instead of an independent per-cell coin toss.
    • + *
    • It is the frame a bound thing rides. Inside the declared {@link #radiusLy radius} a + * position co-rotates at {@link #angularSpeedAt}; outside it, it does not.
    • + *
    + * + *

    The inside/outside test is the DECLARED RADIUS, never a level of the profile. A profile is + * continuous and has no boundary, so a frame decided by "is the density high enough here" would flip + * back and forth for anything hovering near the threshold. The radius is a sphere: the disc and its + * halo are both inside it, which is right — a halo is bound to its galaxy too.

    + * + *

    Rotation

    + *

    {@code θ(t) = θ₀ + ω(r)·t}, analytic in {@code t} and never integrated, so nothing accumulates + * drift — the argument {@link BodyEphemeris} already makes one level down. The curve is + * {@code v(r) = v∞ · r / √(r² + r_core²)}: solid-body near the centre, flat outside the core, and the + * type's {@link GalaxyGenConfig.GalaxyType#coreRadiusFraction} says where the turnover is, so a dwarf + * rotates almost rigidly (little shear) and a massive spiral shears strongly. Hence + * {@code ω(r) = v∞ / √(r² + r_core²)}, which is finite at the centre rather than singular.

    + * + *

    The rate is slow enough to be invisible inside one save, which is the ratified position: the + * mechanic exists even when slow, and the speed is tuning.

    + */ +public final class Galaxy { + + /** How far the exponential disc reaches, as a fraction of the radius. */ + private static final double DISC_SCALE_FRACTION = 1d / 3d; + /** How far the central bulge reaches, as a fraction of the radius. */ + private static final double BULGE_SCALE_FRACTION = 1d / 12d; + /** How strongly the arms modulate the disc: the density between arms against the density on one. */ + private static final double ARM_CONTRAST = 0.6d; + /** The centre is a singular point of the arm winding; inside this fraction the bulge speaks. */ + private static final double ARM_INNER_FRACTION = 1e-3d; + /** + * What the profile is divided by, so that a point ON AN ARM at the sun-like galactic radius scores + * 1. It is the disc term there, and it is scale-free — the exponentials are all in units of the + * radius, so this one number normalises a galaxy of any size. + */ + private static final double REFERENCE_LEVEL = + Math.exp(-UniverseScale.HOME_GALAXY_ORIGIN_FRACTION / DISC_SCALE_FRACTION); + + /** + * What {@link #densityAt} reads at a galaxy's own EDGE, in its plane. Derived from the profile + * rather than written down, and scale-free for the same reason every other length here is: the + * exponentials are in units of the radius, so this is one number for a galaxy of any size and of + * either profile. + * + *

    It is the anchor the {@linkplain #ejectaDensityAt ejecta halo} hangs from, which is what makes + * the void's population a statement about the galaxies that threw it out rather than a second + * field with its own normalisation.

    + */ + public static final double EDGE_LEVEL = Math.exp(-1d / DISC_SCALE_FRACTION) / REFERENCE_LEVEL; + + /** The metric this object was seated under — its schema's, never a global one. */ + private final IUniverseLaws laws; + private final long cellX; + private final long cellY; + private final long cellZ; + private final int satelliteIndex; + private final GalacticCoord centre; + private final LightYearVector seat; + private final LightYearVector peculiarVelocity; + private final GalaxyGenConfig.GalaxyType type; + private final double radiusLy; + private final double armPitch; + private final double armPhase; + + // The galaxy frame, precomputed: (u, v) span its plane and w is its pole. A position's cylindrical + // (r, theta, z) is read off these, so the profile below is written in the galaxy's own terms and + // the orientation is applied exactly once. + private final double ux; + private final double uy; + private final double uz; + private final double vx; + private final double vy; + private final double vz; + private final double wx; + private final double wy; + private final double wz; + + /** + * @param cellX the galaxy-lattice index this galaxy is seated in + * @param satelliteIndex {@code 0} for the cube's PRIMARY galaxy, {@code 1..n} for a satellite of + * it. A cube holds one primary and its retinue, so the lattice index alone no + * longer identifies a galaxy — this is what distinguishes them, in the name and + * in every draw made per galaxy rather than per cell + * @param centre its centre, as a cell name + * @param radiusLy its declared radius in light years — drawn inside {@code type}'s band + * @param tilt the angle its pole makes with the static +Y axis, in radians + * @param node the direction that pole leans in, in radians about +Y + * @param armPitch the arms' pitch angle in radians (ignored when the type has no arms) + * @param armPhase where arm zero starts, in radians + * @param peculiarVelocity its comoving velocity in light years per tick — its own motion through + * the expanding universe, on top of the expansion. A satellite carries its + * PRIMARY's, so a group travels together + */ + public Galaxy(long cellX, long cellY, long cellZ, int satelliteIndex, GalacticCoord centre, + GalaxyGenConfig.GalaxyType type, double radiusLy, double tilt, double node, + double armPitch, double armPhase, LightYearVector peculiarVelocity, + IUniverseLaws laws) { + this.laws = (laws == null) ? UniverseLawsV0.INSTANCE : laws; + this.cellX = cellX; + this.cellY = cellY; + this.cellZ = cellZ; + this.satelliteIndex = Math.max(0, satelliteIndex); + this.centre = centre; + this.seat = LightYearVector.ofCell(centre, this.laws); + this.peculiarVelocity = (peculiarVelocity == null) ? LightYearVector.ZERO : peculiarVelocity; + this.type = type; + this.radiusLy = Math.max(1d, radiusLy); + this.armPitch = armPitch; + this.armPhase = armPhase; + + double[] basis = basisOf(tilt, node); + this.ux = basis[0]; + this.uy = basis[1]; + this.uz = basis[2]; + this.vx = basis[3]; + this.vy = basis[4]; + this.vz = basis[5]; + this.wx = basis[6]; + this.wy = basis[7]; + this.wz = basis[8]; + } + + /** + * The orthonormal frame of a galaxy with this orientation: {@code u} and {@code v} span its plane, + * {@code w} is its pole. Laid out as {@code [ux,uy,uz, vx,vy,vz, wx,wy,wz]}. + */ + private static double[] basisOf(double tilt, double node) { + double st = Math.sin(tilt); + double ct = Math.cos(tilt); + double sn = Math.sin(node); + double cn = Math.cos(node); + return new double[] { + ct * cn, -st, ct * sn, + -sn, 0d, cn, + st * cn, ct, st * sn, + }; + } + + /** + * A unit vector lying IN the plane of a galaxy with this orientation, at in-plane angle + * {@code angle}. What a caller uses to put something at a stated galactic radius in the DISC, + * rather than somewhere in the halo above it. + */ + public static LightYearVector planeDirection(double tilt, double node, double angle) { + double[] b = basisOf(tilt, node); + double c = Math.cos(angle); + double s = Math.sin(angle); + return LightYearVector.of(c * b[0] + s * b[3], c * b[1] + s * b[4], c * b[2] + s * b[5]); + } + + public long cellX() { + return cellX; + } + + public long cellY() { + return cellY; + } + + public long cellZ() { + return cellZ; + } + + /** + * Where this galaxy's centre stands, as a cell NAME — the seat it was drawn at, at {@code t = 0}. + * A name is not a place: for where the centre actually is at a tick, see {@link #centreAt}. + */ + public GalacticCoord centre() { + return centre; + } + + /** Its comoving velocity, in light years per tick — its own motion, on top of the expansion. */ + public LightYearVector peculiarVelocity() { + return peculiarVelocity; + } + + public GalaxyGenConfig.GalaxyType type() { + return type; + } + + /** The declared radius in light years — the boundary, and the only boundary. */ + public double radiusLy() { + return radiusLy; + } + + /** The arms' pitch angle in radians; meaningless when the type has no arms. */ + public double armPitch() { + return armPitch; + } + + /** Where arm zero starts, in radians. */ + public double armPhase() { + return armPhase; + } + + /** + * {@code 0} for the cube's primary galaxy, {@code 1..n} for one of its satellites. A cube holds a + * primary AND its retinue, so this is the second half of a galaxy's identity. + */ + public int satelliteIndex() { + return satelliteIndex; + } + + /** Whether this galaxy is a satellite of the primary seated in the same cube. */ + public boolean isSatellite() { + return satelliteIndex > 0; + } + + /** + * This galaxy's designation — procedurally-generated galaxy, named for the cell it is seated in, + * and for its place in that cube's retinue when it is not the primary. + * + *

    The suffix is not decoration: a satellite is a destination with an address, and two galaxies in + * one cube sharing a name would be two places a player could neither tell apart nor write down.

    + */ + public String name() { + String cell = "PGG-" + cellX + "." + cellY + "." + cellZ; + return isSatellite() ? cell + "-S" + satelliteIndex : cell; + } + + // ─── Membership and profile ──────────────────────────────────────────────── + + /** Whether a point {@code (dx, dy, dz)} light years from the centre is inside this galaxy. */ + public boolean contains(double dxLy, double dyLy, double dzLy) { + return dxLy * dxLy + dyLy * dyLy + dzLy * dzLy <= radiusLy * radiusLy; + } + + /** Whether a cell named by this sector triple is inside this galaxy. */ + public boolean containsSector(long sectorX, long sectorY, long sectorZ) { + double dx = offsetLy(sectorX, centre.sectorX()); + double dy = offsetLy(sectorY, centre.sectorY()); + double dz = offsetLy(sectorZ, centre.sectorZ()); + return contains(dx, dy, dz); + } + + /** + * How dense this galaxy is at a point {@code (dx, dy, dz)} light years from its centre, relative to + * a SUN-LIKE spot in its disc: {@code 0} outside the radius, about {@code 1} out where the home + * galaxy puts the origin, and several times that in the nucleus. + * + *

    Normalised at the sun-like radius, not at the nucleus, and that choice is load-bearing. + * The mean star separation is the primary quantity of this whole layer and it is REAL — it is the + * separation in the solar neighbourhood. So the configured density has to mean "how full a sky + * like ours is"; normalising at the nucleus instead would have made every configured density a + * statement about the galactic core, and left the sky a player actually stands under five times + * too empty. The centre goes above 1 and is clamped where the probability is used, which is the + * honest place for a saturation.

    + * + *

    This is the ONE function that decides both where stars are placed and what shape a galaxy + * reads as. A disc is an exponential disc times an exponential in height, modulated by arms and + * added to a bulge; a spheroid is one isotropic exponential with the type's flattening applied to + * its pole.

    + */ + public double densityAt(double dxLy, double dyLy, double dzLy) { + if (!contains(dxLy, dyLy, dzLy)) { + return 0d; + } + // Into the galaxy's own frame: the plane it spans, and the height above it. + double localX = dxLy * ux + dyLy * uy + dzLy * uz; + double localY = dxLy * vx + dyLy * vy + dzLy * vz; + double z = dxLy * wx + dyLy * wy + dzLy * wz; + double r = Math.hypot(localX, localY); + + if (type.profile == GalaxyGenConfig.GalaxyProfile.SPHEROID) { + // Round, with the type's flattening squashing the pole. No plane, so no arms and no bulge + // term — the whole thing IS the bulge. + double scaled = Math.hypot(r, z / Math.max(1e-6d, type.scaleHeightRatio)); + return atLeastZero(Math.exp(-scaled / (radiusLy * DISC_SCALE_FRACTION)) / REFERENCE_LEVEL); + } + + double scaleHeight = Math.max(1e-6d, radiusLy * type.scaleHeightRatio); + double disc = Math.exp(-r / (radiusLy * DISC_SCALE_FRACTION)) + * Math.exp(-Math.abs(z) / scaleHeight); + disc *= armFactor(r, Math.atan2(localY, localX)); + double bulge = Math.exp(-Math.hypot(r, z) / (radiusLy * BULGE_SCALE_FRACTION)); + return atLeastZero((disc + bulge) / REFERENCE_LEVEL); + } + + /** The profile read at a cell name — the form the generator asks in. */ + public double densityAtSector(long sectorX, long sectorY, long sectorZ) { + return densityAt(offsetLy(sectorX, centre.sectorX()), + offsetLy(sectorY, centre.sectorY()), + offsetLy(sectorZ, centre.sectorZ())); + } + + /** + * How dense this galaxy's UNBOUND material is at a point {@code (dx, dy, dz)} light years from its + * centre — the planets and stars it has thrown out — on the same scale as {@link #densityAt}. + * + *

    Zero INSIDE the radius, and that is a division of labour rather than a claim that a galaxy + * ejects nothing into itself: inside its own sphere the bound profile is what says how much + * material is at a point, and adding a second term there would double-count the same stars.

    + * + *

    Outside, it falls as {@code (R/r)^falloff} from {@link #EDGE_LEVEL} — anchored at the edge, so a big + * galaxy fills far more of the void than a dwarf and neither needs a normalisation of its own. It + * is ISOTROPIC while the disc is not: ejection randomises a direction long before a body has + * crossed the void, so a spiral's poles are not a dead cone. The step at the radius is therefore + * real, and it is at the one surface this layer already declares as a boundary — the surface where + * the frame flips and where the star field stops dead.

    + */ + public double ejectaDensityAt(double dxLy, double dyLy, double dzLy, double falloff) { + double r = Math.sqrt(dxLy * dxLy + dyLy * dyLy + dzLy * dzLy); + if (r <= radiusLy) { + return 0d; + } + return EDGE_LEVEL * Math.pow(radiusLy / r, falloff); + } + + /** + * The ejecta halo read at a cell name — the form the generator asks in. + * + *

    The exponent is the CALLER's, out of {@code GalaxyGenConfig.RogueTuning}: a galaxy is a value + * drawn from a hash and knows nothing about how the universe is tuned, and giving it a config + * would make two galaxies of one seed differ by which config happened to draw them.

    + */ + public double ejectaDensityAtSector(long sectorX, long sectorY, long sectorZ, double falloff) { + return ejectaDensityAt(offsetLy(sectorX, centre.sectorX()), + offsetLy(sectorY, centre.sectorY()), + offsetLy(sectorZ, centre.sectorZ()), falloff); + } + + /** + * The arms' contribution as a multiplier in {@code (0, 1]}, normalised so a point ON an arm scores + * 1 and the disc between them is dimmer. A type with no arms scores 1 everywhere, so a smooth disc + * is the same code path with an empty term rather than a branch somewhere else. + */ + private double armFactor(double r, double theta) { + if (type.armCount <= 0) { + return 1d; + } + double tan = Math.tan(armPitch); + if (!(Math.abs(tan) > 1e-9d)) { + return 1d; // a degenerate pitch would wind the arms into a circle; leave the disc smooth + } + double rArm = Math.max(r, radiusLy * ARM_INNER_FRACTION); + double wind = Math.log(rArm / radiusLy) / tan; + double phase = type.armCount * (theta - armPhase - wind); + return (1d + ARM_CONTRAST * Math.cos(phase)) / (1d + ARM_CONTRAST); + } + + // ─── Rotation ────────────────────────────────────────────────────────────── + + /** + * The angular speed at galaxy-local radius {@code rLy}, in radians per tick — the SHEAR that makes + * a galaxy a place that moves rather than a fixed backdrop. + * + *

    Signed: the sign is the galaxy's spin direction about its own pole, and it is the same + * everywhere in one galaxy. Positive always here; the pole's direction is what distinguishes two + * galaxies spinning opposite ways, and that is carried by the orientation.

    + */ + public double angularSpeedAt(double rLy) { + double core = radiusLy * type.coreRadiusFraction; + double speed = laws.lightYearsPerTick(type.rotationSpeedKmS); + return speed / Math.hypot(Math.max(0d, rLy), core); + } + + /** + * Where something that started at {@code theta0} and sits at radius {@code rLy} has got to by tick + * {@code tick}. Evaluated, never integrated. + */ + public double thetaAt(double theta0, double rLy, long tick) { + return theta0 + angularSpeedAt(rLy) * (double) tick; + } + + /** How long one turn at radius {@code rLy} takes, in ticks. Diagnostics and tests read this. */ + public double rotationPeriodTicks(double rLy) { + double omega = angularSpeedAt(rLy); + return omega > 0d ? 2d * Math.PI / omega : Double.POSITIVE_INFINITY; + } + + // ─── Where the galaxy itself is ──────────────────────────────────────────── + + /** + * Where this galaxy's centre stands at tick {@code t}: {@code C(t) = a(t) · (C₀ + v·t)}. + * + *

    Expansion carries the centre and nothing inside the galaxy. A gravitationally bound + * system does not expand, and scaling intra-galactic coordinates would grow every {@code r} and + * corrupt {@code ω(r)} from within — so the split is structural rather than a rule someone has to + * remember: everything below is written as an offset from this point.

    + * + *

    Expansion alone would let a galaxy only ever RECEDE, which makes an approaching neighbour + * unrepresentable — and in a real group at short range peculiar motion dominates expansion. Hence + * the velocity term, one hash draw, still analytic, still nothing integrated.

    + */ + public LightYearVector centreAt(long tick) { + return seat.plus(peculiarVelocity.scale((double) tick)) + .scale(laws.scaleFactorAt(tick)); + } + + /** + * Where a point BOUND to this galaxy stands at tick {@code t}, absolutely. + * + *

    It rides the galaxy: it turns with the disc at {@code ω(r)} and it does not expand. The + * arguments are its galaxy-local cylindrical elements at {@code t = 0}, which are what a bound + * thing actually has — a radius, an angle and a height, exactly as a planet has an orbit.

    + */ + public LightYearVector boundPositionAt(long tick, double rLy, double theta0, double heightLy) { + double theta = thetaAt(theta0, rLy, tick); + double localX = rLy * Math.cos(theta); + double localY = rLy * Math.sin(theta); + // Back out of the galaxy frame: the basis is orthonormal, so the inverse is its transpose. + return centreAt(tick).plus(LightYearVector.of( + localX * ux + localY * vx + heightLy * wx, + localX * uy + localY * vy + heightLy * wy, + localX * uz + localY * vz + heightLy * wz)); + } + + /** The galaxy-local radius of a static-frame offset from the centre, in light years. */ + public double localRadius(double dxLy, double dyLy, double dzLy) { + return Math.hypot(dxLy * ux + dyLy * uy + dzLy * uz, dxLy * vx + dyLy * vy + dzLy * vz); + } + + /** The galaxy-local angle of a static-frame offset from the centre, in radians. */ + public double localTheta(double dxLy, double dyLy, double dzLy) { + return Math.atan2(dxLy * vx + dyLy * vy + dzLy * vz, dxLy * ux + dyLy * uy + dzLy * uz); + } + + /** The height of a static-frame offset above this galaxy's plane, in light years. */ + public double localHeight(double dxLy, double dyLy, double dzLy) { + return dxLy * wx + dyLy * wy + dzLy * wz; + } + + /** + * Where the cell named {@code cell} stands at tick {@code t}, IF it is bound to this galaxy. + * + *

    Its elements are read once, off its offset from the seat at {@code t = 0} — that is what a + * cell NAME means here, and it is why a name stays put while the place it names moves.

    + */ + public LightYearVector boundPositionOfCellAt(GalacticCoord cell, long tick) { + double dx = offsetLy(cell.sectorX(), centre.sectorX()); + double dy = offsetLy(cell.sectorY(), centre.sectorY()); + double dz = offsetLy(cell.sectorZ(), centre.sectorZ()); + return boundPositionAt(tick, localRadius(dx, dy, dz), localTheta(dx, dy, dz), + localHeight(dx, dy, dz)); + } + + // ─── Helpers ─────────────────────────────────────────────────────────────── + + /** A sector delta as a length in light years. Exact: the delta is bounded by one galaxy cell. */ + private double offsetLy(long sector, long centreSector) { + return laws.lightYearsForCells((double) (sector - centreSector)); + } + + private static double atLeastZero(double v) { + return v > 0d ? v : 0d; + } + + @Override + public String toString() { + return "Galaxy[" + name() + " " + type.name + " r=" + (long) radiusLy + "ly centre=" + + centre.cellKey() + "]"; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/universe/GalaxyField.java b/src/main/java/zmaster587/advancedRocketry/universe/GalaxyField.java new file mode 100644 index 000000000..5503f9a0f --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/GalaxyField.java @@ -0,0 +1,715 @@ +package zmaster587.advancedRocketry.universe; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import zmaster587.advancedRocketry.space.GalacticCoord; + +/** + * Where the galaxies are: the lattice one level above the star lattice, and the same scheme. + * + *

    Space is partitioned into {@code galaxySpacing}-cube galaxy cells; a cell holds at most + * one galaxy, seated at a hash offset inside it, with every parameter — type, radius, orientation, + * arms — drawn from {@code hash(seed, gx, gy, gz)}. Nothing is stored. A galaxy is a value produced + * by this class exactly as a system is produced by {@link ClusteredGalaxyGenerator}.

    + * + *

    The galaxy index is DERIVED, not an addressing tier

    + *

    {@link #galaxyIndex} is {@code sector / galaxySpacing} — a coarse reading of the sector space + * that already exists. No coordinate gains a field, nothing is persisted, and a distance is still a + * distance. That is what makes "which galaxy is this?" an O(1) question with an answer, where an + * independent per-cell mask left it undefined.

    + * + *

    Every point is in a galaxy CELL; only some are in a GALAXY

    + *

    There is no "nowhere". A cell either holds a galaxy or is entirely void, and inside a cell that + * holds one, a point is inside a galaxy iff it is within some declared radius. Those are different + * questions with different methods here — {@link #galaxyOwning} names the cube's PRIMARY, + * {@link Galaxy#containsSector} says whether you are in one named galaxy, and + * {@link #galaxyContainingSector} answers which of the cube's galaxies you are actually in.

    + * + *

    A cube holds a primary AND its retinue

    + *

    The lattice seats one galaxy per cube 25 diameters wide, so on the lattice alone the nearest + * galaxy is always 25 diameters off — which is the distance to the nearest equal GIANT, not to the + * nearest galaxy of any kind. A real giant keeps company at one to three diameters. So a galaxy draws + * {@link #satellitesOf satellites} as CHILDREN inside its own cube, the way a system draws moons inside + * its primary's cell. Nothing about the representation moves: the cube keeps its size, no + * coordinate gains a field, and a satellite is a {@link Galaxy} value drawn from {@code (seed, cell, + * ordinal)} and stored nowhere. What moves is only that a cube's galaxies now have to be told apart — + * hence {@link Galaxy#satelliteIndex()} and the {@code -Sn} suffix in its name.

    + * + *

    The home galaxy

    + *

    Galaxy cell {@code (0,0,0)} is RESERVED: it always holds a galaxy, seated so that the universe + * ORIGIN falls at a sun-like radius inside its disc, and drawn only among types large enough to hold + * authored content. A galaxy is otherwise a hash draw and may simply not be there under another seed — + * but authored content must exist under EVERY seed, and a hand-picked absolute coordinate would + * otherwise land in intergalactic space with probability 99.997 %.

    + * + *

    Around the origin, not ON it. The centre of a galaxy is its nucleus, which is the last + * address a shipped solar system should have. Only the galaxy's EXISTENCE and the origin's place + * inside it are fixed; its type, size, orientation and arms are drawn like any other galaxy's, so + * every world's home galaxy is still its own.

    + */ +public final class GalaxyField { + + // A salt space of its own, well clear of the generator's, so a galaxy draw and a star draw over + // the same integer triple can never be the same number. + private static final long SALT_GALAXY_OCC = 0x101L; + private static final long SALT_GALAXY_TYPE = 0x102L; + private static final long SALT_GALAXY_RADIUS = 0x103L; + private static final long SALT_GALAXY_OX = 0x104L; + private static final long SALT_GALAXY_OY = 0x105L; + private static final long SALT_GALAXY_OZ = 0x106L; + private static final long SALT_GALAXY_TILT = 0x107L; + private static final long SALT_GALAXY_NODE = 0x108L; + private static final long SALT_GALAXY_PITCH = 0x109L; + private static final long SALT_GALAXY_PHASE = 0x10AL; + private static final long SALT_GALAXY_SPEED = 0x10BL; + private static final long SALT_GALAXY_HEADING = 0x10CL; + private static final long SALT_GALAXY_ELEVATION = 0x10DL; + private static final long SALT_GALAXY_HOME_ANGLE = 0x10EL; + // The retinue's own draws. A satellite's parameters are drawn from its PRIMARY's cell index with + // the satellite's own ordinal folded into the seed, so two satellites of one galaxy cannot + // correlate and no salt has to be allocated per satellite. + private static final long SALT_SATELLITE_COUNT = 0x10FL; + private static final long SALT_SATELLITE_TYPE = 0x110L; + private static final long SALT_SATELLITE_RADIUS = 0x111L; + private static final long SALT_SATELLITE_DISTANCE = 0x112L; + private static final long SALT_SATELLITE_HEADING = 0x113L; + private static final long SALT_SATELLITE_ELEVATION = 0x114L; + private static final long SALT_SATELLITE_TILT = 0x115L; + private static final long SALT_SATELLITE_NODE = 0x116L; + private static final long SALT_SATELLITE_PITCH = 0x117L; + private static final long SALT_SATELLITE_PHASE = 0x118L; + + /** + * What separates one satellite's draws from the next's. Mixed into the SEED through a multiplier of + * its own, exactly as {@code CellHash.ofBody} does for a system's bodies — added to the salt + * instead, the two would merge and satellite {@code i} would be a near-copy of {@code i+1}. + */ + private static final long SATELLITE_ORDINAL_MIX = 0xD1B54A32D192ED03L; + + /** Arms are drawn in this pitch band, in degrees — the range real spirals occupy. */ + private static final double MIN_ARM_PITCH_DEGREES = 10d; + private static final double MAX_ARM_PITCH_DEGREES = 30d; + + /** + * A galaxy's own motion through the expanding universe, in km/s — the band real peculiar + * velocities occupy. Andromeda's 110 km/s sits inside it. + */ + private static final double MIN_PECULIAR_SPEED_KM_S = 50d; + private static final double MAX_PECULIAR_SPEED_KM_S = 600d; + + private final GalaxyGenConfig config; + /** The metric this field measures with — its schema's, not a global one. */ + private final IUniverseLaws laws; + private final long totalGalaxyWeight; + private final long totalHomeWeight; + + public GalaxyField(GalaxyGenConfig config, IUniverseLaws laws) { + this.laws = (laws == null) ? UniverseLawsV0.INSTANCE : laws; + this.config = (config == null) ? GalaxyGenConfig.defaults() : config; + long all = 0L; // accumulated in long so a few near-Integer.MAX weights cannot overflow the sum + long home = 0L; + for (GalaxyGenConfig.GalaxyType t : this.config.galaxyTypes) { + all += t.weight; + if (qualifiesForAuthoredContent(t)) { + home += t.weight; + } + } + this.totalGalaxyWeight = Math.max(1L, all); + this.totalHomeWeight = home; + } + + public GalaxyGenConfig config() { + return config; + } + + /** + * The galaxy-lattice index a sector belongs to: the DERIVED grouping that answers "which galaxy + * cell is this", with nothing stored anywhere. + * + *

    The lattice is offset by half a cell, so the ORIGIN is a cell CENTRE and not a corner. + * That is what lets the home galaxy be centred on the origin and still sit wholly inside its own + * cell — with the corner convention, every sector with a negative coordinate would belong to a + * NEIGHBOURING cell, so most of the space around the shipped solar system would have been reading + * a different galaxy's profile (or none) while standing inside the home galaxy.

    + * + *

    The half-cell shift is applied to the QUOTIENT rather than to the coordinate: adding it to a + * sector near the {@code long} limit would overflow, and a coordinate that silently wraps is + * exactly the failure this layer removed from {@code absoluteX()}.

    + */ + public static long galaxyIndex(long sector, long galaxySpacing) { + long s = Math.max(1L, galaxySpacing); + long half = s / 2L; + long rem = Math.floorMod(sector, s); + long base = Math.floorDiv(sector, s); + return rem >= s - half ? base + 1L : base; + } + + /** The lowest sector belonging to galaxy cell {@code index} on one axis. */ + public static long cellLowCorner(long index, long galaxySpacing) { + long s = Math.max(1L, galaxySpacing); + return index * s - s / 2L; + } + + /** + * The PRIMARY galaxy of the cube this sector triple falls in, or empty when that cube is void. + * + *

    Three questions live near each other and only this one is answered here — which galaxy owns + * this cube, the identity a cube is named and declared against. It does not ask whether the + * point is inside that galaxy ({@link Galaxy#containsSector}), and it does not ask which of the + * cube's galaxies the point is in, because a cube holds the primary AND its satellites + * ({@link #galaxyContainingSector}). A caller that wants a PROFILE, a FRAME or a cluster wants that + * third one; a caller naming the neighbourhood wants this.

    + */ + public Optional galaxyOwningSector(long seed, long sectorX, long sectorY, long sectorZ) { + long s = config.galaxySpacing; + return galaxyAtIndex(seed, galaxyIndex(sectorX, s), galaxyIndex(sectorY, s), + galaxyIndex(sectorZ, s)); + } + + /** The primary galaxy of the cube {@code cell} falls in, or empty when that cube is void. */ + public Optional galaxyOwning(long seed, GalacticCoord cell) { + return galaxyOwningSector(seed, cell.sectorX(), cell.sectorY(), cell.sectorZ()); + } + + /** + * The galaxy this sector triple is actually INSIDE — the cube's primary, or one of its satellites, + * or empty out in the void between them. + * + *

    This is the question the placement profile, the frame law and the cluster lattice all ask, and + * the reason it is separate from {@link #galaxyOwningSector} is that a cube holds more than one + * galaxy. Asking the owner and then reading ITS profile would put every satellite's interior at + * density zero — the satellites would be named, addressable and empty.

    + * + *

    The answer is always at most one. A satellite is seated at least one full primary + * DIAMETER out and is at most {@link UniverseScale#MAX_SATELLITE_RADIUS_FRACTION} of the primary's + * radius, so no two spheres in a cube can overlap; the single-answer invariant the whole layer rests + * on is a property of that geometry rather than of a tie-break rule here.

    + * + *

    Cost: the primary is tested first, then the retinue is rejected wholesale by one sphere test + * against {@link UniverseScale#retinueReachLy} before any satellite is drawn. The retinue reaches a + * few diameters and the cube is 25 across, so that rejects ~98 % of the cube's volume — which + * matters, because this runs once per super-cell of every placement query.

    + */ + public Optional galaxyContainingSector(long seed, long sectorX, long sectorY, long sectorZ) { + Optional owner = galaxyOwningSector(seed, sectorX, sectorY, sectorZ); + if (!owner.isPresent()) { + return owner; + } + Galaxy primary = owner.get(); + if (primary.containsSector(sectorX, sectorY, sectorZ)) { + return owner; + } + if (!withinRetinueReach(primary, sectorX, sectorY, sectorZ)) { + return Optional.empty(); + } + for (Galaxy satellite : satellitesOf(seed, primary)) { + if (satellite.containsSector(sectorX, sectorY, sectorZ)) { + return Optional.of(satellite); + } + } + return Optional.empty(); + } + + /** The galaxy {@code cell} is inside — primary, satellite, or none. */ + public Optional galaxyContaining(long seed, GalacticCoord cell) { + return galaxyContainingSector(seed, cell.sectorX(), cell.sectorY(), cell.sectorZ()); + } + + /** + * How much stellar material stands at one sector triple, split into the part that is BOUND to a + * galaxy and the part that is not. + * + *

    The two are asked together because they are answered by the same walk over the cube, and that + * walk is the expensive thing on the placement path — it runs once per lattice cell of every + * placement query, so resolving the cube twice would double the cost of every star in the game.

    + */ + public static final class Material { + + /** Nothing here: the cube is empty, or a point too far from anything in it. */ + public static final Material NONE = new Material(0d, 0d); + + /** + * The density of the galaxy this point is INSIDE, on {@link Galaxy#densityAt}'s scale, or zero + * out in the void. What decides where stars form. + */ + public final double bound; + /** + * The density of the cube's galaxies' ejecta at this point — what they have thrown out and no + * longer hold. It is the void's whole population, and it is zero inside a galaxy, where the + * bound profile already accounts for every body standing there. + */ + public final double unbound; + + Material(double bound, double unbound) { + this.bound = bound > 0d ? bound : 0d; + this.unbound = unbound > 0d ? unbound : 0d; + } + + /** Everything at this point, bound or not — what a population that does not need a star sees. */ + public double total() { + return bound + unbound; + } + } + + /** + * The bound and unbound material at a sector triple, resolved in ONE pass over the cube's galaxies. + * + *

    Supersedes reading the profile alone. A caller that only wants to place a STAR reads + * {@link Material#bound} and gets exactly what it got before; the void's own population reads + * {@link Material#total()}, which is what makes the intergalactic content a consequence of the + * galaxies rather than a second field seated by its own rule.

    + */ + public Material materialAtSector(long seed, long sectorX, long sectorY, long sectorZ) { + Optional owner = galaxyOwningSector(seed, sectorX, sectorY, sectorZ); + if (!owner.isPresent()) { + // A cube with no galaxy has thrown nothing out: the deepest void, and genuinely empty. + return Material.NONE; + } + Galaxy primary = owner.get(); + if (primary.containsSector(sectorX, sectorY, sectorZ)) { + // Inside the primary, and the retinue is never drawn here. A satellite is at most 0.3 R + // across and sits one to three DIAMETERS out, so the strongest halo one can cast anywhere + // inside its primary is a couple of percent of what the primary's own disc reads there — + // and this is the hottest path in the layer, taken for every cell of the shipped galaxy. + return new Material(primary.densityAtSector(sectorX, sectorY, sectorZ), 0d); + } + double falloff = config.rogue.ejectaFalloff; + double unbound = primary.ejectaDensityAtSector(sectorX, sectorY, sectorZ, falloff); + if (!withinRetinueReach(primary, sectorX, sectorY, sectorZ)) { + return new Material(0d, unbound); // past the retinue: only the primary's own halo reaches + } + for (Galaxy satellite : satellitesOf(seed, primary)) { + if (satellite.containsSector(sectorX, sectorY, sectorZ)) { + return new Material(satellite.densityAtSector(sectorX, sectorY, sectorZ), unbound); + } + // The strongest halo, never the sum: two overlapping haloes are one region of thrown-out + // material counted twice, and adding them would make the gap between two dwarfs read + // denser than either dwarf's own edge. + unbound = Math.max(unbound, + satellite.ejectaDensityAtSector(sectorX, sectorY, sectorZ, falloff)); + } + return new Material(0d, unbound); + } + + /** + * The satellites of {@code primary} — drawn from {@code (seed, its cell, ordinal)}, stored nowhere, + * exactly as the primary itself is. Empty for a type that keeps none, and for a satellite: the + * retinue is one level deep, because a satellite of a satellite is not a thing a real group has and + * would make the containment answer recursive. + */ + public List satellitesOf(long seed, Galaxy primary) { + if (primary == null || primary.isSatellite()) { + return Collections.emptyList(); + } + GalaxyGenConfig.GalaxyType type = primary.type(); + if (type.maxSatellites <= 0) { + return Collections.emptyList(); + } + long gx = primary.cellX(); + long gy = primary.cellY(); + long gz = primary.cellZ(); + int span = type.maxSatellites - type.minSatellites + 1; + int count = type.minSatellites + + (int) Math.floorMod(CellHash.of(seed, gx, gy, gz, SALT_SATELLITE_COUNT), (long) span); + if (count <= 0) { + return Collections.emptyList(); + } + List retinue = new ArrayList<>(count); + for (int i = 1; i <= count; i++) { + Galaxy satellite = satelliteOf(seed, primary, i); + if (satellite != null) { + retinue.add(satellite); + } + } + return Collections.unmodifiableList(retinue); + } + + /** The satellites of the primary seated in the cube {@code cell} falls in. */ + public List satellitesAround(long seed, GalacticCoord cell) { + Optional primary = galaxyOwning(seed, cell); + return primary.isPresent() ? satellitesOf(seed, primary.get()) + : Collections.emptyList(); + } + + /** + * One satellite: a smaller galaxy of its own type, seated a band of primary DIAMETERS out in an + * isotropic direction, with its own orientation and arms. + * + *

    Its TYPE is drawn from the archetypes whose whole radius band fits under + * {@link UniverseScale#MAX_SATELLITE_RADIUS_FRACTION} of the primary's radius, so "smaller than what + * it orbits" is a constraint on the DRAW and never a clamp on its result — the same shape the + * authored-content floor uses. A primary too small for any type to fit under that fraction keeps no + * satellites, which is the honest answer rather than a forced dwarf.

    + * + *

    Its centre does not move relative to its primary, and that is a measurement rather than + * a simplification: a real satellite's orbit runs to 10⁹ years, three orders slower than the disc + * rotation this layer already establishes is invisible inside one save. It carries the primary's + * peculiar velocity, so the group travels together and the home galaxy's retinue stands as still as + * the home galaxy does.

    + */ + private Galaxy satelliteOf(long seed, Galaxy primary, int ordinal) { + long gx = primary.cellX(); + long gy = primary.cellY(); + long gz = primary.cellZ(); + long ownSeed = seed ^ ((long) ordinal * SATELLITE_ORDINAL_MIX); + + GalaxyGenConfig.GalaxyType type = pickSatelliteType(ownSeed, gx, gy, gz, primary.radiusLy()); + if (type == null) { + return null; + } + double radiusFraction = CellHash.norm( + CellHash.of(ownSeed, gx, gy, gz, SALT_SATELLITE_RADIUS)); + double radiusLy = type.minRadiusLy + radiusFraction * (type.maxRadiusLy - type.minRadiusLy); + + double distanceFraction = CellHash.norm( + CellHash.of(ownSeed, gx, gy, gz, SALT_SATELLITE_DISTANCE)); + double diameters = UniverseScale.MIN_SATELLITE_DISTANCE_IN_DIAMETERS + distanceFraction + * (UniverseScale.MAX_SATELLITE_DISTANCE_IN_DIAMETERS + - UniverseScale.MIN_SATELLITE_DISTANCE_IN_DIAMETERS); + double distanceLy = diameters * 2d * primary.radiusLy(); + + // Isotropic: cos(elevation) uniform rather than the elevation itself, or the retinue would pile + // up over the primary's poles. Deliberately NOT in the primary's plane — real companions are + // scattered around a giant rather than laid out in its disc. + double heading = CellHash.norm(CellHash.of(ownSeed, gx, gy, gz, SALT_SATELLITE_HEADING)) + * 2d * Math.PI; + double cosEl = 2d * CellHash.norm(CellHash.of(ownSeed, gx, gy, gz, SALT_SATELLITE_ELEVATION)) + - 1d; + double sinEl = Math.sqrt(Math.max(0d, 1d - cosEl * cosEl)); + double offX = distanceLy * sinEl * Math.cos(heading); + double offY = distanceLy * cosEl; + double offZ = distanceLy * sinEl * Math.sin(heading); + + GalacticCoord centre = GalacticCoord.ofSectorLocal( + primary.centre().sectorX() + laws.cellsAt(offX), + primary.centre().sectorY() + laws.cellsAt(offY), + primary.centre().sectorZ() + laws.cellsAt(offZ), 0L, 0L, 0L); + + double tilt = Math.acos(2d * CellHash.norm( + CellHash.of(ownSeed, gx, gy, gz, SALT_SATELLITE_TILT)) - 1d); + double node = CellHash.norm(CellHash.of(ownSeed, gx, gy, gz, SALT_SATELLITE_NODE)) + * 2d * Math.PI; + double pitch = Math.toRadians(MIN_ARM_PITCH_DEGREES + + CellHash.norm(CellHash.of(ownSeed, gx, gy, gz, SALT_SATELLITE_PITCH)) + * (MAX_ARM_PITCH_DEGREES - MIN_ARM_PITCH_DEGREES)); + double phase = CellHash.norm(CellHash.of(ownSeed, gx, gy, gz, SALT_SATELLITE_PHASE)) + * 2d * Math.PI; + + return new Galaxy(gx, gy, gz, ordinal, centre, type, radiusLy, tilt, node, pitch, phase, + primary.peculiarVelocity(), laws); + } + + /** + * A satellite's archetype: drawn by weight among the types small enough to be one, or {@code null} + * when the table holds none that small. + */ + private GalaxyGenConfig.GalaxyType pickSatelliteType(long seed, long gx, long gy, long gz, + double primaryRadiusLy) { + double ceiling = UniverseScale.MAX_SATELLITE_RADIUS_FRACTION * primaryRadiusLy; + long total = 0L; + for (GalaxyGenConfig.GalaxyType t : config.galaxyTypes) { + if (t.maxRadiusLy <= ceiling) { + total += t.weight; + } + } + if (total <= 0L) { + return null; + } + long r = Math.floorMod(CellHash.of(seed, gx, gy, gz, SALT_SATELLITE_TYPE), total); + GalaxyGenConfig.GalaxyType last = null; + for (GalaxyGenConfig.GalaxyType t : config.galaxyTypes) { + if (t.maxRadiusLy > ceiling) { + continue; + } + last = t; + if (r < t.weight) { + return t; + } + r -= t.weight; + } + return last; + } + + /** + * Whether a point is close enough to {@code primary} for any of its satellites to reach it. One + * sphere test that rejects the whole retinue, so the void inside a cube costs nothing. + */ + private boolean withinRetinueReach(Galaxy primary, long sectorX, long sectorY, + long sectorZ) { + double reach = laws.retinueReachLy(primary.radiusLy()); + double dx = laws.lightYearsForCells( + (double) (sectorX - primary.centre().sectorX())); + double dy = laws.lightYearsForCells( + (double) (sectorY - primary.centre().sectorY())); + double dz = laws.lightYearsForCells( + (double) (sectorZ - primary.centre().sectorZ())); + return dx * dx + dy * dy + dz * dz <= reach * reach; + } + + /** The home galaxy — the one authored content lives in. Present under every seed, by construction. */ + public Galaxy home(long seed) { + // Reserved, so the Optional is always full; unwrapping it here is what makes that a statement + // callers can rely on rather than one they have to re-check. + return galaxyAtIndex(seed, 0L, 0L, 0L).get(); + } + + /** Whether this galaxy-lattice index is the reserved home cell. */ + public static boolean isHomeCell(long gx, long gy, long gz) { + return gx == 0L && gy == 0L && gz == 0L; + } + + /** + * Whether this cell holds a galaxy WHATEVER the hash says — the home cell, or any key authored + * content was declared against. + */ + public boolean isReserved(long gx, long gy, long gz) { + for (GalaxyKey key : config.reservedGalaxies) { + if (key.gx() == gx && key.gy() == gy && key.gz() == gz) { + return true; + } + } + return false; + } + + /** + * The cell an authored anchor declared against {@code key} is measured FROM, or empty when that + * cell holds no galaxy. A reserved key always answers, which is the whole point of reserving it. + * + *

    For {@code home} it is the universe ORIGIN, not the galaxy's centre — the home galaxy is + * seated around the origin rather than on it, and the origin is where authored content has always + * been declared. So a coordinate written before galaxies existed still means exactly what it did, + * and the galaxy is what moved to contain it.

    + */ + public Optional declarationOriginOf(long seed, GalaxyKey key) { + if (key != null && key.isHome()) { + return Optional.of(GalacticCoord.ORIGIN); + } + return centreOf(seed, key); + } + + /** Where the galaxy named by {@code key} is CENTRED, or empty when that cell holds no galaxy. */ + public Optional centreOf(long seed, GalaxyKey key) { + if (key == null) { + return Optional.empty(); + } + Optional galaxy = galaxyAtIndex(seed, key.gx(), key.gy(), key.gz()); + return galaxy.isPresent() ? Optional.of(galaxy.get().centre()) : Optional.empty(); + } + + /** + * The galaxy seated in galaxy cell {@code (gx, gy, gz)}, or empty when the cell is void. + * + *

    Every parameter is a hash draw over the cell index, so the answer is a pure function of + * {@code (seed, cell)} and two queries about the same galaxy can never disagree.

    + */ + public Optional galaxyAtIndex(long seed, long gx, long gy, long gz) { + boolean home = isHomeCell(gx, gy, gz); + boolean reserved = home || isReserved(gx, gy, gz); + if (!reserved && !occupied(seed, gx, gy, gz)) { + return Optional.empty(); + } + // A reserved cell holds authored content, so its galaxy must be large enough to have room for + // it — the guarantee is a constraint on the TYPE DRAW, never a clamp applied to its result. + GalaxyGenConfig.GalaxyType type = pickType(CellHash.of(seed, gx, gy, gz, SALT_GALAXY_TYPE), + reserved); + double radiusFraction = CellHash.norm(CellHash.of(seed, gx, gy, gz, SALT_GALAXY_RADIUS)); + double radiusLy = type.minRadiusLy + radiusFraction * (type.maxRadiusLy - type.minRadiusLy); + + // An isotropic pole: cos(tilt) uniform, not tilt uniform, or galaxies would cluster edge-on. + double tilt = Math.acos(2d * CellHash.norm(CellHash.of(seed, gx, gy, gz, SALT_GALAXY_TILT)) - 1d); + double node = CellHash.norm(CellHash.of(seed, gx, gy, gz, SALT_GALAXY_NODE)) * 2d * Math.PI; + double pitch = Math.toRadians(MIN_ARM_PITCH_DEGREES + + CellHash.norm(CellHash.of(seed, gx, gy, gz, SALT_GALAXY_PITCH)) + * (MAX_ARM_PITCH_DEGREES - MIN_ARM_PITCH_DEGREES)); + double phase = CellHash.norm(CellHash.of(seed, gx, gy, gz, SALT_GALAXY_PHASE)) * 2d * Math.PI; + + return Optional.of(new Galaxy(gx, gy, gz, 0, + seatOf(seed, gx, gy, gz, radiusLy, tilt, node, home), type, + radiusLy, tilt, node, pitch, phase, + peculiarVelocityOf(seed, gx, gy, gz, radiusLy, home), laws)); + } + + /** + * A galaxy's own motion through the expanding universe, in light years per tick. + * + *

    The home galaxy has none. It is the rest frame authored content is declared in: if it + * drifted, the shipped solar system — named by absolute cells at {@code t = 0} — would be left + * behind by its own galaxy. Every other galaxy moves relative to it, which is also what an + * observer actually sees.

    + * + *

    The speed is CLAMPED so the galaxy cannot leave its own lattice cell within + * {@link Cosmology#DRIFT_HORIZON_TICKS}. At realistic speeds the clamp is five orders from + * binding — so galaxy mergers are excluded by construction and nothing else is.

    + */ + private LightYearVector peculiarVelocityOf(long seed, long gx, long gy, long gz, double radiusLy, + boolean home) { + if (home) { + return LightYearVector.ZERO; + } + double u = CellHash.norm(CellHash.of(seed, gx, gy, gz, SALT_GALAXY_SPEED)); + double kmPerSecond = MIN_PECULIAR_SPEED_KM_S + + u * (MAX_PECULIAR_SPEED_KM_S - MIN_PECULIAR_SPEED_KM_S); + double speed = Math.min(laws.lightYearsPerTick(kmPerSecond), + driftBudgetLy(radiusLy) / (double) laws.driftHorizonTicks()); + + // Isotropic: cos(elevation) uniform, not the elevation itself, or the draws would pile up at + // the poles of whatever axis happened to be written first. + double heading = CellHash.norm(CellHash.of(seed, gx, gy, gz, SALT_GALAXY_HEADING)) * 2d * Math.PI; + double cosEl = 2d * CellHash.norm(CellHash.of(seed, gx, gy, gz, SALT_GALAXY_ELEVATION)) - 1d; + double sinEl = Math.sqrt(Math.max(0d, 1d - cosEl * cosEl)); + return LightYearVector.of(speed * sinEl * Math.cos(heading), speed * cosEl, + speed * sinEl * Math.sin(heading)); + } + + /** + * How far a galaxy of this radius may drift before its RETINUE would touch its own cell's face. The + * whole group travels together, so the budget is the group's reach and not the primary's radius. + */ + private double driftBudgetLy(double radiusLy) { + double halfCellLy = laws.lightYearsForCells(config.galaxySpacing / 2d); + return Math.max(0d, halfCellLy - laws.retinueReachLy(radiusLy)); + } + + // ─── The intergalactic regime ────────────────────────────────────────────── + + /** + * Which law carries this cell through time: bound to its galaxy, or comoving out in the void. + * + *

    Ask this at a CROSSING and store the answer — see {@link GalacticFrame}. Calling it every + * tick for a moving craft is the frame-flapping this design exists to prevent.

    + */ + public GalacticFrame frameAt(long seed, GalacticCoord cell) { + // The galaxy the cell is INSIDE, which may be a satellite: a thing in a satellite rides the + // satellite's disc, not the primary's. Reading the cube's owner instead would leave every point + // in every satellite comoving with the void it is demonstrably not in. + return galaxyContaining(seed, cell).isPresent() + ? GalacticFrame.GALACTIC : GalacticFrame.COMOVING; + } + + /** + * Where the cell named {@code cell} actually is at tick {@code tick}, under whichever law governs + * it. The two laws meet here and nowhere else. + */ + public LightYearVector positionAt(long seed, GalacticCoord cell, long tick) { + Optional galaxy = galaxyContaining(seed, cell); + if (galaxy.isPresent()) { + return galaxy.get().boundPositionOfCellAt(cell, tick); + } + return comovingPositionAt(cell, tick); + } + + /** + * Where a VOID cell is at tick {@code tick}: carried by the Hubble flow and nothing else. + * + *

    Out here a position is stated in LIGHT YEARS, not in blocks, and that is what makes the + * intergalactic regime expressible at all: a galaxy cube is millions of light years across, which + * is orders past what a block {@code long} holds, and the layer never asks one to hold it. The + * cell NAME carries the magnitude (a sector triple) and this vector carries the rest.

    + */ + public LightYearVector comovingPositionAt(GalacticCoord cell, long tick) { + return LightYearVector.ofCell(cell, laws).scale(laws.scaleFactorAt(tick)); + } + + /** + * Whether this cell holds a galaxy at all. + * + *

    The cosmic-web slot. Galaxies in reality lie on filaments around genuine voids, and + * that is a field over the lattice, not a per-cell coin toss. The field is not built — none of its + * numbers is ratified and it needs spatially CORRELATED noise, a primitive this generator does not + * have. What is built is the shape it drops into: {@link #webDensity} is the constant 1 today and + * becomes that field later, with no change to placement.

    + */ + private boolean occupied(long seed, long gx, long gy, long gz) { + double threshold = config.galaxyDensity * webDensity(gx, gy, gz); + return CellHash.norm(CellHash.of(seed, gx, gy, gz, SALT_GALAXY_OCC)) < threshold; + } + + /** + * How much likelier than baseline a galaxy is at this lattice index — the cosmic web's hook. It is + * deliberately a constant: this states that galaxy density is not REQUIRED to be uniform, and + * names the one place non-uniformity will live. + */ + static double webDensity(long gx, long gy, long gz) { + return 1d; + } + + /** + * Where the galaxy sits inside its cube: anywhere that leaves it wholly inside, so it never + * straddles a face. + * + *

    That containment is what keeps three things true at once — at most one PRIMARY per cell, + * galaxies that cannot overlap, and an O(1) answer to "which galaxy is this point in" that reads + * the containing cell and nothing else.

    + * + *

    The margin is the whole RETINUE's reach, not the primary's radius. A satellite is + * seated a few diameters out, so a margin sized to the primary alone would let a galaxy near a face + * keep satellites on the wrong side of it — and a galaxy outside its own lattice cell is one the + * index hands to a neighbour, which is the single-answer invariant broken by a number that was + * correct before satellites existed.

    + * + *

    The home galaxy is seated AROUND the origin instead — the origin is where authored content + * is, so the galaxy has to contain it. Not ON it: the centre of a galaxy is its nucleus, and that + * is the last address a shipped solar system should have. The offset puts the origin at a + * sun-like galactic radius, in the plane, out in the disc.

    + */ + private GalacticCoord seatOf(long seed, long gx, long gy, long gz, double radiusLy, double tilt, + double node, boolean home) { + if (home) { + double angle = CellHash.norm(CellHash.of(seed, gx, gy, gz, SALT_GALAXY_HOME_ANGLE)) + * 2d * Math.PI; + LightYearVector offset = Galaxy.planeDirection(tilt, node, angle) + .scale(-UniverseScale.HOME_GALAXY_ORIGIN_FRACTION * radiusLy); + return GalacticCoord.ofSectorLocal(laws.cellsAt(offset.x()), + laws.cellsAt(offset.y()), laws.cellsAt(offset.z()), + 0L, 0L, 0L); + } + long s = config.galaxySpacing; + long margin = Math.min(laws.cellsForLightYears(laws.retinueReachLy(radiusLy)), + Math.max(0L, (s - 1L) / 2L)); + long band = Math.max(1L, s - 2L * margin); + // The index came from a real sector, so a cell corner is bounded by that sector and the + // products below cannot overflow: each is at most the coordinate it was derived from. + long ox = margin + Math.floorMod(CellHash.of(seed, gx, gy, gz, SALT_GALAXY_OX), band); + long oy = margin + Math.floorMod(CellHash.of(seed, gx, gy, gz, SALT_GALAXY_OY), band); + long oz = margin + Math.floorMod(CellHash.of(seed, gx, gy, gz, SALT_GALAXY_OZ), band); + return GalacticCoord.ofSectorLocal(cellLowCorner(gx, s) + ox, cellLowCorner(gy, s) + oy, + cellLowCorner(gz, s) + oz, 0L, 0L, 0L); + } + + /** + * Whether a type may be drawn for a galaxy that HOLDS AUTHORED CONTENT: its smallest possible + * radius must already clear the guaranteed minimum, so the guarantee is a constraint on the DRAW + * rather than a clamp applied to its result. + */ + private static boolean qualifiesForAuthoredContent(GalaxyGenConfig.GalaxyType type) { + return type.minRadiusLy >= UniverseScale.MIN_AUTHORED_GALAXY_RADIUS_LY; + } + + /** + * Draw a type by weight — over the whole table, or over the subset a galaxy holding authored + * content may be. + * + *

    A table with nothing large enough falls back to the whole table: a pack that ships only dwarf + * galaxies gets the universe it asked for, and its authored content had better be near the + * centre.

    + */ + private GalaxyGenConfig.GalaxyType pickType(long h, boolean restrictToLarge) { + boolean restricted = restrictToLarge && totalHomeWeight > 0L; + long r = Math.floorMod(h, restricted ? totalHomeWeight : totalGalaxyWeight); + GalaxyGenConfig.GalaxyType last = null; + for (GalaxyGenConfig.GalaxyType t : config.galaxyTypes) { + if (restricted && !qualifiesForAuthoredContent(t)) { + continue; + } + last = t; + if (r < t.weight) { + return t; + } + r -= t.weight; + } + return last; // config.galaxyTypes is never empty + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/universe/GalaxyGenConfig.java b/src/main/java/zmaster587/advancedRocketry/universe/GalaxyGenConfig.java index a2ffdaae4..8fe0cb9a2 100644 --- a/src/main/java/zmaster587/advancedRocketry/universe/GalaxyGenConfig.java +++ b/src/main/java/zmaster587/advancedRocketry/universe/GalaxyGenConfig.java @@ -9,21 +9,59 @@ * knobs, never a contract — authored via the optional {@code } XML element; every field has a * default so {@code } with no attributes is valid. * - *

    Immutable. The distribution is CLUSTERED: space is partitioned into {@link #minSpacing}-cube - * "super-cells" (at most one system each — the spacing guarantee), and a coarser blob field grouped - * {@link #clusterScale} super-cells wide decides which super-cells sit inside a galaxy versus the - * inter-galaxy {@link #voidFraction void}.

    + *

    Immutable, and it describes TWO nested lattices of the same shape:

    + *
      + *
    • {@link #galaxySpacing}-cube galaxy cells, at most one galaxy each, occupied with + * probability {@link #galaxyDensity} — a galaxy is a seated object with a type, a radius, an + * orientation and a density profile ({@link Galaxy});
    • + *
    • {@link #minSpacing}-cube super-cells, at most one system each, occupied with + * probability {@link #density} scaled by the owning galaxy's profile at that point.
    • + *
    + * + *

    The galaxy tier replaces an independent per-blob Bernoulli mask (a {@code clusterScale} field and + * a {@code voidFraction}, both retired). That mask drew each blob cell independently at a probability + * above the site-percolation threshold, so the "galaxies" it produced were one unbounded sponge: no + * centre, no radius, no orientation, and no answer to which galaxy a point is in.

    */ 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 = UniverseScale.DEFAULT_SPACING_CELLS; + + /** + * Default galaxy-cell edge in cells — {@link UniverseScale#DEFAULT_GALAXY_SPACING_CELLS}. A + * {@code long}: the galaxy lattice is five orders coarser than the star lattice. */ - public static final int DEFAULT_MIN_SPACING = 512; + public static final long DEFAULT_GALAXY_SPACING = UniverseScale.DEFAULT_GALAXY_SPACING_CELLS; + + /** + * Fraction of galaxy cells that actually hold a galaxy, before the cosmic web weights them. + * + *

    A knob, and deliberately not an observation — unlike its neighbours in this file. The + * star separation, the galaxy radii and the rogue abundance are all measured quantities; this one + * is a chance-per-cube standing in for a number density astronomy states per unit volume, and + * nothing here derives it from a catalogue. It is stated as a knob so the next reader does not + * mistake it for a reading. + * + *

    And it is doing double duty, which is the part worth knowing: half of what it means is + * "structure we have not built". The cosmic web is a deliberate deferral — {@code webDensity} is + * the constant 1 — so the clumping that should come from the web is folded into this single + * uniform chance. Deriving it properly is not a matter of finding a better number; it needs the + * correlated noise the web needs, and until that exists a measured value would be no more honest + * than this one. + */ + public static final double DEFAULT_GALAXY_DENSITY = 0.5d; /** A weighted star archetype: a temperature (drives colour) and a size range. */ public static final class StarType { @@ -40,40 +78,564 @@ public StarType(int temperature, float minSize, float maxSize, int weight) { } } - /** Per-super-cell occupancy probability inside a galaxy (before the void mask). */ + /** + * The radial shape a galaxy's stars are distributed in. It decides the FORM of the profile, not + * its size: how far the stars reach is the galaxy's radius, which is drawn per type. + */ + public enum GalaxyProfile { + /** A flattened exponential disc with a central bulge, and arms when the type has them. */ + DISC, + /** A round exponential cloud — no plane, no arms, no preferred direction. */ + SPHEROID + } + + /** + * A weighted galaxy archetype. The exact analogue of {@link StarType} one level up, and it exists + * for the same reason: so that size is drawn CONDITIONAL ON TYPE, never independently. + * + *

    Independent draws would produce dwarf galaxies carrying spiral arms and spirals the size of a + * dwarf — the type and the size of a real galaxy are not two facts, they are one. The weights are + * what makes "mostly dwarfs, and a spiral is a find" a property of the table rather than a rule + * somewhere in the generator.

    + */ + public static final class GalaxyType { + /** Short archetype name; a seated galaxy's designation is built from it. */ + public final String name; + public final GalaxyProfile profile; + /** Radius band, in light years. A galaxy's radius is DRAWN INSIDE ITS TYPE'S band. */ + public final double minRadiusLy; + public final double maxRadiusLy; + /** + * Scale height as a fraction of the radius — how flat the thing is. A real thin disc is about + * 1:50, an irregular is a fat slab, a spheroid is nearly round. + */ + public final double scaleHeightRatio; + /** Spiral arms, or 0 for a type that has none. */ + public final int armCount; + /** The rotation curve's asymptotic speed, in km/s — quoted the way astronomy quotes it. */ + public final double rotationSpeedKmS; + /** + * Where the rotation curve turns over, as a fraction of the radius. Near 1 the whole galaxy + * rotates almost as a solid body (little shear); near 0 the curve is flat almost everywhere + * (strong shear, and arms that wind up). + */ + public final double coreRadiusFraction; + /** + * How many SATELLITE galaxies a galaxy of this type keeps, as a band — the same shape as the + * radius band above, and stated as two numbers for the same reason: a single maximum would hide + * the decision of whether a giant may have none at all. + * + *

    Real giants essentially all keep company, so the floor is non-zero for them; a dwarf keeps + * none, and {@code 0..0} is how that is said. It is deliberately a handful and not the dozens a + * real catalogue lists: a satellite is a full galaxy resolved on the placement path, so the + * count is a cost per query, and the ultra-faint dwarfs beyond a handful are not destinations + * anybody would fly to.

    + */ + public final int minSatellites; + public final int maxSatellites; + public final int weight; + + public GalaxyType(String name, GalaxyProfile profile, double minRadiusLy, double maxRadiusLy, + double scaleHeightRatio, int armCount, double rotationSpeedKmS, + double coreRadiusFraction, int minSatellites, int maxSatellites, + int weight) { + this.name = (name == null || name.isEmpty()) ? "GALAXY" : name; + this.profile = (profile == null) ? GalaxyProfile.DISC : profile; + this.minRadiusLy = Math.max(1d, minRadiusLy); + this.maxRadiusLy = Math.max(this.minRadiusLy, maxRadiusLy); + this.scaleHeightRatio = Math.min(1d, Math.max(0.001d, scaleHeightRatio)); + this.armCount = Math.max(0, armCount); + this.rotationSpeedKmS = Math.max(0d, rotationSpeedKmS); + this.coreRadiusFraction = Math.min(1d, Math.max(0.001d, coreRadiusFraction)); + this.minSatellites = Math.max(0, minSatellites); + this.maxSatellites = Math.max(this.minSatellites, maxSatellites); + this.weight = Math.max(1, weight); + } + } + + /** + * A weighted STAR-CLUSTER archetype — the same seat one level DOWN, and the third table of the + * same shape. + * + *

    The stratified lattice reads correctly as randomness but produces no GROUPS, and groups are + * what a real sky has: the lattice caps density at roughly three times the mean, while an open + * cluster runs tens of times the field. A cluster is therefore a seated object like a galaxy and + * like a system, and inside it the star lattice is finer.

    + * + *

    {@code subdivision} is what makes this cheap rather than a graded spacing. The fine + * lattice divides each coarse super-cell into {@code k³} parts, so it tiles the coarse cells it + * replaces exactly — there is no boundary pathology and nothing has to be re-proved per ring. + * Density inside a cluster is {@code k³} times the field.

    + */ + public static final class ClusterType { + public final String name; + /** {@code k}: how many parts each coarse super-cell is divided into, per axis. */ + public final int subdivision; + public final double minRadiusLy; + public final double maxRadiusLy; + /** + * How much of its natal cloud a cluster of this type still has, {@code 0}..{@code 1} — which + * is the same thing as how OLD it is. An open cluster is young and still wrapped in gas; a + * globular is ancient and has none at all, which is why real globulars are gas-free. + * + *

    It is the only input a nebula needs, and it is why a nebula is not seated separately: a + * cluster and its cloud are one object at two ages.

    + */ + public final double nebulaFraction; + /** + * Whether a cluster of this type holds itself together well enough to survive OUTSIDE a + * galaxy. Only these are seated in the intergalactic void. + * + *

    It is not a gameplay switch but the property that decides the question: a globular is + * bound tightly enough to have outlived its own galaxy's mergers and is routinely found far + * out in a halo, while an open cluster disperses in a few hundred million years and a + * molecular cloud never was bound at all. Something thrown clear of a galaxy has the whole + * crossing to fall apart in, so only the bound one arrives.

    + */ + public final boolean selfBound; + public final int weight; + + public ClusterType(String name, int subdivision, double minRadiusLy, double maxRadiusLy, + double nebulaFraction, boolean selfBound, int weight) { + this.name = (name == null || name.isEmpty()) ? "CLUSTER" : name; + this.subdivision = Math.max(1, subdivision); + this.minRadiusLy = Math.max(0.01d, minRadiusLy); + this.maxRadiusLy = Math.max(this.minRadiusLy, maxRadiusLy); + this.nebulaFraction = Math.min(1d, Math.max(0d, nebulaFraction)); + this.selfBound = selfBound; + this.weight = Math.max(1, weight); + } + } + + /** + * Per-super-cell occupancy probability, before the owning galaxy's profile scales it. It is the + * density AT A GALAXY'S DENSEST POINT, not an average over space: outside a galaxy the profile is + * zero and no value here places a system. + */ 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; - /** Fraction of space that is inter-galaxy void (no systems). */ - public final double voidFraction; + /** Galaxy-cell edge in cells: at most one galaxy per {@code galaxySpacing}-cube. */ + public final long galaxySpacing; + /** Fraction of galaxy cells that hold a galaxy at all — the rest is intergalactic void. */ + public final double galaxyDensity; /** Star archetypes sampled by weight when a system is placed (never empty). */ public final List starTypes; + /** Galaxy archetypes sampled by weight when a galaxy is seated (never empty). */ + public final List galaxyTypes; + /** Star-cluster archetypes sampled by weight when a cluster is seated (never empty). */ + public final List clusterTypes; + /** + * Galaxy cells that hold a galaxy WHATEVER the hash says — every key authored content is declared + * against. Always contains {@link GalaxyKey#HOME}: a pack that names no galaxy still has one. + */ + public final List reservedGalaxies; + /** + * What the UNBOUND population looks like — how many free-floating worlds there are, what they are + * made of, and how far a galaxy's ejecta reaches. Never {@code null}; defaults to + * {@link RogueTuning#physical()}, i.e. to what is measured. + */ + public final RogueTuning rogue; + + /** + * Each lattice states its EDGE and then its OCCUPANCY, stars first and galaxies second, so the two + * (edge, density) pairs cannot be read for one another. + */ + public GalaxyGenConfig(int minSpacing, double density, long galaxySpacing, double galaxyDensity, + List starTypes, List galaxyTypes) { + this(minSpacing, density, galaxySpacing, galaxyDensity, starTypes, galaxyTypes, null); + } - public GalaxyGenConfig(double density, int minSpacing, int clusterScale, double voidFraction, - List starTypes) { + public GalaxyGenConfig(int minSpacing, double density, long galaxySpacing, double galaxyDensity, + List starTypes, List galaxyTypes, + List reservedGalaxies) { this.density = clamp01(density); this.minSpacing = Math.max(1, minSpacing); - this.clusterScale = Math.max(1, clusterScale); - this.voidFraction = clamp01(voidFraction); + this.galaxySpacing = Math.max(1L, galaxySpacing); + this.galaxyDensity = clamp01(galaxyDensity); this.starTypes = (starTypes == null || starTypes.isEmpty()) ? defaultStarTypes() : Collections.unmodifiableList(new ArrayList<>(starTypes)); + this.galaxyTypes = (galaxyTypes == null || galaxyTypes.isEmpty()) + ? defaultGalaxyTypes() + : Collections.unmodifiableList(new ArrayList<>(galaxyTypes)); + this.clusterTypes = defaultClusterTypes(); + List reserved = new ArrayList<>(); + reserved.add(GalaxyKey.HOME); + if (reservedGalaxies != null) { + for (GalaxyKey key : reservedGalaxies) { + if (key != null && !reserved.contains(key)) { + reserved.add(key); + } + } + } + this.reservedGalaxies = Collections.unmodifiableList(reserved); + this.rogue = RogueTuning.physical(); + } + + private GalaxyGenConfig(GalaxyGenConfig from, RogueTuning rogue) { + this.density = from.density; + this.minSpacing = from.minSpacing; + this.galaxySpacing = from.galaxySpacing; + this.galaxyDensity = from.galaxyDensity; + this.starTypes = from.starTypes; + this.galaxyTypes = from.galaxyTypes; + this.clusterTypes = from.clusterTypes; + this.reservedGalaxies = from.reservedGalaxies; + this.rogue = rogue == null ? RogueTuning.physical() : rogue; + } + + /** + * The same configuration with the unbound population retuned — the {@code } attributes + * a pack may state about rogues. + * + *

    A named copy rather than four more constructor parameters, and the same shape + * {@link #withReservedGalaxies} already uses: what ships is the measured universe, and a pack + * states only the part it disagrees with.

    + */ + public GalaxyGenConfig withRogueTuning(RogueTuning tuning) { + return new GalaxyGenConfig(this, tuning); + } + + /** + * The same configuration, reserving these galaxy cells as well. Authored anchors are discovered + * while the catalogue is walked, which is after {@code } has been read — so the keys + * they name are folded in here rather than parsed twice. + */ + public GalaxyGenConfig withReservedGalaxies(List keys) { + // The rogue tuning is carried over EXPLICITLY. This runs after the catalogue walk, i.e. after + // has already been read, so going through the public constructor — which resets the + // unbound population to the measured default — would silently discard whatever the pack + // authored about rogues for every pack that also names a galaxy. + return new GalaxyGenConfig(minSpacing, density, galaxySpacing, galaxyDensity, starTypes, + galaxyTypes, keys).withRogueTuning(rogue); + } + + /** + * A stable digest of every knob in this configuration — the identity of the universe these + * parameters describe. + * + *

    What it is FOR: a save records the fingerprint of the configuration it was generated under, + * and a later load compares. The generator is a pure function of {@code (seed, cell)} and these + * numbers, so a pack that retunes one of them is not tweaking balance — it is describing a + * different universe, in which every unpinned system moves. That is invisible without a stamp, and + * a moved system is discovered by a player arriving somewhere his notes do not match.

    + * + *

    Stable across JVMs and runs by construction: no {@link Object#hashCode()} anywhere (identity + * hashes and {@code String.hashCode} are not a promise across versions), doubles rendered through + * {@link Double#doubleToLongBits} rather than formatted (no locale, no rounding), and every list + * walked in its declared order — order IS part of the identity, because a weighted table's order + * decides which archetype a given hash lands on.

    + * + * @return 16 lowercase hex characters of SHA-256 over the canonical rendering — enough that a + * collision is not something a pack author will meet, short enough to read out of a log + */ + public String fingerprint() { + StringBuilder sb = new StringBuilder(512); + sb.append("v1;"); + sb.append("minSpacing=").append(minSpacing).append(';'); + sb.append("density=").append(bits(density)).append(';'); + sb.append("galaxySpacing=").append(galaxySpacing).append(';'); + sb.append("galaxyDensity=").append(bits(galaxyDensity)).append(';'); + for (StarType t : starTypes) { + sb.append("star[").append(t.temperature).append(',').append(bits(t.minSize)).append(',') + .append(bits(t.maxSize)).append(',').append(t.weight).append("];"); + } + for (GalaxyType t : galaxyTypes) { + sb.append("galaxy[").append(t.name).append(',').append(t.profile).append(',') + .append(bits(t.minRadiusLy)).append(',').append(bits(t.maxRadiusLy)).append(',') + .append(bits(t.scaleHeightRatio)).append(',').append(t.armCount).append(',') + .append(bits(t.rotationSpeedKmS)).append(',').append(bits(t.coreRadiusFraction)) + .append(',').append(t.minSatellites).append(',').append(t.maxSatellites) + .append(',').append(t.weight).append("];"); + } + for (ClusterType t : clusterTypes) { + sb.append("cluster[").append(t.name).append(',').append(t.subdivision).append(',') + .append(bits(t.minRadiusLy)).append(',').append(bits(t.maxRadiusLy)).append(',') + .append(bits(t.nebulaFraction)).append(',').append(t.selfBound).append(',') + .append(t.weight).append("];"); + } + for (GalaxyKey key : reservedGalaxies) { + sb.append("reserved[").append(key.gx()).append(',').append(key.gy()).append(',') + .append(key.gz()).append("];"); + } + sb.append("rogue[").append(bits(rogue.abundance)).append(',').append(bits(rogue.giantFraction)) + .append(',').append(bits(rogue.ejectaFalloff)).append(']'); + for (RogueType t : rogue.types) { + sb.append("rogueType[").append(t.name).append(',').append(t.primaryKind).append(',') + .append(t.weight).append("];"); + } + return digest(sb.toString()); + } + + /** The fingerprint of "no procedural generator at all" — an authored-anchors-only universe. */ + public static String noGeneratorFingerprint() { + return digest("none"); + } + + private static String bits(double v) { + return Fingerprint.bits(v); + } + + private static String digest(String canonical) { + return Fingerprint.hex16(canonical); } /** A sparse, strongly-clustered default galaxy. */ public static GalaxyGenConfig defaults() { - return new GalaxyGenConfig(0.35d, DEFAULT_MIN_SPACING, 16, 0.6d, defaultStarTypes()); + // The occupancy is READ from the metric rather than repeated here: it is half of what decides + // the mean star separation, and a second copy of it would move the field without moving the + // constant that claims to state where the field is. + return new GalaxyGenConfig(DEFAULT_MIN_SPACING, UniverseScale.DEFAULT_STAR_OCCUPANCY, + DEFAULT_GALAXY_SPACING, DEFAULT_GALAXY_DENSITY, defaultStarTypes(), defaultGalaxyTypes()); } + /** + * The stock star table, weighted by the OBSERVED abundance of each class rather than by a feel for + * how often one should turn up. + * + *

    Weights are per ten thousand systems, from a solar-neighbourhood census BY NUMBER — which is + * the census that matters here, because this table is sampled once per seat. (A census by + * luminosity or by mass gives almost the opposite ordering, and is what makes a blue star feel + * common: it dominates every photograph of the sky while being nearly absent from the volume.)

    + * + * + * + * + * + * + * + * + *
    class, share by number, weight
    M red dwarf~76 %7600
    K orange~12 %1200
    G sun-like~7.6 %760
    F/A white~3.6 %360
    B blue~0.13 %13
    + * + *

    They do not sum to 10 000, and that is correct rather than sloppy: the remaining ~0.7 % is + * white and brown dwarfs, which this table does not model, and O stars at ~3×10-5 % + * are below the resolution of any weight an integer can carry. Weights are relative; a missing + * class is simply absent, not redistributed. + * + *

    What this changed. The previous table read 40/25/20/10/5, i.e. a blue star in one + * system out of twenty against an observed one in seven hundred and sixty — 38× too + * common, against its own comment calling them rare. It flowed downstream too: a star's + * temperature and size set its habitable zone, so an over-bright field made warm orbits commoner + * everywhere. + */ private static List defaultStarTypes() { List l = new ArrayList<>(); - l.add(new StarType(40, 0.6f, 1.0f, 40)); // cool red dwarfs — most common - l.add(new StarType(70, 0.8f, 1.2f, 25)); // orange - l.add(new StarType(100, 0.9f, 1.4f, 20)); // sol-like yellow - l.add(new StarType(150, 1.1f, 1.8f, 10)); // white - l.add(new StarType(220, 1.4f, 2.6f, 5)); // hot blue giants — rare + // temp size band weight (per 10 000 systems, observed) + l.add(new StarType(40, 0.6f, 1.0f, 7600)); // M — red dwarfs, three quarters of every sky + l.add(new StarType(70, 0.8f, 1.2f, 1200)); // K — orange + l.add(new StarType(100, 0.9f, 1.4f, 760)); // G — sun-like + l.add(new StarType(150, 1.1f, 1.8f, 360)); // F/A — white + l.add(new StarType(220, 1.4f, 2.6f, 13)); // B — blue, one system in ~760 + return Collections.unmodifiableList(l); + } + + /** + * The stock galaxy table. Weights are the real abundance ordering — dwarfs outnumber giants by two + * orders — so a spiral is something a player FINDS rather than the default sky. + */ + /** + * The stock SPIRAL archetype — the type every partially-specified {@code } inherits + * its unwritten attributes from. + * + *

    It exists so those defaults are not a second copy of the numbers below. They were, and the + * copy went stale the moment the galaxy scale moved: a pack writing + * {@code } got a "spiral" 900–2 200 ly across, an order and a + * half under every real one, silently and only in the authored path.

    + */ + public static GalaxyType stockSpiral() { + for (GalaxyType t : defaultGalaxyTypes()) { + if ("Spiral".equals(t.name)) { + return t; + } + } + throw new IllegalStateException("the stock galaxy table must contain a Spiral"); + } + + private static List defaultGalaxyTypes() { + // The bands are REAL radii, read off a catalogue and stated in light years so they can be + // checked against one — never a multiple of UniverseScale.REFERENCE_GALAXY_RADIUS_LY. They + // were once about a thirtieth of these; multiplying that table back up by the same factor is + // the mistake to avoid, because it gives dwarf galaxies larger than real spirals. Each band + // is instead the range its own class actually occupies: + // dwarf spheroidal Sculptor ~1 000 ly, Fornax ~2 300 ly + // dwarf irregular SMC ~3 500 ly, LMC ~7 000 ly + // spiral M33 ~15 000 ly, Milky Way 50 000 ly, the largest discs past 60 000 ly + // elliptical M87 ~60 000 ly, the cluster-centre giants far past that + // scaleHeightRatio is a FRACTION of the radius, so it needs no re-derivation and the heights + // it now produces are the real ones: a spiral's 0.02 is 1 000 ly at 50 000 ly of radius, + // which is the disc thickness that makes a galaxy's population come out at 10^11. + // Satellites: a dwarf keeps none — it IS somebody's satellite — and a giant keeps a handful, + // never the dozens a real catalogue lists (see minSatellites for why the count is small). + List l = new ArrayList<>(); + // name profile radius band (ly) flatten arms km/s core sats weight + l.add(new GalaxyType("Dwarf Spheroidal", GalaxyProfile.SPHEROID, 500d, 3_000d, 0.70d, 0, 20d, 0.90d, 0, 0, 700)); + l.add(new GalaxyType("Dwarf Irregular", GalaxyProfile.DISC, 2_000d, 10_000d, 0.30d, 0, 50d, 0.60d, 0, 0, 290)); + l.add(new GalaxyType("Spiral", GalaxyProfile.DISC, 15_000d, 60_000d, 0.02d, 2, 220d, 0.08d, 1, 3, 7)); + l.add(new GalaxyType("Barred Spiral", GalaxyProfile.DISC, 20_000d, 75_000d, 0.02d, 4, 210d, 0.10d, 1, 4, 2)); + l.add(new GalaxyType("Elliptical", GalaxyProfile.SPHEROID, 30_000d, 150_000d, 0.60d, 0, 40d, 0.50d, 2, 5, 1)); + return Collections.unmodifiableList(l); + } + + /** + * The stock cluster table, and every subdivision in it is now the real one. + * + *

    Two of the three always were. An open cluster's and a globular's contrast is measured + * against the FIELD, and the field's density is {@link UniverseScale#MEAN_STAR_SEPARATION_LY} — + * real, and never compressed. So {@code k = 4} really does put about a thousand stars in a + * ten-light-year open cluster and {@code k = 14} about a million in a globular, which is what + * those objects hold.

    + * + *

    The NUCLEUS was the exception, and it no longer is. Its contrast is the one number in + * this table that is a statement about its whole GALAXY, and the galaxy used to be compressed in + * radius while the star separation stayed real — so it held of the order of a million stars, and a + * real nucleus's {@code k = 215} (about 10⁷ times the field) would have put ninety times the + * galaxy's entire population inside five light years. It was held at {@code k = 25} for that + * reason, and the reason is gone: a galaxy at its real radius holds ~10¹¹ systems, and 10⁷ times + * the field over a few light years is the nuclear star cluster a real one has.

    + */ + private static List defaultClusterTypes() { + List l = new ArrayList<>(); + // name k radius band (ly) gas bound weight + // A molecular cloud is a cluster whose stars have not formed: it refines nothing (k = 1) and + // is all gas. That it drops out of the SAME table as the others is the point — a cloud, a + // young cluster and an ancient one are one sequence, not three features. + l.add(new ClusterType("Molecular Cloud", 1, 10d, 30d, 1.0d, false, 60)); + l.add(new ClusterType("Open Cluster", 4, 5d, 15d, 0.55d, false, 80)); + l.add(new ClusterType("Globular Cluster", 14, 20d, 40d, 0d, true, 20)); + return Collections.unmodifiableList(l); + } + + /** + * The cluster every galaxy has at its own centre — the richest one, and no special case: it is a + * cluster like the others, drawn at the galaxy's centre instead of on the cluster lattice. + */ + public static final ClusterType NUCLEUS = new ClusterType("Nucleus", 215, 4d, 8d, 0.4d, true, 1); + + /** Edge of the cube that holds at most one cluster, in light years. */ + public static final double CLUSTER_SPACING_LY = 300d; + + /** + * Fraction of those cubes that hold a cluster, before the galaxy's own profile scales it. + * + *

    A KNOB, not a reading — the same class as {@link #DEFAULT_GALAXY_DENSITY}: a chance per cube + * standing in for a number density astronomy states per unit volume. Said out loud so the next + * reader does not take it for an observation the way the star separation, the galaxy radii and the + * rogue abundance beside it are. + */ + public static final double CLUSTER_DENSITY = 0.35d; + + /** + * The unbound population's tuning: how many free-floating worlds there are, what they are made of, + * and how far a galaxy's ejecta reaches. + * + *

    Every default here is a MEASURED astronomical quantity rather than a balance choice, because + * the rest of this layer already is — the star separation, the galaxy radii and the galaxy + * separation are all real. A pack that wants a different sky changes them through + * {@code }; what ships states what is out there.

    + */ + public static final class RogueTuning { + + /** + * How many unbound worlds the lattice draws for each STAR, at the same point. + * + *

    21, and it is an observation. Nine years of MOA-II microlensing put the + * terrestrial-mass free-floating population at roughly twenty per main-sequence star, and the + * worlds this generator draws are overwhelmingly rocky, so that is the matching number. The + * older headline of ~1.8 Jupiter-mass objects per star was retracted by OGLE, which caps that + * mass range at ~0.25 — see {@link #giantFraction}.

    + * + *

    The lattice SATURATES this, and the saturation is the honest reading rather than a + * bug. A cube holds at most one seat, so any abundance past {@code 1/density} means "every + * territory the stars left empty has something in it", which is exactly what twenty per star + * says when a territory is one star's worth of space. Lowering it below that threshold is what + * makes the number visible again.

    + */ + public final double abundance; + + /** + * The fraction of unbound worlds massive enough to have kept hydrogen — a giant rather than + * a rock. + * + *

    Far below the ordinary outer-zone giant chance, and for a physical reason: what + * unbinds a planet is a scattering encounter, and a giant is the body doing the scattering + * rather than the one thrown out. The number is the ratio of the two measured populations — + * ~0.25 Jupiter-mass free floaters per star against ~21 terrestrial ones — so about one in + * eighty. Inheriting the 0.34 that a bound body past the snow line gets would have produced + * half a free-floating giant per star, two orders above what is seen.

    + */ + public final double giantFraction; + + /** + * How steeply a galaxy's ejecta thins outside it, as a power of the distance in radii. + * + *

    Three: the slope the outer parts of a stellar halo and the intracluster light are + * measured at, which is what a population thrown out over a Hubble time into a growing volume + * comes to. Not the disc's exponential — an exponential in units of the radius is dead within + * a few of them, and the void is twenty-five across.

    + */ + public final double ejectaFalloff; + + /** What an unbound seat turns out to hold, by weight (never empty). */ + public final List types; + + public RogueTuning(double abundance, double giantFraction, double ejectaFalloff, + List types) { + this.abundance = (Double.isNaN(abundance) || abundance < 0d) ? 0d : abundance; + this.giantFraction = clamp01(giantFraction); + this.ejectaFalloff = (Double.isNaN(ejectaFalloff) || ejectaFalloff <= 0d) + ? 3d : ejectaFalloff; + this.types = (types == null || types.isEmpty()) + ? defaultRogueTypes() : Collections.unmodifiableList(new ArrayList<>(types)); + } + + /** The measured universe: what the sky actually holds. */ + public static RogueTuning physical() { + return new RogueTuning(21d, 0.012d, 3d, defaultRogueTypes()); + } + } + + /** + * A weighted ROGUE archetype — what an unbound seat turns out to hold. The fourth table of the + * shape {@link StarType} / {@link GalaxyType} / {@link ClusterType} use, and it exists for the + * same reason they do: relative abundance is a WEIGHT, so "by falling abundance" is a + * property of the table rather than a rule somewhere in the generator, and adding a kind of + * unbound object later is one row instead of another occupancy knob. + */ + public static final class RogueType { + public final String name; + /** What is actually seated — the {@link SystemBodyKind} the anchor's primary body carries. */ + public final SystemBodyKind primaryKind; + public final int weight; + + public RogueType(String name, SystemBodyKind primaryKind, int weight) { + this.name = (name == null || name.isEmpty()) ? "ROGUE" : name; + this.primaryKind = (primaryKind == null) ? SystemBodyKind.ROGUE_PLANET : primaryKind; + this.weight = Math.max(1, weight); + } + } + + /** + * The stock rogue table, and the ratio in it is measured too. + * + *

    A thrown-out WORLD against a thrown-out STAR is ~21 per star against the few per cent of + * stars that end up unbound from their galaxy at all — the intragroup population a galaxy group + * carries, well below the intracluster fractions a rich cluster shows. So a rogue star is about + * one seat in a thousand, which is what makes meeting a whole lit system out in the void an event + * rather than routine.

    + * + *

    A rogue star is a {@link SystemBodyKind#STAR} and nothing else — rogue-ness is a statement + * about WHERE it stands, not about what it is — so it is fabricated by the ordinary path and gets + * an ordinary retinue.

    + */ + public static List defaultRogueTypes() { + List l = new ArrayList<>(); + // name what is seated weight + l.add(new RogueType("Rogue Planet", SystemBodyKind.ROGUE_PLANET, 1050)); + l.add(new RogueType("Rogue Star", SystemBodyKind.STAR, 1)); return Collections.unmodifiableList(l); } diff --git a/src/main/java/zmaster587/advancedRocketry/universe/GalaxyKey.java b/src/main/java/zmaster587/advancedRocketry/universe/GalaxyKey.java new file mode 100644 index 000000000..a86e71975 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/GalaxyKey.java @@ -0,0 +1,106 @@ +package zmaster587.advancedRocketry.universe; + +/** + * The name of one galaxy: its lattice index, or the reserved word {@code home}. + * + *

    Authored content is declared against a key rather than at an absolute coordinate, and the reason + * is arithmetic: a galaxy fills about three thousandths of a percent of its own lattice cell, so a + * hand-picked absolute coordinate lands in intergalactic space with probability 99.997 %. Declaring + * {@code (galaxy, position within it)} is what makes an authored system land in a galaxy on every + * seed — and what lets it then rotate with that galaxy exactly like a procedural one, which an + * absolute declaration could never do.

    + * + *

    A declared key FORCES its cell to hold a galaxy. A galaxy is otherwise a hash draw and may + * simply not be there under another seed, while authored content must exist under every seed. The + * key's parameters — type, radius, orientation, arms — stay hash-drawn, so only EXISTENCE is + * guaranteed and every world's galaxies are still its own.

    + * + *

    Immutable value type.

    + */ +public final class GalaxyKey { + + /** The word a pack writes for the galaxy authored content lives in by default. */ + public static final String HOME_NAME = "home"; + + /** The reserved home galaxy: lattice cell (0,0,0), centred on the universe origin. */ + public static final GalaxyKey HOME = new GalaxyKey(0L, 0L, 0L); + + private final long gx; + private final long gy; + private final long gz; + + private GalaxyKey(long gx, long gy, long gz) { + this.gx = gx; + this.gy = gy; + this.gz = gz; + } + + public static GalaxyKey of(long gx, long gy, long gz) { + return new GalaxyKey(gx, gy, gz); + } + + /** + * Parse {@code "home"} or {@code "gx,gy,gz"}. Returns {@code null} for anything else — a malformed + * key is a thing the caller must report, not a thing this type may guess at. + */ + public static GalaxyKey parse(String text) { + if (text == null) { + return null; + } + String trimmed = text.trim(); + if (trimmed.isEmpty() || HOME_NAME.equalsIgnoreCase(trimmed)) { + return HOME; + } + String[] parts = trimmed.split(","); + if (parts.length != 3) { + return null; + } + try { + return new GalaxyKey(Long.parseLong(parts[0].trim()), Long.parseLong(parts[1].trim()), + Long.parseLong(parts[2].trim())); + } catch (NumberFormatException bad) { + return null; + } + } + + public long gx() { + return gx; + } + + public long gy() { + return gy; + } + + public long gz() { + return gz; + } + + /** Whether this is the home galaxy — the one centred on the origin. */ + public boolean isHome() { + return gx == 0L && gy == 0L && gz == 0L; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof GalaxyKey)) { + return false; + } + GalaxyKey other = (GalaxyKey) o; + return gx == other.gx && gy == other.gy && gz == other.gz; + } + + @Override + public int hashCode() { + int result = Long.hashCode(gx); + result = 31 * result + Long.hashCode(gy); + return 31 * result + Long.hashCode(gz); + } + + @Override + public String toString() { + return isHome() ? HOME_NAME : (gx + "," + gy + "," + gz); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/universe/IBodyDerivation.java b/src/main/java/zmaster587/advancedRocketry/universe/IBodyDerivation.java new file mode 100644 index 000000000..9375d0d26 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/IBodyDerivation.java @@ -0,0 +1,58 @@ +package zmaster587.advancedRocketry.universe; + +import zmaster587.advancedRocketry.api.dimension.solar.StellarBody; +import zmaster587.advancedRocketry.space.GalacticCoord; + +/** + * How a body's physics is drawn from its cell — the second half of a world model, and the half a + * player meets on the ground. + * + *

    Why this is an interface at all. A schema version is only worth having if an old save can + * still be derived the old way, and the derivation is exactly where a later version wants to move: new + * world types, a different mass law, another climate band. The generator seam alone could not carry + * that — it says WHERE things are, not WHAT they are. + * + *

    And why it costs nothing to thread. The derivation has one real consumer, the generator, + * which is already the object a schema hands out. So a version selects a derivation by selecting a + * generator, and everything else reaches it through {@link IGalaxyGenerator#derivation()} rather than + * through a static call that no version can intercept. + * + *

    Every method must be a pure, deterministic function of its arguments, for the same reason + * {@link IGalaxyGenerator}'s are: a scan and a later landing have to agree. + */ +public interface IBodyDerivation { + + /** The parent star's metal content relative to Sol, drawn once per system. */ + double metallicityOf(long seed, GalacticCoord anchor); + + /** The orbital distance a body of {@code star}'s system sits at, in AR distance units. */ + int referenceDistance(StellarBody star); + + /** Where body {@code index} of {@code count} sits around {@code star}. */ + int orbitalDistanceOf(long seed, GalacticCoord anchor, int index, int count, StellarBody star); + + /** The innermost orbit a body may hold around {@code star}. */ + double innerOrbit(StellarBody star); + + /** The outermost orbit a body may hold around {@code star}. */ + double outerOrbit(StellarBody star); + + /** The equilibrium temperature at {@code orbitalDistance}, before any atmosphere. */ + int bareTemperature(StellarBody star, int orbitalDistance); + + /** Whether a body at {@code orbitalDistance} keeps one face to its star. */ + boolean tidallyLockedAt(StellarBody star, int orbitalDistance); + + /** Whether body {@code index} accreted enough hydrogen to be a giant. */ + boolean isGiantAt(long seed, GalacticCoord anchor, int index, int bareTemperatureK); + + /** The full profile of a body BOUND to a star. */ + BodyProfile derive(long seed, GalacticCoord anchor, GalacticCoord bodyCell, int variant, + StellarBody star, boolean moon, int orbitalDistance); + + /** The full profile of an UNBOUND body — no star, no orbit, no insolation. */ + BodyProfile deriveRogue(long seed, GalacticCoord bodyCell, int variant, double giantFraction); + + /** What a body of this bulk still radiates with no star to warm it, in kelvin. */ + int residualTemperature(double massEarths, double radiusEarths); +} diff --git a/src/main/java/zmaster587/advancedRocketry/universe/IGalaxyGenerator.java b/src/main/java/zmaster587/advancedRocketry/universe/IGalaxyGenerator.java index 91f7638c5..fb8316e8d 100644 --- a/src/main/java/zmaster587/advancedRocketry/universe/IGalaxyGenerator.java +++ b/src/main/java/zmaster587/advancedRocketry/universe/IGalaxyGenerator.java @@ -28,14 +28,14 @@ public interface IGalaxyGenerator { * @param coord an absolute galactic coordinate; implementations should treat it at cell granularity * @return the procedural system at {@code coord}'s cell, or empty for void space */ - Optional systemAt(long seed, GalacticCoord coord); + Optional systemAt(long seed, GalacticCoord coord); /** * Enumerate every procedural system whose cell falls within the inclusive sector box {@code [min, max]}. * * @return a map from each occupied cell-centre coordinate to its system (empty when the region is void) */ - Map systemsInRegion(long seed, GalacticCoord min, GalacticCoord max); + Map systemsInRegion(long seed, GalacticCoord min, GalacticCoord max); /** * The procedural CONTENT of the system at {@code systemCoord}'s cell — its star plus planets/moons/POIs @@ -58,12 +58,139 @@ default Optional anchorAt(long seed, GalacticCoord cell) { return systemAt(seed, cell).isPresent() ? Optional.of(cell.cellCentre()) : Optional.empty(); } + /** + * The nebulae seated within {@code radiusLy} light years of {@code cell} — what a sky asks, because + * a cloud is meant to be seen from OUTSIDE it. + * + *

    A DIRECTION-and-size query, never a placement one: a nebula has no cell name and is not a + * body, so nothing here can be flown to. The default is empty, which is the correct answer for a + * generator with no clusters rather than a stub — no clusters means no gas.

    + */ + default List nebulaeAround(long seed, GalacticCoord cell, double radiusLy) { + return Collections.emptyList(); + } + + /** + * How much diffuse matter lies between two cells, in density-light-years — the column an + * observer at {@code from} looks THROUGH to see {@code to}. + * + *

    The one query every looking-consequence of a cloud is written against, in both directions: + * what a survey loses to a cloud in the way, and what a ship inside one loses looking out, are + * this integral with the endpoints moved. Zero for a generator with no clouds, which is the + * correct answer for clear space and not a stub.

    + */ + default double columnDensityBetween(long seed, GalacticCoord from, GalacticCoord to) { + return 0d; + } + /** * The super-cell edge (in cells) this generator partitions space by — at most one system per * {@code minSpacingCells}-cube. The registry uses it to attribute member cells of AUTHORED systems and * to bound body-offset clamping ({@code radius <= minSpacingCells/2 - margin}). */ + /** + * The DERIVED retinue an AUTHORED system asks for — {@code count} major bodies from + * {@code (seed, anchor)}, avoiding {@code takenCells}. + * + *

    Default: none. A generator with no procedural content has no retinue to lend, and an + * authored system then holds exactly what its pack authored — which is the honest answer rather + * than a stub, and is what the {@code EmptyGalaxyGenerator} means.

    + */ + default java.util.List authoredRetinueFor(long seed, + zmaster587.advancedRocketry.space.GalacticCoord anchor, + zmaster587.advancedRocketry.api.dimension.solar.StellarBody star, int starId, int count, + java.util.Set takenCells) { + return java.util.Collections.emptyList(); + } + default int minSpacingCells() { return GalaxyGenConfig.DEFAULT_MIN_SPACING; } + + /** + * Every anchor seated inside the star TERRITORY that {@code cell} falls in — what one look of a + * survey owes the direction it is pointed in. + * + *

    A survey strides by the territory, because that is the cube that holds at most one system + * and walking finer would spend a whole sweep re-reading one system's own neighbourhood. But a + * generator is free to divide that cube further, and then a stride that samples ONE point of it + * reports a fraction of the sky and calls it the sky. So a look asks for the territory's + * contents rather than for the point's, and the resolution of the answer is the generator's own + * business rather than the surveyor's.

    + * + *

    {@code limit} is a refusal, not a truncation. A generator that would return more than + * {@code limit} anchors returns the single anchor at {@code cell} instead — the sampling a + * survey has always done inside a star cluster, where one look is a find and not a census. + * Returning the first {@code limit} of them would be worse than sampling: it would be a biased + * corner of the territory presented as its whole.

    + * + *

    Default: whatever {@link #anchorAt} answers, which is exactly right for a generator whose + * lattice has one seat per territory.

    + */ + default List anchorsInTerritory(long seed, + zmaster587.advancedRocketry.space.GalacticCoord cell, int limit) { + Optional anchor = anchorAt(seed, cell); + return anchor.isPresent() ? Collections.singletonList(anchor.get()) : Collections + .emptyList(); + } + + /** + * The tunables this generator was built from, when it has any — what a {@code } element + * would have to say to reproduce it, and what the save fingerprints so a later load can tell that + * the pack has been retuned underneath it. + * + *

    Empty is a real answer and not a stub: a generator with no parameters (the authored-anchors-only + * default, or one an addon fabricates from something other than this config) has nothing to write + * back, and a pack file that carried a {@code } section for it would describe a generator + * nobody installed. + */ + default Optional tuning() { + return Optional.empty(); + } + + /** + * How this generator's bodies are derived — the half of a world model that says WHAT a body is, + * where {@link #systemAt} says where it is. + * + *

    It hangs here rather than on the schema because the generator is what a schema selects, so a + * version picks a derivation by picking a generator, and anything outside the universe layer that + * needs a body's physics asks the generator that produced the body. The default is version 1's, + * which is the right answer for a generator that does not derive anything of its own. + */ + default IBodyDerivation derivation() { + return BodyDerivationV0.INSTANCE; + } + + /** + * The metric and expansion this generator measures with — how many cells a light year is, and how + * the whole thing grows. + * + *

    Beside {@link #derivation()} and for the same reason: a schema selects the laws by selecting a + * generator, and anything outside this package that must convert a length in THIS world's terms + * asks the world's generator rather than a global. The default is version 1's. + */ + default IUniverseLaws laws() { + return UniverseLawsV0.INSTANCE; + } + + /** + * The cell an authored anchor declared against {@code key} is measured FROM, or empty when this + * generator has no galaxies. + * + *

    What an authored {@link GalacticAnchor} is resolved against. The default is empty, and that + * is the right answer rather than a stub: a generator with no galaxy tier has nothing for a + * declaration to be LOCAL to, so a declared position is already absolute — which is exactly the + * behaviour an authored-only universe had before galaxies existed.

    + */ + default Optional declarationOriginOf(long seed, GalaxyKey key) { + return Optional.empty(); + } + + /** + * How far from its DECLARATION ORIGIN authored content is guaranteed to stay inside its galaxy, + * in light years, or {@code 0} when this generator has no galaxies (and therefore no wall). + */ + default double guaranteedAuthoredReachLy() { + return 0d; + } } diff --git a/src/main/java/zmaster587/advancedRocketry/universe/IUniverseLaws.java b/src/main/java/zmaster587/advancedRocketry/universe/IUniverseLaws.java new file mode 100644 index 000000000..1fd105695 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/IUniverseLaws.java @@ -0,0 +1,58 @@ +package zmaster587.advancedRocketry.universe; + +/** + * The METRIC and the EXPANSION — what a cell is worth in light years, and how the whole thing grows. + * + *

    One interface for both, because they are one thing. The expansion rate is expressed per + * tick, and a tick's worth of anything is a length, so {@code Cosmology}'s Hubble constant is derived + * through the metric's own conversion. Versioning them apart would let a build pair one release's + * metric with another's expansion, which is a universe neither of them describes. + * + *

    Why this is an instance and not the static class it forwards to. A released world model has + * to keep being derivable the way it was released, and a save re-derives everything untouched on every + * load. If the metric were global, a build that changed it would silently re-answer every existing + * world: an address a player wrote down would denote a different distance, with the same generator + * still running over it. Behind this seam the same build can hold a new metric for new worlds and the + * old one for the worlds that were made under it — which is the whole point of versioning the schema + * rather than the mod. + * + *

    What is deliberately NOT here. The lattice DEFAULTS ({@code DEFAULT_SPACING_CELLS}, + * {@code DEFAULT_GALAXY_SPACING_CELLS}) stay static: they only decide what a NEW world is given, and an + * existing world carries the numbers it was made with in its own {@code GalaxyGenConfig}. So do the + * drive-band constants, which price a machine rather than measure space — a rebalanced drive is a mod + * feature, and mod features are exactly what an old world is supposed to keep receiving. + * + *

    Implementations are pure and stateless: same arguments, same answer, for the life of the save. + */ +public interface IUniverseLaws { + + /** Cells spanned by {@code lightYears} — the chart metric, rounded down to whole cells. */ + long cellsForLightYears(double lightYears); + + /** The same conversion where a partial cell must not vanish (offsets rather than extents). */ + long cellsAt(double lightYears); + + /** What {@code cells} are worth in light years. */ + double lightYearsForCells(double cells); + + /** A speed quoted in km/s, in light years per tick. */ + double lightYearsPerTick(double kilometresPerSecond); + + /** Cells spanned by an orbital distance in Advanced Rocketry units. */ + long cellsForOrbitUnits(double orbitUnits); + + /** The inverse: what {@code cells} are worth in Advanced Rocketry orbital units. */ + double orbitUnitsForCells(long cells); + + /** The clear space a seat keeps inside a super-cell of {@code spacingCells}. */ + long seatMarginCells(long spacingCells); + + /** How far a primary's retinue reaches, given its radius in light years. */ + double retinueReachLy(double primaryRadiusLy); + + /** How much the universe has expanded by {@code tick}, as a factor on comoving distance. */ + double scaleFactorAt(long tick); + + /** The horizon a galaxy's peculiar drift is budgeted against, in ticks. */ + long driftHorizonTicks(); +} diff --git a/src/main/java/zmaster587/advancedRocketry/universe/LightYearVector.java b/src/main/java/zmaster587/advancedRocketry/universe/LightYearVector.java new file mode 100644 index 000000000..781e35c15 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/LightYearVector.java @@ -0,0 +1,89 @@ +package zmaster587.advancedRocketry.universe; + +import zmaster587.advancedRocketry.space.GalacticCoord; + +/** + * A position or displacement in LIGHT YEARS — the vocabulary the galaxy layer is written in. + * + *

    Why not blocks: an intergalactic position reaches 10¹² light years, which is 4·10²⁵ blocks and + * does not fit a {@code long}. Below the galaxy layer, blocks and a sectorised {@link GalacticCoord} + * are exactly right and stay so; above it, the honest type is a physical length in a {@code double}, + * whose relative precision is uniform at any magnitude. The conversion between the two lives in + * {@link UniverseScale} and nowhere else.

    + * + *

    Immutable value type.

    + */ +public final class LightYearVector { + + public static final LightYearVector ZERO = new LightYearVector(0d, 0d, 0d); + + private final double x; + private final double y; + private final double z; + + private LightYearVector(double x, double y, double z) { + this.x = x; + this.y = y; + this.z = z; + } + + public static LightYearVector of(double x, double y, double z) { + return new LightYearVector(x, y, z); + } + + /** The position a cell NAME stands at in the static frame, in light years. */ + /** + * A cell's position as a vector in light years, measured by {@code laws}. + * + *

    The metric is a PARAMETER because a cell is worth a different number of light years under a + * different schema, and this type is a plain value that must not decide which schema it belongs to. + */ + public static LightYearVector ofCell(GalacticCoord cell, IUniverseLaws laws) { + return new LightYearVector( + laws.lightYearsForCells(cell.sectorX()), + laws.lightYearsForCells(cell.sectorY()), + laws.lightYearsForCells(cell.sectorZ())); + } + + public double x() { + return x; + } + + public double y() { + return y; + } + + public double z() { + return z; + } + + public LightYearVector plus(LightYearVector other) { + return new LightYearVector(x + other.x, y + other.y, z + other.z); + } + + public LightYearVector minus(LightYearVector other) { + return new LightYearVector(x - other.x, y - other.y, z - other.z); + } + + public LightYearVector scale(double factor) { + return new LightYearVector(x * factor, y * factor, z * factor); + } + + /** Length in light years. */ + public double length() { + return Math.sqrt(x * x + y * y + z * z); + } + + /** Distance to {@code other} in light years. */ + public double distanceTo(LightYearVector other) { + double dx = other.x - x; + double dy = other.y - y; + double dz = other.z - z; + return Math.sqrt(dx * dx + dy * dy + dz * dz); + } + + @Override + public String toString() { + return "(" + x + ", " + y + ", " + z + ") ly"; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/universe/Nebula.java b/src/main/java/zmaster587/advancedRocketry/universe/Nebula.java new file mode 100644 index 000000000..7b0b70426 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/Nebula.java @@ -0,0 +1,194 @@ +package zmaster587.advancedRocketry.universe; + +/** + * A nebula: the diffuse cloud a star cluster is wrapped in. + * + *

    It is not seated separately, and that is the design. A molecular cloud, the young cluster + * that condenses out of it and the ancient cluster that has blown it away are ONE object at three + * ages — so a nebula is derived from a {@link StarCluster} and its type's residual gas, and there is + * no second lattice, no second spacing number and no way for a cloud and its cluster to disagree + * about where they are.

    + * + *

    What it is for today, and what it is a seam for

    + *

    Today a cluster is a pure refinement of the star lattice: it has no property anything outside it + * can observe, so it can only be discovered by counting stars. A nebula is what makes a cluster a + * LANDMARK rather than a statistical fact.

    + * + *

    {@link #densityAt} is the whole seam. Every consequence a nebula could ever have — a + * sensor it muffles, a drag it imposes, something it conceals, something a ship mines out of it — is a + * function of how thick it is at a point. That function exists now and is tested; what does NOT exist + * is any consumer of it, deliberately: none of those numbers is ratified, and inventing them + * alongside the thing they judge is how a mechanic ends up measuring itself.

    + * + *

    Diffuse matter is NOT a body

    + *

    A nebula has no cell name, is not a destination, and does not participate in one-real-body-per- + * cell. It is the same category as a system's comet cloud: attribution reads names, not matter, + * so a nebula may freely overlap whatever it lies across.

    + * + *

    Immutable value type.

    + */ +public final class Nebula { + + /** + * How much wider than its cluster a nebula reaches. Real clouds are far larger than the cluster + * inside them — Orion is about twelve light years across around a cluster of one. + */ + private static final double MIN_SPREAD = 1.5d; + private static final double MAX_SPREAD = 3d; + + /** Below this much residual gas a cluster has no cloud left worth drawing. */ + static final double MINIMUM_VISIBLE_GAS = 0.05d; + + /** + * What one unit of {@link #densityAt} integrated over one light year costs the light behind it, + * in magnitudes of visual extinction ({@code A_V}) — the unit astronomy measures dust in. + * + *

    A calibration, not a balance knob. It maps this model's dimensionless density onto a + * physical quantity, so moving it silently redefines every threshold expressed in magnitudes. The + * anchor: a TYPICAL dark cloud should come out at the classic opaque value, {@code A_V ~ 10} — + * the Barnard-object regime, a hole in the star field. These clouds are Gaussian with + * {@code s = radius/2}, so a ray through the centre integrates to {@code peak * s * sqrt(pi)}; + * for a representative dark cloud (radius ~30 ly, peak ~0.6) that is + * {@code 0.6 * 15 * 1.772 ~ 16} density-light-years, giving {@code 10/16 = 0.63}. Rounded to 0.6, + * and the rounding is deliberate: the anchor is itself "a typical cloud" and not a measurement of + * one particular object.

    + * + *

    For scale, once converted: {@code A_V ~ 1} is noticeable dimming, {@code ~5} is where faint + * objects behind a cloud disappear, {@code ~10} is opaque in the visible, and a real dense core + * (B68) reaches ~30.

    + */ + public static final double MAGNITUDES_PER_DENSITY_LIGHT_YEAR = 0.6d; + + /** A column of diffuse matter, in density-light-years, read as visual extinction in magnitudes. */ + public static double magnitudesForColumn(double columnDensityLightYears) { + return Math.max(0d, columnDensityLightYears) * MAGNITUDES_PER_DENSITY_LIGHT_YEAR; + } + + /** + * What a nebula looks like — DERIVED from how much gas is left, never drawn, because the three + * appearances are one age sequence and not three options. + * + *

    Youngest first: the cloud is dark and molecular while its stars are still forming inside it; + * once they are burning, the hottest of them ionise what is left and it emits; once the gas is + * blown clear, the remaining dust merely reflects.

    + */ + public enum Appearance { + /** Thick and cold: it blocks the light behind it rather than making any of its own. */ + DARK, + /** Ionised by the stars inside it, and shining because of them. */ + EMISSION, + /** Thin dust, lit by whatever is nearby. */ + REFLECTION + } + + /** The metric this object was seated under — its schema's, never a global one. */ + private final IUniverseLaws laws; + private final StarCluster cluster; + private final Appearance appearance; + private final double centreXLy; + private final double centreYLy; + private final double centreZLy; + private final double radiusLy; + private final double peakDensity; + + public Nebula(StarCluster cluster, Appearance appearance, double centreXLy, double centreYLy, + double centreZLy, double radiusLy, double peakDensity, IUniverseLaws laws) { + this.laws = (laws == null) ? UniverseLawsV0.INSTANCE : laws; + this.cluster = cluster; + this.appearance = appearance; + this.centreXLy = centreXLy; + this.centreYLy = centreYLy; + this.centreZLy = centreZLy; + this.radiusLy = Math.max(0.01d, radiusLy); + this.peakDensity = Math.min(1d, Math.max(0d, peakDensity)); + } + + /** The cluster this cloud belongs to — the same object at a different age. */ + public StarCluster cluster() { + return cluster; + } + + public Appearance appearance() { + return appearance; + } + + /** Its centre in light years, in the static frame. It shares its cluster's. */ + public double centreXLy() { + return centreXLy; + } + + public double centreYLy() { + return centreYLy; + } + + public double centreZLy() { + return centreZLy; + } + + /** How far it reaches, in light years. Wider than the cluster inside it. */ + public double radiusLy() { + return radiusLy; + } + + /** How thick it is at its densest, {@code 0}..{@code 1}. */ + public double peakDensity() { + return peakDensity; + } + + /** + * How thick this nebula is at a point, {@code 0}..{@code 1} — zero outside its radius. + * + *

    This is the seam. A Gaussian falloff, so a cloud has no edge to see: it thins out, + * which is what diffuse matter does and what any consequence built on it will want. The + * appearance decides how it is drawn; this decides how much of it there is.

    + */ + public double densityAt(double xLy, double yLy, double zLy) { + double dx = xLy - centreXLy; + double dy = yLy - centreYLy; + double dz = zLy - centreZLy; + double rSq = dx * dx + dy * dy + dz * dz; + if (rSq > radiusLy * radiusLy) { + return 0d; + } + double scale = radiusLy / 2d; + return peakDensity * Math.exp(-rSq / (scale * scale)); + } + + /** The same reading at a cell name — the form the rest of the layer asks in. */ + public double densityAtSector(long sectorX, long sectorY, long sectorZ) { + return densityAt(laws.lightYearsForCells(sectorX), + laws.lightYearsForCells(sectorY), + laws.lightYearsForCells(sectorZ)); + } + + /** Whether a point is inside this nebula at all. */ + public boolean contains(double xLy, double yLy, double zLy) { + double dx = xLy - centreXLy; + double dy = yLy - centreYLy; + double dz = zLy - centreZLy; + return dx * dx + dy * dy + dz * dz <= radiusLy * radiusLy; + } + + /** + * How wide a cloud of {@code spread} reaches around a cluster of {@code clusterRadiusLy}, and how + * dense it is at its centre, given the residual gas. Static so the seating code and a test can + * agree without one of them re-deriving it. + */ + static double spreadFor(double fraction) { + return MIN_SPREAD + Math.min(1d, Math.max(0d, fraction)) * (MAX_SPREAD - MIN_SPREAD); + } + + /** The appearance a cloud with this much gas left has. One number, three ages, in order. */ + static Appearance appearanceFor(double fraction) { + if (fraction >= 0.7d) { + return Appearance.DARK; + } + return fraction >= 0.3d ? Appearance.EMISSION : Appearance.REFLECTION; + } + + @Override + public String toString() { + return "Nebula[" + appearance + " r=" + (long) radiusLy + "ly d=" + peakDensity + + " around " + cluster + "]"; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/universe/NebulaField.java b/src/main/java/zmaster587/advancedRocketry/universe/NebulaField.java new file mode 100644 index 000000000..06f27bc8f --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/NebulaField.java @@ -0,0 +1,204 @@ +package zmaster587.advancedRocketry.universe; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import zmaster587.advancedRocketry.space.GalacticCoord; + +/** + * Where the nebulae are — which is: wherever a star cluster still has gas. + * + *

    There is no nebula lattice and no nebula spacing. A cloud is derived from the cluster it wraps + * and that cluster's residual gas, so the two can never disagree about where they are, and adding + * nebulae cost the generator no new partition, no new occupancy draw and no new number to invent. + * A cloud with no stars in it is expressible too — it is a cluster type whose subdivision is 1.

    + * + *

    This class is a SEAM, and it has no consumer yet

    + *

    What a nebula DOES to a ship that flies into it — muffled sensors, drag, concealment, something + * to mine — is deliberately not here. None of those numbers is ratified, and building a mechanic + * beside the criteria that would judge it is how a mechanic comes to measure itself. What is here is + * everything such a mechanic would need: where the clouds are, how big they are, and + * {@link Nebula#densityAt} for how thick one is at a point.

    + */ +public final class NebulaField { + + private static final long SALT_NEBULA_GAS = 0x301L; + private static final long SALT_NEBULA_SPREAD = 0x302L; + + /** How much a cluster's residual gas may vary from the figure its type states. */ + private static final double GAS_VARIATION = 0.35d; + + /** Step of the column integral, in light years. A cloud is tens across, so its profile is resolved. */ + private static final double COLUMN_SAMPLE_STEP_LY = 1d; + + /** Ceiling on that integral's samples: a bound on WORK, not a statement about the sky. */ + private static final int MAX_COLUMN_SAMPLES = 512; + + private final GalaxyGenConfig config; + /** The metric this field measures with — its schema's, not a global one. */ + private final IUniverseLaws laws; + private final ClusterField clusters; + + public NebulaField(GalaxyGenConfig config, ClusterField clusters, IUniverseLaws laws) { + this.laws = (laws == null) ? UniverseLawsV0.INSTANCE : laws; + this.config = (config == null) ? GalaxyGenConfig.defaults() : config; + this.clusters = clusters; + } + + /** + * The cloud wrapping this cluster, or empty when it has none left. + * + *

    An ancient globular has blown its gas away and gets nothing; a molecular cloud is all gas and + * no stars; the open clusters between them are the interesting middle.

    + */ + public Optional nebulaOf(long seed, StarCluster cluster) { + if (cluster == null) { + return Optional.empty(); + } + double stated = cluster.type().nebulaFraction; + if (!(stated > 0d)) { + return Optional.empty(); + } + // The type says how gassy its age is; the draw says how gassy THIS one is. + double swing = (CellHash.of(seed, cluster.centreSuperX(), cluster.centreSuperY(), + cluster.centreSuperZ(), SALT_NEBULA_GAS) >>> 11) * 0x1.0p-53; + double gas = Math.min(1d, Math.max(0d, stated + (swing - 0.5d) * 2d * GAS_VARIATION)); + if (gas < Nebula.MINIMUM_VISIBLE_GAS) { + return Optional.empty(); + } + + double spreadRoll = CellHash.norm(CellHash.of(seed, cluster.centreSuperX(), + cluster.centreSuperY(), cluster.centreSuperZ(), SALT_NEBULA_SPREAD)); + double clusterRadiusLy = laws.lightYearsForCells( + (double) cluster.radiusSuperCells() * config.minSpacing); + double radiusLy = clusterRadiusLy * Nebula.spreadFor(spreadRoll); + + long s = config.minSpacing; + return Optional.of(new Nebula(cluster, Nebula.appearanceFor(gas), + laws.lightYearsForCells((double) cluster.centreSuperX() * s), + laws.lightYearsForCells((double) cluster.centreSuperY() * s), + laws.lightYearsForCells((double) cluster.centreSuperZ() * s), + radiusLy, gas, laws)); + } + + /** The cloud covering this coarse super-cell, if a cluster covers it and still has one. */ + public Optional nebulaAt(long seed, Galaxy galaxy, long supX, long supY, long supZ) { + Optional cluster = clusters.clusterAt(seed, galaxy, supX, supY, supZ); + return cluster.isPresent() ? nebulaOf(seed, cluster.get()) : Optional.empty(); + } + + /** + * Every nebula seated in the box of coarse super-cells {@code [min, max]} — what a render or a + * long-range scan asks, because a cloud is meant to be seen from OUTSIDE it. + * + *

    Enumerated over the CLUSTER lattice rather than per super-cell, so the cost is the number of + * cluster cells the box crosses and not its volume.

    + */ + public List nebulaeInRegion(long seed, Galaxy galaxy, long supMinX, long supMinY, + long supMinZ, long supMaxX, long supMaxY, long supMaxZ) { + List out = new ArrayList<>(); + if (galaxy == null) { + return out; + } + long spacing = clusters.spacingSuperCells(); + // A cloud reaches beyond its own cluster cell, so the sweep widens by one cell each way. + for (long cx = Math.floorDiv(supMinX, spacing) - 1L; + cx <= Math.floorDiv(supMaxX, spacing) + 1L; cx++) { + for (long cy = Math.floorDiv(supMinY, spacing) - 1L; + cy <= Math.floorDiv(supMaxY, spacing) + 1L; cy++) { + for (long cz = Math.floorDiv(supMinZ, spacing) - 1L; + cz <= Math.floorDiv(supMaxZ, spacing) + 1L; cz++) { + Optional cluster = clusters.clusterAtIndex(seed, galaxy, cx, cy, cz); + if (!cluster.isPresent()) { + continue; + } + Optional nebula = nebulaOf(seed, cluster.get()); + if (nebula.isPresent()) { + out.add(nebula.get()); + } + } + } + } + // The nucleus is not on the cluster lattice, so it is asked for separately — the same + // exception the cluster tier already makes for it. + Optional nucleus = clusters.nucleusOf(seed, galaxy); + if (nucleus.isPresent()) { + Optional core = nebulaOf(seed, nucleus.get()); + if (core.isPresent()) { + out.add(core.get()); + } + } + return out; + } + + /** + * How much diffuse matter lies ALONG A LINE, in density-light-years — the integral of + * {@link #densityAtSector} from one cell to another. + * + *

    Built once, on purpose. Every consequence of a cloud that involves LOOKING is this + * number: what a survey loses to a cloud between it and its target, and what a ship inside one + * loses looking out, are the same integral with the endpoints moved. Two functions computing it + * would drift in the third decimal and nobody would notice for months.

    + * + *

    Sampled rather than solved. A closed form exists for one Gaussian, but the line crosses an + * arbitrary set of clouds seated on a lattice, and the sampled form stays correct when the + * profile changes. The step is a light year — a cloud is tens of them across, so its profile is + * resolved many times over — and the sample count is bounded, which is a bound on WORK and not a + * physical statement.

    + */ + public double columnDensityBetween(long seed, Galaxy galaxy, GalacticCoord from, + GalacticCoord to) { + if (galaxy == null || from == null || to == null) { + return 0d; + } + GalacticCoord a = from.cellCentre(); + GalacticCoord b = to.cellCentre(); + double ax = laws.lightYearsForCells(a.sectorX()); + double ay = laws.lightYearsForCells(a.sectorY()); + double az = laws.lightYearsForCells(a.sectorZ()); + double bx = laws.lightYearsForCells(b.sectorX()); + double by = laws.lightYearsForCells(b.sectorY()); + double bz = laws.lightYearsForCells(b.sectorZ()); + double dx = bx - ax, dy = by - ay, dz = bz - az; + double lengthLy = Math.sqrt(dx * dx + dy * dy + dz * dz); + if (lengthLy <= 0d) { + return 0d; + } + + int samples = (int) Math.max(2L, Math.min(MAX_COLUMN_SAMPLES, + Math.round(lengthLy / COLUMN_SAMPLE_STEP_LY) + 1L)); + double step = lengthLy / (samples - 1); + double sum = 0d; + for (int i = 0; i < samples; i++) { + double t = i / (double) (samples - 1); + double density = densityAtLightYears(seed, galaxy, ax + dx * t, ay + dy * t, az + dz * t); + // Trapezoid: the endpoints are half-weighted, so the answer does not depend on which + // end the walk started from. + sum += (i == 0 || i == samples - 1) ? density * 0.5d : density; + } + return sum * step; + } + + /** The density at a point stated in light years — what the line integral samples. */ + public double densityAtLightYears(long seed, Galaxy galaxy, double xLy, double yLy, double zLy) { + long s = config.minSpacing; + long sectorX = laws.cellsAt(xLy); + long sectorY = laws.cellsAt(yLy); + long sectorZ = laws.cellsAt(zLy); + Optional nebula = nebulaAt(seed, galaxy, Math.floorDiv(sectorX, s), + Math.floorDiv(sectorY, s), Math.floorDiv(sectorZ, s)); + return nebula.isPresent() ? nebula.get().densityAt(xLy, yLy, zLy) : 0d; + } + + /** + * How much diffuse matter lies at this cell, {@code 0}..{@code 1} — the one query a consequence + * would be written against, whatever the consequence turns out to be. + */ + public double densityAtSector(long seed, Galaxy galaxy, long sectorX, long sectorY, long sectorZ) { + long s = config.minSpacing; + Optional nebula = nebulaAt(seed, galaxy, Math.floorDiv(sectorX, s), + Math.floorDiv(sectorY, s), Math.floorDiv(sectorZ, s)); + return nebula.isPresent() ? nebula.get().densityAtSector(sectorX, sectorY, sectorZ) : 0d; + } +} 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..6e7546cb4 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/PlanetDerivation.java @@ -0,0 +1,502 @@ +package zmaster587.advancedRocketry.universe; + +import java.util.function.DoubleToIntFunction; + +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

    + *
      + *
    1. 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.
    2. + *
    3. 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.
    4. + *
    5. Bare temperature at that radius, with NO atmosphere. The snow line is this temperature + * crossing a threshold — never a separate parameter.
    6. + *
    7. 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.
    8. + *
    9. Pressure, from the world's ability to hold an atmosphere against its own heat — heavy and + * cold retains, light and hot does not.
    10. + *
    11. 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.
    12. + *
    13. 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.
    14. + *
    15. 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.
    16. + *
    + * + *

    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; + 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 + * 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; + + /** + * The surface temperature of an Earth-gravity world lit by NOTHING, in kelvin: what its own + * internal heat alone holds it at. + * + *

    Measured rather than picked. Earth's geothermal flux is 0.087 W/m²; a black body radiating + * that sits at {@code (F/σ)^¼ = 35 K}. Since the flux a world leaks scales with its mass over its + * area, and {@code M/R²} is exactly the surface gravity this derivation already computes, a + * starless world's temperature is {@code 35 K · g^¼} — one law, anchored on a real measurement, + * reusing a quantity that is already there rather than introducing a second size-to-heat + * relation.

    + * + *

    What it does not model: a young giant is far hotter than this, because most of its + * heat is gravitational contraction rather than leftover formation heat — Jupiter's own flux is + * sixty times Earth's, and it would come out at 124 K rather than the 45 K this gives. That is an + * age term, and nothing in this layer knows a body's age.

    + */ + private static final double RESIDUAL_TEMPERATURE_K = 35d; + /** {@code T ∝ F^¼} for a black body, and the flux goes as the gravity. */ + private static final double RESIDUAL_TEMPERATURE_EXPONENT = 0.25d; + + 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 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) { + 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. + * + *

    Two floors, and they answer different questions. {@code MIN_DISTANCE} is what the body + * FORMAT can express; {@link AstronomicalBodyHelper#MIN_ADDRESSABLE_ORBIT_UNITS} is what the + * universe can ADDRESS — one cell's worth of orbit, below which a body shares its star's cell and + * is silently dropped in the seat race rather than becoming an ambiguous destination. A dim + * star's zone can sit entirely inside that radius, so without this floor its innermost world is + * generated and then lost, which reads as "the generator drops bodies" and is really "the cell is + * the resolution".

    + */ + public static double innerOrbit(StellarBody star) { + return Math.max( + Math.max(DimensionProperties.MIN_DISTANCE, + AstronomicalBodyHelper.MIN_ADDRESSABLE_ORBIT_UNITS), + 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); + } + + // 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) { + 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); + // A world's ALBEDO is a property of its surface, its surface is what its TYPE says it is, and + // the type is admitted by temperature — so the temperature is not one number here but a + // FUNCTION of albedo, and each candidate type is admitted at the temperature the world would + // have if it were that type. Evaluated once per candidate; nothing iterates, and the physics + // stays here rather than moving into the table. + // + // While this was a single neutral-albedo reading, the derivation and the dimension model + // answered one question with two numbers: a `greenhouse` world (albedo 0.75) was reported + // 22.7 % warmer than it turned out to be and an `ice` world 13 % (ledger #289). + final int orbit = Math.max(1, orbitalDistance); + DoubleToIntFunction temperatureForAlbedo = + albedo -> AstronomicalBodyHelper.getAverageTemperature(star, orbit, pressure, albedo); + + PlanetTypePreset preset = PlanetTypes.drawType(pressure, temperatureForAlbedo, gravityPercent, + giant, CellHash.ofBody(seed, key, variant, SALT_TYPE)); + int temperature = temperatureForAlbedo.applyAsInt( + preset == null ? AstronomicalBodyHelper.EARTH_ALBEDO : preset.albedo()); + 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); + + 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, spin); + } + + /** + * The full profile of a world with NO STAR — a {@link SystemBodyKind#ROGUE_PLANET}, the commonest + * thing there is to meet in the intergalactic void. + * + *

    Half of {@link #derive}'s order simply does not apply, and that is the interesting part rather + * than a gap to be filled with defaults. There is no metallicity inherited from a parent star, no + * orbital radius, no insolation, no snow line to sit inside or outside of, and no tidal lock. What + * is left is the world's own bulk and its own leftover heat, so a rogue is derived from those and + * from nothing else.

    + * + *

    Its atmosphere is on the ground. A rocky rogue sits at a few tens of kelvin, where every + * volatile it ever had is frozen solid, so it reads at minimum pressure however well its gravity + * could have held a gas — the retention law answers "could it keep this gas hot" and the answer here + * is that there is no gas left to keep. A body massive enough to have accreted hydrogen keeps it, + * because hydrogen does not freeze at these temperatures, and that is the one case that comes out + * thick.

    + * + *

    One kind, whatever its bulk. A rogue that accreted like a giant is still a + * {@code ROGUE_PLANET} and not a {@link SystemBodyKind#GAS_GIANT}: that kind exists to say "a + * destination with a dimension and no surface", which is a statement about realization, and a rogue + * is not realized into a dimension yet. Its bulk is in the profile for anything that wants it.

    + * + * @param variant disambiguates bodies SHARING a cell — the rogue itself is 0 and its moons follow + * @param giantFraction how many unbound worlds kept hydrogen; see + * {@code GalaxyGenConfig.RogueTuning.giantFraction}. It is NOT the outer-zone + * chance a bound body past the snow line gets — what unbinds a planet is a + * scattering encounter, and a giant is the body doing the scattering + */ + public static BodyProfile deriveRogue(long seed, GalacticCoord bodyCell, int variant, + double giantFraction) { + GalacticCoord key = bodyCell.cellCentre(); + // Its own draw, because it has no star to have inherited one from. A rogue formed in some + // system and carries that system's metals; which system is not a thing this layer can know. + double metallicity = metallicityOf(seed, key); + // Its OWN rate, and the difference from a bound body's is the physics: a world past the frost + // line accretes a giant about a third of the time, while a world thrown out of its system is + // overwhelmingly one of the light ones — the giant is what did the throwing. + boolean bulky = CellHash.norm(CellHash.ofBody(seed, key, variant, SALT_GIANT)) < giantFraction; + + double radius = radiusOf(seed, key, variant, bulky, false); + double mass = massOf(seed, key, variant, radius, bulky); + int gravityPercent = gravityPercentOf(mass, radius); + int pressure = bulky ? DimensionProperties.MAX_ATM_PRESSURE : DimensionProperties.MIN_ATM_PRESSURE; + int temperature = residualTemperature(mass, radius); + + // Albedo does not enter here, and that is a statement rather than a shortcut: albedo is the + // fraction of INCIDENT light a surface throws back, and nothing shines on this world. Its heat + // is its own, so every candidate type is admitted at the same temperature. + PlanetTypePreset preset = PlanetTypes.drawType(pressure, albedo -> temperature, gravityPercent, + bulky, CellHash.ofBody(seed, key, variant, SALT_TYPE)); + TerrainOption terrain = PlanetTypes.drawTerrain(preset, + CellHash.ofBody(seed, key, variant, SALT_TERRAIN)); + + boolean rings = CellHash.norm(CellHash.ofBody(seed, key, variant, SALT_RINGS)) + < (bulky ? RING_CHANCE_GIANT : RING_CHANCE_ROCKY); + int spin = rotationalPeriodOf(seed, key, variant, bulky); + + // No oxygen: free oxygen is biology, and it is also a GAS — a world whose air is lying on it as + // ice has none of either. No tidal lock: there is nothing to be locked to. + return new BodyProfile(SystemBodyKind.ROGUE_PLANET, + preset == null ? PlanetTypes.UNCLASSIFIED : preset.name(), preset, + SystemBody.ORBIT_UNKNOWN, mass, radius, gravityPercent, pressure, temperature, + false, false, rings, metallicity, terrain, spin); + } + + /** + * What a world with no star sits at, in kelvin: its own internal heat and nothing else. + * + *

    {@code 35 K · g^¼}, with {@code g = M/R²} in Earth units — see + * {@link #RESIDUAL_TEMPERATURE_K} for where the anchor comes from and what it leaves out.

    + */ + public static int residualTemperature(double massEarths, double radiusEarths) { + double gravity = massEarths / Math.max(1e-6d, radiusEarths * radiusEarths); + double kelvin = RESIDUAL_TEMPERATURE_K + * Math.pow(Math.max(1e-6d, gravity), RESIDUAL_TEMPERATURE_EXPONENT); + return (int) Math.max(1L, Math.round(kelvin)); + } + + /** + * 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 ─────────────────────────────────────────────────── + + 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..e90aba161 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/PlanetRealizer.java @@ -0,0 +1,286 @@ +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

    + *
      + *
    1. 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.
    2. + *
    3. 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.
    4. + *
    5. 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.
    6. + *
    7. 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.
    8. + *
    + * + *

    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; + SystemBody parentBody = 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; + } + // 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. + 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 = UniverseRegistry.getGenerator().derivation() + .derive(registry.worldSeed(), anchor, target.name(), variant, + star, target.kind() == SystemBodyKind.MOON, target.orbitalDistance()); + DimensionProperties props = materialize(dimId, profile, star, target, parentBody); + + 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, + 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 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()); + // STATED, never recomputed: the profile's number is the one a telescope already reported, and + // materialization is the moment it becomes the world's. The albedo is applied below, and after + // the derivation's second pass a recompute would reproduce this exact value anyway — which is + // the invariant, not a coincidence to lean on. + props.setAverageTemp(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) { + // 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()); + } + 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.getMass()); + double ticks = days * AstronomicalBodyHelper.TICKS_PER_DAY; + if (!(ticks > 0d) || ticks > Integer.MAX_VALUE) { + return Integer.MAX_VALUE; + } + return (int) ticks; + } + // 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(); + } + + // 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/PlanetTypePreset.java b/src/main/java/zmaster587/advancedRocketry/universe/PlanetTypePreset.java new file mode 100644 index 000000000..bd4c62ae7 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/PlanetTypePreset.java @@ -0,0 +1,304 @@ +package zmaster587.advancedRocketry.universe; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import zmaster587.advancedRocketry.util.AstronomicalBodyHelper; +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 final double albedo; + + 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; + 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. */ + 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 double albedo = AstronomicalBodyHelper.EARTH_ALBEDO; + + 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; + } + + /** 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 new file mode 100644 index 000000000..27ebbbdc7 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/PlanetTypes.java @@ -0,0 +1,336 @@ +package zmaster587.advancedRocketry.universe; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.DoubleToIntFunction; +import java.util.function.Predicate; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import zmaster587.advancedRocketry.util.AstronomicalBodyHelper; + +/** + * 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). + * + *

    Each candidate is tested at the temperature the world would have IF IT WERE THAT TYPE. + * A preset states its surface, a surface has an albedo, and the albedo is part of what sets the + * temperature — so admitting every candidate at one temperature and then applying the winner's + * albedo produced worlds outside their own declared band: an {@code ocean} preset admitting + * 255–380 K would be handed to a world that its own albedo of 0.10 then warms to 393 K.

    + * + *

    It is not circular and it does not iterate: the caller hands in a FUNCTION from albedo to + * temperature, so each candidate is evaluated once, against its own number. That also keeps the + * LAW out of this class — it stays a table matcher and never learns what a star is or how one + * warms a world.

    + * + * @param temperatureForAlbedo what this world's surface temperature would be at a given albedo + */ + public static List candidates(int pressure, + DoubleToIntFunction temperatureForAlbedo, + int gravityPercent, boolean gasGiant) { + List out = new ArrayList<>(); + for (PlanetTypePreset p : presets) { + if (p.admits(pressure, temperatureForAlbedo.applyAsInt(p.albedo()), 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, + DoubleToIntFunction temperatureForAlbedo, + int gravityPercent, boolean gasGiant, long hash) { + List admitting = candidates(pressure, temperatureForAlbedo, gravityPercent, + gasGiant); + if (admitting.isEmpty()) { + // Reported at the NEUTRAL reading, which is the one number that describes the world rather + // than one of the types that declined it — an author widening a range needs to know where + // the world actually sits, not where the last candidate would have put it. + int neutral = temperatureForAlbedo.applyAsInt(AstronomicalBodyHelper.EARTH_ALBEDO); + if (SystemContent.reportOnce("noPlanetType:" + gasGiant + ':' + pressure / 50 + ':' + + neutral / 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, neutral, 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").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)) + .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").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)) + .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").albedo(0.10d).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").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)) + .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").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").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").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") + .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").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") + .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").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") + .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").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").albedo(0.50d).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/PlanetarySystem.java b/src/main/java/zmaster587/advancedRocketry/universe/PlanetarySystem.java new file mode 100644 index 000000000..7c14274e9 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/PlanetarySystem.java @@ -0,0 +1,107 @@ +package zmaster587.advancedRocketry.universe; + +import java.util.Optional; + +import zmaster587.advancedRocketry.api.dimension.solar.StellarBody; + +/** + * An immutable query-time handle to what stands at one anchor cell, returned by the + * {@link UniverseRegistry} and {@link IGalaxyGenerator}. It carries deliberately no coordinate: + * a system is LOCATION-AGNOSTIC and its galactic address is owned solely by the registry + * (universe-model.md §2/§10). + * + *

    An anchor holds a PRIMARY BODY, and the primary need not be a star

    + *

    Most of them are: a star, its planets and its companion sub-stars, reusing the existing + * {@link StellarBody} content object. Out in the intergalactic void the commonest thing there is to + * meet is a world that was thrown out of the system it formed in, and it anchors a system of its own — + * it may keep moons, it has an address, and a telescope finds it exactly as it finds a star.

    + * + *

    So {@link #primaryKind()} says WHAT is here and {@link #star()} is an {@link Optional}, which is + * the point of the shape: a caller has to decide what it does about a system with no star instead of + * receiving a {@code null} or — worse — a 30 K, zero-radius {@code StellarBody} whose arithmetic comes + * out right while its name is a lie. The alternative shapes were both rejected for that reason: a + * nullable star hides the decision, and a rogue path of its own would duplicate the whole + * {@code coord → system → bodies} chain.

    + * + *

    Identity is the system's int id — the primary star's id where the primary IS a star, so a whole + * multi-star system shares one id and sub-stars mirror the primary.

    + */ +public final class PlanetarySystem { + + private final SystemBodyKind primaryKind; + /** The primary, when it is a star. {@code null} for a system whose primary is not one. */ + private final StellarBody star; + private final int id; + private final String name; + + private PlanetarySystem(SystemBodyKind primaryKind, StellarBody star, int id, String name) { + this.primaryKind = primaryKind; + this.star = star; + this.id = id; + this.name = name == null ? "" : name; + } + + /** The ordinary case: a system anchored on a star, with its planets and companions. */ + public static PlanetarySystem ofStar(StellarBody star) { + if (star == null) { + throw new NullPointerException("star"); + } + return new PlanetarySystem(SystemBodyKind.STAR, star, star.getId(), star.getName()); + } + + /** + * A system anchored on a starless world — a {@link SystemBodyKind#ROGUE_PLANET}. + * + *

    It carries an id and a name and nothing else, because there is nothing else to carry: a + * rogue's physics is derived from {@code (seed, cell)} by {@link PlanetDerivation} exactly as + * every other procedural world's is, and it has no star whose temperature or size anything here + * would have to remember.

    + */ + public static PlanetarySystem ofRogue(int id, String name) { + return new PlanetarySystem(SystemBodyKind.ROGUE_PLANET, null, id, name); + } + + /** What stands at this system's anchor — {@link SystemBodyKind#STAR} or a starless world. */ + public SystemBodyKind primaryKind() { + return primaryKind; + } + + /** + * The reused content object — the primary star plus its planets and companion sub-stars — or empty + * when this system's primary is not a star. + */ + public Optional star() { + return Optional.ofNullable(star); + } + + /** The system id (the primary star's id, where the primary is a star). */ + public int systemId() { + return id; + } + + /** What this system is called — the star's name, or the rogue's designation. */ + public String name() { + return name; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof PlanetarySystem)) { + return false; + } + return id == ((PlanetarySystem) o).id; + } + + @Override + public int hashCode() { + return id; + } + + @Override + public String toString() { + return "PlanetarySystem[" + primaryKind + " id=" + id + ", name=" + name + "]"; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/universe/RegionScan.java b/src/main/java/zmaster587/advancedRocketry/universe/RegionScan.java index 21f07954b..def8b9dfe 100644 --- a/src/main/java/zmaster587/advancedRocketry/universe/RegionScan.java +++ b/src/main/java/zmaster587/advancedRocketry/universe/RegionScan.java @@ -6,14 +6,30 @@ import zmaster587.advancedRocketry.space.GalacticCoord; /** - * A telescope's survey of one region of the galaxy: which box of sectors it covers, how far through - * it the instrument has got, and when the next batch of cells is resolved. + * A telescope's survey: which sky it covers, how far through it the instrument has got, and when the + * next batch of looks is resolved. * - *

    A survey sweeps. It walks its region cell by cell, a bounded number of cells per step, - * writing what each one holds as it goes — an operator points the instrument at a patch of sky once - * and the machine works through it, rather than being re-aimed by hand for every cell. That bound is - * what keeps a procedurally endless universe from being enumerated in a tick; the reach bound keeps - * the patch inside the local cluster.

    + *

    A survey sweeps. It walks its looks a bounded number at a time, writing what each one + * holds as it goes — an operator points the instrument at a patch of sky once and the machine works + * through it, rather than being re-aimed by hand for every cell. That bound is what keeps a + * procedurally endless universe from being enumerated in a tick.

    + * + *

    Two shapes, and they are two different instruments.

    + *
      + *
    • A pointing ({@link #directed}) is a {@link ConeWalk}: an apex at the observatory, a + * direction, a half-angle, and a reach that comes from what the instrument can SEE rather than + * from a configured horizon. This is the telescope.
    • + *
    • A local radar ({@link #local}) is a box of the star territories around the + * observatory's own. This is the passive watch over the neighbourhood: near sky, no aiming, and + * its data ready.
    • + *
    + * + *

    It samples, it does not enumerate. Between two looks of a pointing lies a whole star's + * territory — the sweep strides by {@link Tuning#strideCells()}, the edge of the cube that holds at + * most one system. Walking cell by cell would spend a whole sweep re-reading one system's own + * neighbourhood, since every cell of a system's territory resolves to that same system. What a look + * OWES its territory — one seat, or all of the sub-seats a cluster divides it into — is the resolving + * side's business and lives in {@link TelescopeScan}, not here.

    * *

    Each step is a deadline, never a counter: the tick the next batch lands is stored, so a * survey whose observatory unloads mid-sweep resumes exactly where it stood, owing no replay.

    @@ -25,109 +41,193 @@ public final class RegionScan { private static final String KEY_MIN = "min"; private static final String KEY_MAX = "max"; - private static final String KEY_DISTANCE = "dist"; + private static final String KEY_DISTANCE = "distCells"; + private static final String KEY_STRIDE = "stride"; private static final String KEY_START = "start"; private static final String KEY_STEP_DEADLINE = "stepDeadline"; private static final String KEY_CELLS_DONE = "cellsDone"; private static final String KEY_CELLS_PER_STEP = "cellsPerStep"; private static final String KEY_TICKS_PER_STEP = "ticksPerStep"; + private static final String KEY_CONE = "cone"; + /** The pointing this survey walks, or {@code null} for the box-shaped local radar. */ + private final ConeWalk cone; private final GalacticCoord min; private final GalacticCoord max; - private final int distanceSectors; + private final long distanceCells; + private final long strideCells; private final long startTick; private final long stepDeadline; private final int cellsDone; private final int cellsPerStep; private final int ticksPerStep; - - private RegionScan(GalacticCoord min, GalacticCoord max, int distanceSectors, long startTick, - long stepDeadline, int cellsDone, int cellsPerStep, int ticksPerStep) { - this.min = min; - this.max = max; - this.distanceSectors = distanceSectors; + private final int totalCells; + + private RegionScan(ConeWalk cone, GalacticCoord min, GalacticCoord max, long distanceCells, + long strideCells, long startTick, long stepDeadline, int cellsDone, + int cellsPerStep, int ticksPerStep) { + this.cone = cone; + this.distanceCells = Math.max(0L, distanceCells); + this.strideCells = Math.max(1L, strideCells); this.startTick = startTick; this.stepDeadline = stepDeadline; this.cellsDone = cellsDone; this.cellsPerStep = Math.max(1, cellsPerStep); this.ticksPerStep = Math.max(0, ticksPerStep); + if (cone == null) { + this.min = min; + this.max = max; + this.totalCells = countLooks(min, max, this.strideCells); + } else { + // The corners a cone reports are its BOUNDING BOX and nothing it promises to fill: they + // exist because a survey is asked "roughly where are you looking" by the status read-out + // and by the obscured-count probe, and a cone has no corners of its own to answer with. + long reach = cone.reachCells(); + this.min = GalacticCoord.ofSectorLocal(cone.apex().sectorX() - reach, + cone.apex().sectorY() - reach, cone.apex().sectorZ() - reach, 0L, 0L, 0L); + this.max = GalacticCoord.ofSectorLocal(cone.apex().sectorX() + reach, + cone.apex().sectorY() + reach, cone.apex().sectorZ() + reach, 0L, 0L, 0L); + this.totalCells = cone.totalLooks(); + } + } + + /** + * How many looks the region between two corners holds at {@code stride} — computed once, + * here, and REFUSED rather than clamped when it will not fit an {@code int}. + * + *

    A survey is walked by an {@code int} cursor, so a region with more looks than an {@code int} + * can index is not a long survey: it is one that would report itself complete at 2·10⁹ looks with + * the rest of the region never visited, and progress would read 100 % while the sky was untouched. + * That was unreachable while a scan's reach was a few hundred cells and becomes reachable the + * moment survey ranges grow with the galaxy, so the bound is stated where the survey is built.

    + * + *

    The product is checked in {@code double} first: the three counts are {@code long}s and their + * product overflows one long before it passes an {@code int}, so multiplying to find out would be + * the same silent wrap in a different place. Fifty-three bits of mantissa is far more than a + * comparison against 231 needs.

    + */ + private static int countLooks(GalacticCoord min, GalacticCoord max, long stride) { + long x = countAlong(min.sectorX(), max.sectorX(), stride); + long y = countAlong(min.sectorY(), max.sectorY(), stride); + long z = countAlong(min.sectorZ(), max.sectorZ(), stride); + if ((double) x * (double) y * (double) z > Integer.MAX_VALUE) { + throw new IllegalArgumentException("a survey of " + x + "x" + y + "x" + z + + " looks cannot be walked: " + min.cellKey() + " .. " + max.cellKey() + + " at a stride of " + stride + " cells. Narrow the region or widen the stride."); + } + return (int) (x * y * z); } /** - * Aim a survey from {@code origin} along a direction, {@code distanceSectors} sectors out. + * Point the instrument from {@code origin} along a direction, {@code distanceSteps} star + * territories deep. + * + *

    Unlike a box, a pointing keeps its direction EXACTLY: the vector is used as given rather + * than reduced to a sign per axis, because a cone that snapped to the twenty-six lattice + * directions would not be an aim, it would be a menu.

    * - *

    The direction is taken as a sign per axis, so any vector pointing the same way aims the same - * survey. The distance is clamped into {@code [1, maxRange]} rather than refused: an operator who - * asks for more than the instrument can reach gets the instrument's reach, which is what a - * horizon means.

    + *

    The depth is counted in STEPS — one step is one star's territory, the same stride the sweep + * walks by — and is clamped into {@code [1, maxRangeSteps]} rather than refused: an operator who + * asks for more than the instrument can reach gets the instrument's reach, which is what a horizon + * means. That reach is {@link Tuning#maxRangeSteps()}, which is derived from the aperture's + * limiting magnitude and is a fact about the instrument rather than a number someone set.

    * * @throws IllegalArgumentException if there is no origin, or the direction is the zero vector — - * a survey with no direction does not name a region. + * a pointing with no direction does not name a patch of sky. */ public static RegionScan directed(GalacticCoord origin, int dirX, int dirY, int dirZ, - int distanceSectors, long startTick, Tuning tuning) { + int distanceSteps, long startTick, Tuning tuning) { if (origin == null) { - throw new IllegalArgumentException("a region survey needs an origin to aim from"); + throw new IllegalArgumentException("a survey needs an origin to aim from"); } if (tuning == null) { - throw new IllegalArgumentException("a region survey needs its bounds"); + throw new IllegalArgumentException("a survey needs its bounds"); } - int dx = Integer.signum(dirX); - int dy = Integer.signum(dirY); - int dz = Integer.signum(dirZ); - if (dx == 0 && dy == 0 && dz == 0) { - throw new IllegalArgumentException("a survey with no direction does not name a region"); - } - - int distance = Math.max(1, Math.min(distanceSectors, tuning.maxRangeSectors())); - int half = tuning.effectiveHalfWidthSectors(); - - long cx = origin.sectorX() + (long) dx * distance; - long cy = origin.sectorY() + (long) dy * distance; - long cz = origin.sectorZ() + (long) dz * distance; - - return box(GalacticCoord.ofSectorLocal(cx - half, cy - half, cz - half, 0L, 0L, 0L), - GalacticCoord.ofSectorLocal(cx + half, cy + half, cz + half, 0L, 0L, 0L), - distance, startTick, tuning); + long stride = tuning.strideCells(); + int steps = Math.max(1, Math.min(distanceSteps, tuning.maxRangeSteps())); + ConeWalk aimed = tuning.fit(origin, dirX, dirY, dirZ, steps); + int ticks = tuning.baseTicks(); + return new RegionScan(aimed, null, null, aimed.reachCells(), stride, startTick, + startTick + ticks, 0, tuning.cellsPerStep(), ticks); } /** - * A survey of an explicit box — how the passive local radar states its own neighbourhood, where - * there is no direction to aim and the distance is simply how far the box reaches. + * The passive local radar: the observatory's own star territory and the {@code radiusSteps} rings + * of territories around it. + * + *

    Territories and not cells. This walked cell by cell until 2026-08-19, on the ground + * that "the planet in the next cell over is a different destination from its star" — which is + * true and is not a reason, because one look already yields every body of the system that owns + * it. What a cell-by-cell radius bought was nothing at all: two cells is 0.107 AU, a fifth of the + * way to the innermost planet of the system the instrument is already standing in, and no radius + * a cell-strided box could afford would ever have reached a NEIGHBOUR, which is a whole territory + * away. A radius of one territory is twenty-seven looks and is what "watches the neighbourhood" + * was always meant to say.

    */ - public static RegionScan box(GalacticCoord lo, GalacticCoord hi, int distanceSectors, - long startTick, Tuning tuning) { - int ticksPerStep = Math.max(0, tuning.baseTicks() - + tuning.ticksPerSector() * Math.max(0, distanceSectors)); - return new RegionScan(lo, hi, distanceSectors, startTick, startTick + ticksPerStep, - 0, tuning.cellsPerStep(), ticksPerStep); + public static RegionScan local(GalacticCoord origin, int radiusSteps, long startTick, + Tuning tuning) { + if (origin == null) { + throw new IllegalArgumentException("a local radar needs the cell it is standing in"); + } + if (tuning == null) { + throw new IllegalArgumentException("a survey needs its bounds"); + } + long stride = tuning.strideCells(); + long radius = Math.max(0, radiusSteps) * stride; + int ticks = tuning.baseTicks(); + return new RegionScan(null, + GalacticCoord.ofSectorLocal(origin.sectorX() - radius, origin.sectorY() - radius, + origin.sectorZ() - radius, 0L, 0L, 0L), + GalacticCoord.ofSectorLocal(origin.sectorX() + radius, origin.sectorY() + radius, + origin.sectorZ() + radius, 0L, 0L, 0L), + radius, stride, startTick, startTick + ticks, 0, tuning.cellsPerStep(), ticks); + } + + /** The pointing this survey walks, or {@code null} when it is the box-shaped local radar. */ + public ConeWalk cone() { + return cone; } - /** The inclusive low corner of the surveyed sector box. */ + /** {@code true} when this survey is a pointing rather than the local radar. */ + public boolean isPointing() { + return cone != null; + } + + /** The inclusive low corner of the surveyed box; for a pointing, of the box that bounds it. */ public GalacticCoord min() { return min; } - /** The inclusive high corner of the surveyed sector box. */ + /** The inclusive high corner of the surveyed box; for a pointing, of the box that bounds it. */ public GalacticCoord max() { return max; } - /** How far out the survey was aimed, after the range clamp. */ - public int distanceSectors() { - return distanceSectors; + /** How far out the survey reaches, in cells, after the range clamp. */ + public long distanceCells() { + return distanceCells; + } + + /** The same reach in light years — the form the number is recognisable in. */ + public double distanceLightYears() { + return UniverseRegistry.getGenerator().laws().lightYearsForCells(distanceCells); + } + + /** How far apart the cells this survey looks at stand. One star's territory, or one cell. */ + public long strideCells() { + return strideCells; } public long startTick() { return startTick; } - /** The tick the next batch of cells is resolved. */ + /** The tick the next batch of looks is resolved. */ public long stepDeadline() { return stepDeadline; } - /** How many cells of the region have been resolved so far. */ + /** How many looks of the survey have been resolved so far. */ public int cellsDone() { return cellsDone; } @@ -140,16 +240,27 @@ public int ticksPerStep() { return ticksPerStep; } - /** How many cells the region holds. Bounded at construction; never unbounded. */ + /** + * How many cells this survey LOOKS at — not how many the sky it covers contains. The two differ + * by the stride: a pointing a hundred territories deep is a few thousand looks, not the hundreds + * of millions of cells the cone encloses. Bounded at construction; never unbounded, and never a + * clamped count standing in for a real one. + */ public int totalCells() { - long sx = max.sectorX() - min.sectorX() + 1L; - long sy = max.sectorY() - min.sectorY() + 1L; - long sz = max.sectorZ() - min.sectorZ() + 1L; - long cells = sx * sy * sz; - return (int) Math.min(Integer.MAX_VALUE, Math.max(0L, cells)); + return totalCells; + } + + /** How many sampled cells one axis of the region holds, at a given stride. */ + private static long countAlong(long lo, long hi, long stride) { + return Math.max(0L, (hi - lo) / Math.max(1L, stride) + 1L); + } + + /** The same, at this survey's own stride — what the sweep order is built from. */ + private long countAlong(long lo, long hi) { + return countAlong(lo, hi, strideCells); } - /** {@code true} once every cell of the region has been resolved. */ + /** {@code true} once every look of the survey has been resolved. */ public boolean isComplete() { return cellsDone >= totalCells(); } @@ -159,7 +270,7 @@ public boolean stepDue(long now) { return !isComplete() && now >= stepDeadline; } - /** How much of the region is surveyed, in {@code [0,1]}. Cells resolved, not ticks elapsed. */ + /** How much of the survey is done, in {@code [0,1]}. Looks resolved, not ticks elapsed. */ public float progress() { int total = totalCells(); if (total <= 0) { @@ -168,29 +279,51 @@ public float progress() { return Math.min(1f, cellsDone / (float) total); } - /** Roughly how long the whole sweep takes — what a farther region costs against a nearer one. */ + /** Roughly how long the whole sweep takes — what a deeper pointing costs against a shallower one. */ public long estimatedTicks() { int steps = (totalCells() + cellsPerStep - 1) / cellsPerStep; return (long) steps * ticksPerStep; } /** - * The cell at {@code index} in the sweep order: rows along X, then Z, then Y. The order is - * deterministic so a resumed sweep continues where it stopped rather than starting over. + * The cell the look at {@code index} lands on. + * + *

    For a pointing, the cone's own order: shell by shell outwards, so an aborted survey has + * covered a SHORTER cone rather than a scatter. For the local radar, rows along X, then Z, then Y, + * a stride apart. Both are deterministic, so a resumed sweep continues where it stopped rather + * than starting over.

    */ public GalacticCoord cellAt(int index) { - long width = max.sectorX() - min.sectorX() + 1L; - long depth = max.sectorZ() - min.sectorZ() + 1L; + if (cone != null) { + return cone.lookAt(index); + } + long width = countAlong(min.sectorX(), max.sectorX()); + long depth = countAlong(min.sectorZ(), max.sectorZ()); long perLayer = width * depth; long y = index / perLayer; long rest = index % perLayer; long z = rest / width; long x = rest % width; - return GalacticCoord.ofSectorLocal(min.sectorX() + x, min.sectorY() + y, min.sectorZ() + z, + return GalacticCoord.ofSectorLocal(min.sectorX() + x * strideCells, + min.sectorY() + y * strideCells, min.sectorZ() + z * strideCells, 0L, 0L, 0L); + } + + /** + * Where the survey is looking FROM — the apex of a pointing, or the centre of the radar's box. + * + *

    The resolving side needs it for something the box shape never had to answer: how far away + * what it just found is, which is half of how bright the thing looks.

    + */ + public GalacticCoord observer() { + if (cone != null) { + return cone.apex(); + } + return GalacticCoord.ofSectorLocal((min.sectorX() + max.sectorX()) / 2L, + (min.sectorY() + max.sectorY()) / 2L, (min.sectorZ() + max.sectorZ()) / 2L, 0L, 0L, 0L); } - /** How many cells the batch due at {@code now} covers — the per-step bound, or what is left. */ + /** How many looks the batch due at {@code now} covers — the per-step bound, or what is left. */ public int cellsDueAt(long now) { if (!stepDue(now)) { return 0; @@ -198,29 +331,36 @@ public int cellsDueAt(long now) { return Math.min(cellsPerStep, totalCells() - cellsDone); } - /** The survey after a batch of {@code resolved} cells has been written, with its next deadline. */ + /** The survey after a batch of {@code resolved} looks has been written, with its next deadline. */ public RegionScan advanced(long now, int resolved) { int done = Math.min(totalCells(), cellsDone + Math.max(0, resolved)); - return new RegionScan(min, max, distanceSectors, startTick, now + ticksPerStep, done, - cellsPerStep, ticksPerStep); + return new RegionScan(cone, min, max, distanceCells, strideCells, startTick, + now + ticksPerStep, done, cellsPerStep, ticksPerStep); } - /** The survey with every cell resolved — the instant path, where time is not the mechanic. */ + /** The survey with every look resolved — the instant path, where time is not the mechanic. */ public RegionScan completed(long now) { - return new RegionScan(min, max, distanceSectors, startTick, now, totalCells(), - cellsPerStep, ticksPerStep); + return new RegionScan(cone, min, max, distanceCells, strideCells, startTick, now, + totalCells(), cellsPerStep, ticksPerStep); } public void writeToNBT(NBTTagCompound nbt) { - NBTTagCompound lo = new NBTTagCompound(); - min.writeToNBT(lo); - nbt.setTag(KEY_MIN, lo); - - NBTTagCompound hi = new NBTTagCompound(); - max.writeToNBT(hi); - nbt.setTag(KEY_MAX, hi); + if (cone != null) { + NBTTagCompound aim = new NBTTagCompound(); + cone.writeToNBT(aim); + nbt.setTag(KEY_CONE, aim); + } else { + NBTTagCompound lo = new NBTTagCompound(); + min.writeToNBT(lo); + nbt.setTag(KEY_MIN, lo); + + NBTTagCompound hi = new NBTTagCompound(); + max.writeToNBT(hi); + nbt.setTag(KEY_MAX, hi); + } - nbt.setInteger(KEY_DISTANCE, distanceSectors); + nbt.setLong(KEY_DISTANCE, distanceCells); + nbt.setLong(KEY_STRIDE, strideCells); nbt.setLong(KEY_START, startTick); nbt.setLong(KEY_STEP_DEADLINE, stepDeadline); nbt.setInteger(KEY_CELLS_DONE, cellsDone); @@ -230,13 +370,27 @@ public void writeToNBT(NBTTagCompound nbt) { /** The survey stored in {@code nbt}, or {@code null} when nothing was stored. */ public static RegionScan readFromNBT(NBTTagCompound nbt) { - if (nbt == null || !nbt.hasKey(KEY_MIN) || !nbt.hasKey(KEY_MAX)) { + if (nbt == null) { return null; } - return new RegionScan( + if (nbt.hasKey(KEY_CONE)) { + return new RegionScan(ConeWalk.readFromNBT(nbt.getCompoundTag(KEY_CONE)), null, null, + nbt.getLong(KEY_DISTANCE), + nbt.getLong(KEY_STRIDE), + nbt.getLong(KEY_START), + nbt.getLong(KEY_STEP_DEADLINE), + nbt.getInteger(KEY_CELLS_DONE), + nbt.getInteger(KEY_CELLS_PER_STEP), + nbt.getInteger(KEY_TICKS_PER_STEP)); + } + if (!nbt.hasKey(KEY_MIN) || !nbt.hasKey(KEY_MAX)) { + return null; + } + return new RegionScan(null, GalacticCoord.readFromNBT(nbt.getCompoundTag(KEY_MIN)), GalacticCoord.readFromNBT(nbt.getCompoundTag(KEY_MAX)), - nbt.getInteger(KEY_DISTANCE), + nbt.getLong(KEY_DISTANCE), + nbt.getLong(KEY_STRIDE), nbt.getLong(KEY_START), nbt.getLong(KEY_STEP_DEADLINE), nbt.getInteger(KEY_CELLS_DONE), @@ -246,77 +400,159 @@ public static RegionScan readFromNBT(NBTTagCompound nbt) { @Override public String toString() { + if (cone != null) { + return "RegionScan[" + cone + ", " + cellsDone + "/" + totalCells() + + " looks, next@" + stepDeadline + "]"; + } return "RegionScan[" + min.cellKey() + " .. " + max.cellKey() + ", " + cellsDone + "/" + totalCells() + " cells, next@" + stepDeadline + "]"; } /** - * What bounds a survey and what it costs in time. Every number here is balance, not contract: the - * reach, the size of the patch, how many cells one step resolves and how long a step takes. + * What bounds a survey and what it costs in time. + * + *

    The reach is not here, and that is the point of the shape: an instrument reaches a + * BRIGHTNESS, and how far that carries is derived from the aperture's limiting magnitude against + * the brightest star the galaxy can produce ({@link StellarMagnitude#instrumentReachLightYears}). + * A configured length was the wrong quantity — it made one number stand for a red dwarf and a blue + * giant, whose ranges differ by eighty times, and it moved whenever the star spacing or the cell + * edge was retuned. What remains configurable is the aperture, the width of the patch, and the + * cost in time; those are balance, never contract.

    */ public static final class Tuning { - private final int maxRangeSectors; - private final int halfWidthSectors; - private final int maxSectors; + private final double limitMagnitude; + private final double reachLightYears; + private final double halfAngleRadians; + private final int maxCells; private final int baseTicks; - private final int ticksPerSector; private final int cellsPerStep; + private final long strideCells; - public Tuning(int maxRangeSectors, int halfWidthSectors, int maxSectors, - int baseTicks, int ticksPerSector, int cellsPerStep) { - this.maxRangeSectors = Math.max(1, maxRangeSectors); - this.halfWidthSectors = Math.max(0, halfWidthSectors); - this.maxSectors = Math.max(1, maxSectors); + /** + * @param archetypes the star types the sky can produce — the reach is DERIVED against the + * brightest of them here and is never a field anyone can set, so an + * instrument's horizon cannot disagree with its aperture + */ + public Tuning(double limitMagnitude, Iterable archetypes, + double halfAngleRadians, int maxCells, int baseTicks, int cellsPerStep, + long strideCells) { + this.limitMagnitude = limitMagnitude; + this.reachLightYears = StellarMagnitude.instrumentReachLightYears(archetypes, limitMagnitude); + this.halfAngleRadians = Math.max(0d, halfAngleRadians); + this.maxCells = Math.max(1, maxCells); this.baseTicks = Math.max(0, baseTicks); - this.ticksPerSector = Math.max(0, ticksPerSector); this.cellsPerStep = Math.max(1, cellsPerStep); + this.strideCells = Math.max(1L, strideCells); } - /** The tuning the running game is configured with. */ + /** + * The tuning the running game is configured with — including the stride, which is the active + * generator's own star spacing and never a number of its own: a survey that strode by + * anything else would either re-read one system or step over whole ones. + */ public static Tuning fromConfig() { ARConfiguration config = ARConfiguration.getCurrentConfig(); return new Tuning( - config.telescopeScanRangeSectors, - config.telescopeScanHalfWidthSectors, - config.telescopeScanMaxSectors, + config.telescopeLimitingMagnitude, + // The STOCK sky when the installed generator describes none of its own. A + // generator with no star table has not said the sky is empty - it has said it + // does not place stars, and an authored pack's suns are real light an instrument + // has to be able to reach. Falling back to the reference table is the same move + // as reading an unstated bulk as one Earth; taking the empty list literally gave + // the instrument a reach of zero and collapsed every pointing to a single shell. + UniverseRegistry.getGenerator().tuning() + .map(c -> c.starTypes) + .filter(types -> !types.isEmpty()) + .orElse(GalaxyGenConfig.defaults().starTypes), + Math.toRadians(config.telescopeConeHalfAngleDegrees), + config.telescopeScanMaxCells, config.telescopeScanBaseTicks, - config.telescopeScanTicksPerSector, - config.telescopeScanCellsPerStep); + config.telescopeScanCellsPerStep, + UniverseRegistry.getGenerator().minSpacingCells()); } - public int maxRangeSectors() { - return maxRangeSectors; + /** How faint a star this instrument can still register. Magnitudes: larger is fainter. */ + public double limitMagnitude() { + return limitMagnitude; } - public int baseTicks() { - return baseTicks; + /** How wide a patch of sky one pointing covers, from its axis to its edge. */ + public double halfAngleRadians() { + return halfAngleRadians; + } + + /** + * The instrument's horizon, as a length — DERIVED from the limiting magnitude against the + * brightest archetype the active generator can produce, and zero for a generator that + * produces no stars at all. + */ + public double maxRangeLightYears() { + return reachLightYears; + } + + /** How far apart the cells a pointing looks at stand — one star's territory. */ + public long strideCells() { + return strideCells; } - public int ticksPerSector() { - return ticksPerSector; + /** The horizon as a number of steps, which is what an operator aims in. At least one. */ + public int maxRangeSteps() { + long steps = UniverseRegistry.getGenerator().laws() + .cellsForLightYears(maxRangeLightYears()) / strideCells; + return (int) Math.max(1L, Math.min(Integer.MAX_VALUE, steps)); + } + + public int baseTicks() { + return baseTicks; } public int cellsPerStep() { return cellsPerStep; } + /** The hard ceiling on how many looks one survey may hold. */ + public int maxCells() { + return maxCells; + } + /** - * The half-width a survey actually gets: the configured one, narrowed until the region fits - * inside the sector ceiling. The ceiling wins over the width — a sweep may be long, but it - * may not be unbounded. + * The deepest pointing of {@code steps} that still fits under {@link #maxCells()} — SHORTENED + * rather than refused, exactly as a box survey's width used to be narrowed. + * + *

    The ceiling wins over the depth. A survey may be long, but it may not be unbounded, and a + * pointing that will not fit is one an operator gets less of rather than none of: he sees the + * near sky and can point again. Halving is used rather than decrementing because the look + * count grows as the cube of the depth — walking down one step at a time from a magnitude + * limit that reaches a hundred thousand steps would be the same unbounded work in a + * different place.

    */ - public int effectiveHalfWidthSectors() { - int half = halfWidthSectors; - while (half > 0 && volumeOf(half) > maxSectors) { - half--; + public ConeWalk fit(GalacticCoord origin, int dirX, int dirY, int dirZ, int steps) { + int depth = Math.max(1, steps); + IllegalArgumentException refused = null; + while (depth >= 1) { + try { + ConeWalk aimed = ConeWalk.aimed(origin, dirX, dirY, dirZ, halfAngleRadians, + depth * strideCells, strideCells); + if (aimed.totalLooks() <= maxCells) { + return aimed; + } + } catch (IllegalArgumentException tooLarge) { + refused = tooLarge; // too many looks to even count: the same answer, sooner + } + if (depth == 1) { + break; + } + depth = Math.max(1, depth / 2); } - return half; - } - - private static long volumeOf(int half) { - long side = 2L * half + 1L; - return side * side * side; + // A single shell that still will not fit means the aperture is wider than the ceiling can + // ever afford, which is a configuration nobody can survey with — and the operator has to + // be told which of the two numbers to change. + throw new IllegalArgumentException("a pointing of half-angle " + + String.format("%.3f", Math.toDegrees(halfAngleRadians)) + " degrees holds more" + + " than " + maxCells + " looks in its very first shell." + + " Narrow telescopeConeHalfAngleDegrees or raise telescopeScanMaxCells." + + (refused == null ? "" : " (" + refused.getMessage() + ")")); } } } diff --git a/src/main/java/zmaster587/advancedRocketry/universe/StarCluster.java b/src/main/java/zmaster587/advancedRocketry/universe/StarCluster.java new file mode 100644 index 000000000..2300f52cf --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/StarCluster.java @@ -0,0 +1,126 @@ +package zmaster587.advancedRocketry.universe; + +/** + * One star cluster: a region of a galaxy where the star lattice is FINER by an integer factor. + * + *

    The stratified lattice one level up reads correctly as randomness but produces no GROUPS, and + * groups are what a real sky has: a lattice caps density at about three times the mean, while an open + * cluster runs tens of times the field and a nucleus thousands. A cluster is the same seat one level + * down, and the whole mechanism is one word — commensurate.

    + * + *

    Why commensurate, and why that makes it cheap

    + *

    Each coarse super-cell inside a cluster is divided into {@code k³} sub-cells, so the fine lattice + * tiles exactly the coarse cells it replaces. There is no partial cell at the edge, no seam, and + * nothing to re-prove per ring — which is what a graded spacing would have cost. And because + * membership is decided per COARSE cell, a cell is wholly in a cluster or wholly out of it, so + * "which lattice does this coordinate live on" stays an O(1) question with one answer.

    + * + *

    The sub-cell bounds are computed by proportioning rather than by dividing: sub-cell {@code i} + * runs from {@code floor(i·s/k)} to {@code floor((i+1)·s/k)}. That tiles a coarse cell of ANY edge + * exactly, including one that {@code k} does not divide — where a plain {@code s/k} would leave a + * remainder and a seam.

    + * + *

    The separation floor becomes a property of a LATTICE LEVEL

    + *

    Inside a globular's core stars really are closer together than a wide binary, and encounters + * really are frequent. The 10 000 AU floor is derived from whatever spacing is in force locally, so a + * clustered region gets a proportionally smaller one — and a system there loses outer bodies by the + * same rule that has always applied. A floor applied outside its domain of definition is precisely the + * mistake this design keeps removing elsewhere.

    + * + *

    Immutable value type.

    + */ +public final class StarCluster { + + private final GalaxyGenConfig.ClusterType type; + private final int subdivision; + private final long centreSuperX; + private final long centreSuperY; + private final long centreSuperZ; + private final long radiusSuperCells; + + /** + * @param subdivision how many parts each coarse super-cell inside this cluster is divided into, + * per axis. Stated rather than read off {@code type}, because a NUCLEUS's + * contrast is a statement about its own GALAXY's population and every other + * cluster's is a statement about the field — the two cannot both be a + * constant in one table + */ + public StarCluster(GalaxyGenConfig.ClusterType type, int subdivision, long centreSuperX, + long centreSuperY, long centreSuperZ, long radiusSuperCells) { + this.type = type; + this.subdivision = Math.max(1, subdivision); + this.centreSuperX = centreSuperX; + this.centreSuperY = centreSuperY; + this.centreSuperZ = centreSuperZ; + this.radiusSuperCells = Math.max(1L, radiusSuperCells); + } + + public GalaxyGenConfig.ClusterType type() { + return type; + } + + /** How many parts each coarse super-cell inside this cluster is divided into, per axis. */ + public int subdivision() { + return subdivision; + } + + /** Its radius, in COARSE super-cells — the unit its boundary is snapped to. */ + public long radiusSuperCells() { + return radiusSuperCells; + } + + public long centreSuperX() { + return centreSuperX; + } + + public long centreSuperY() { + return centreSuperY; + } + + public long centreSuperZ() { + return centreSuperZ; + } + + /** + * Whether this coarse super-cell is inside the cluster. + * + *

    Rounded: the test is on the super-cell INDEX, so the boundary lands on coarse cell faces + * while the shape stays a ball rather than a box. That is what keeps the fine lattice exactly + * tiling and the answer per-cell.

    + */ + public boolean containsSuperCell(long supX, long supY, long supZ) { + double dx = supX - centreSuperX; + double dy = supY - centreSuperY; + double dz = supZ - centreSuperZ; + return dx * dx + dy * dy + dz * dz <= (double) radiusSuperCells * radiusSuperCells; + } + + /** + * The lower bound of sub-cell {@code index} inside a coarse cell of edge {@code coarseEdge}, + * as an offset from that cell's own low corner. + * + *

    Proportioned, never divided: this tiles a coarse cell of any edge exactly, where + * {@code index · (coarseEdge / k)} would leave a remainder at the top of every cell.

    + */ + public long subCellLow(long index, long coarseEdge) { + return Math.floorDiv(index * coarseEdge, (long) subdivision()); + } + + /** The edge of sub-cell {@code index} — within one of the neighbouring sub-cells' edge. */ + public long subCellEdge(long index, long coarseEdge) { + return Math.max(1L, subCellLow(index + 1L, coarseEdge) - subCellLow(index, coarseEdge)); + } + + /** Which sub-cell an offset inside a coarse cell falls in, on one axis. */ + public long subCellIndex(long offsetInCoarse, long coarseEdge) { + long k = subdivision(); + long index = Math.floorDiv(offsetInCoarse * k, Math.max(1L, coarseEdge)); + return Math.min(k - 1L, Math.max(0L, index)); + } + + @Override + public String toString() { + return "StarCluster[" + type.name + " k=" + subdivision() + " r=" + radiusSuperCells + + " super-cells @ " + centreSuperX + "," + centreSuperY + "," + centreSuperZ + "]"; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/universe/StarSystem.java b/src/main/java/zmaster587/advancedRocketry/universe/StarSystem.java deleted file mode 100644 index 81d24c0e0..000000000 --- a/src/main/java/zmaster587/advancedRocketry/universe/StarSystem.java +++ /dev/null @@ -1,56 +0,0 @@ -package zmaster587.advancedRocketry.universe; - -import zmaster587.advancedRocketry.api.dimension.solar.StellarBody; - -/** - * An immutable query-time handle to a star system, returned by the {@link UniverseRegistry} and - * {@link IGalaxyGenerator}. It is a thin wrapper over the existing {@link StellarBody} content object - * (star + its planets + companion sub-stars) and deliberately carries no coordinate: a system is - * LOCATION-AGNOSTIC and its galactic address is owned solely by the registry (universe-model.md §2/§10). - * - *

    Identity is the system's int star-id (a whole multi-star system shares one id — sub-stars mirror the - * primary). This is the stable return type downstream tasks (generation, content/POIs, discovery) build on; - * they can grow richer accessors here without reshaping the registry's persistent index.

    - */ -public final class StarSystem { - - private final StellarBody star; - - public StarSystem(StellarBody star) { - if (star == null) { - throw new NullPointerException("star"); - } - this.star = star; - } - - /** The reused content object: the primary star plus its planets and companion sub-stars. */ - public StellarBody star() { - return star; - } - - /** The system id (== the primary star's id). */ - public int starId() { - return star.getId(); - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (!(o instanceof StarSystem)) { - return false; - } - return starId() == ((StarSystem) o).starId(); - } - - @Override - public int hashCode() { - return star.getId(); - } - - @Override - public String toString() { - return "StarSystem[id=" + star.getId() + ", name=" + star.getName() + "]"; - } -} diff --git a/src/main/java/zmaster587/advancedRocketry/universe/StellarMagnitude.java b/src/main/java/zmaster587/advancedRocketry/universe/StellarMagnitude.java new file mode 100644 index 000000000..cabf16281 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/StellarMagnitude.java @@ -0,0 +1,185 @@ +package zmaster587.advancedRocketry.universe; + +import zmaster587.advancedRocketry.api.dimension.solar.StellarBody; + +/** + * How bright a star LOOKS from somewhere else — the photometry a telescope is bounded by. + * + *

    An instrument does not reach a distance; it reaches a BRIGHTNESS. Everything a survey can find + * is what stands above its limiting magnitude, and distance enters only through the inverse-square + * law that dims things. Stating an instrument's reach as a length is therefore stating a consequence + * as if it were a cause: the same telescope sees a blue giant eighty times farther than a red dwarf, + * and no single number of light years describes both.

    + * + *

    The unit is already spoken here. {@link Nebula#MAGNITUDES_PER_DENSITY_LIGHT_YEAR} turns a + * dust column into magnitudes of extinction and {@link UniverseRegistry#extinctionBetween} returns + * them, so dust and distance are two terms of ONE sum rather than two mechanics that have to be + * reconciled. That is the whole reason a magnitude limit is the right bound and a light-year horizon + * was the wrong one.

    + * + *

    Three quantities, in the order they are derived:

    + *
      + *
    1. Luminosity, from the star's own size and temperature — {@code L/L(sun) = R^2*(T/T(sun))^4}, + * the Stefan-Boltzmann law for a sphere.
    2. + *
    3. Absolute magnitude {@code M = M(sun) - 2.5*log10(L)} — how bright it would be at the + * standard ten parsecs.
    4. + *
    5. Apparent magnitude {@code m = M + 5*log10(d/10pc) + A} — how bright it is from here, + * through whatever dust {@code A} lies between.
    6. + *
    + * + *

    Magnitudes run BACKWARDS: smaller is brighter, and a difference of 5 is a factor of 100 in + * received flux. So "brighter than the limit" reads {@code m <= limit}, which is the one place this + * scale trips a reader who has not met it before.

    + */ +public final class StellarMagnitude { + + private StellarMagnitude() { + } + + /** + * The Sun's absolute visual magnitude — the zero point the whole scale is hung from. + * + *

    Measured, not chosen: 4.83 is the accepted value in the V band, and every absolute magnitude + * below is stated relative to it. Changing it does not rescale the sky, it moves the Sun.

    + */ + public static final double SOLAR_ABSOLUTE_MAGNITUDE = 4.83d; + + /** Light years in one parsec — 3.26156, the conversion the magnitude law's {@code 10 pc} needs. */ + public static final double LIGHT_YEARS_PER_PARSEC = 3.26156d; + + /** + * The temperature this layer calls the Sun's. + * + *

    {@link StellarBody#getTemperature()} is in units of a hundredth of Sol and not in + * kelvin, whatever its javadoc says — the stock table seats a sun-like star at 100 and a red dwarf + * at 40, and every consumer in the mod reads it that way. It is spelled out here because this + * class raises it to the FOURTH power, where a wrong unit is not a small error.

    + */ + public static final double SOLAR_TEMPERATURE_UNITS = 100d; + + /** + * How luminous a star of {@code radiusSuns} and {@code temperatureUnits} is, in Suns. + * + *

    {@code L = 4*pi*R^2*sigma*T^4} for both, divided: {@code L/L(sun) = (R/R(sun))^2*(T/T(sun))^4}. + * The fourth power is what makes the sky's brightness so unlike its population — a blue star is + * 0.13 % of the stars and outshines a red dwarf by nearly four orders.

    + */ + public static double luminositySuns(double radiusSuns, double temperatureUnits) { + double r = Math.max(0d, radiusSuns); + double t = Math.max(0d, temperatureUnits) / SOLAR_TEMPERATURE_UNITS; + return r * r * t * t * t * t; + } + + /** + * The same for a star object. + * + *

    An unstated temperature is read as Sol's, and that is a decision worth seeing. + * {@link StellarBody} leaves temperature at zero until something sets it, and zero raised to the + * fourth power is a star that emits nothing — so a pack that describes a star by its size alone + * would have written an invisible one, and it would have found out by pointing a telescope at + * empty sky. Zero here means UNSTATED, not cold, exactly as an unstated bulk means one Earth + * everywhere else in this layer. A star that really is dark says so by being a black hole.

    + */ + public static double luminositySuns(StellarBody star) { + if (star == null) { + return 0d; + } + // A black hole emits nothing a survey in the visible could catch. It is not "very faint" — + // it is off this scale entirely, and the caller's own "never detected" branch is the right one. + if (star.isBlackHole()) { + return 0d; + } + int temperature = star.getTemperature(); + return luminositySuns(star.getSize(), + temperature > 0 ? temperature : SOLAR_TEMPERATURE_UNITS); + } + + /** + * The absolute magnitude of a star of {@code luminositySuns} — how bright it would look at ten + * parsecs. Infinite for a star that emits nothing, which is the honest answer and never a number + * a comparison would accidentally accept. + */ + public static double absoluteMagnitude(double luminositySuns) { + if (!(luminositySuns > 0d)) { + return Double.POSITIVE_INFINITY; + } + return SOLAR_ABSOLUTE_MAGNITUDE - 2.5d * Math.log10(luminositySuns); + } + + /** + * How bright a star of absolute magnitude {@code absolute} looks from {@code distanceLightYears} + * away through {@code extinctionMagnitudes} of dust. + * + *

    The distance modulus {@code 5*log10(d/10pc)} is undefined at zero distance and enormous just + * above it, so a look from inside the star's own cell is answered with the absolute magnitude + * alone rather than with minus infinity: standing on top of something is not an observation, and + * a survey's own system is found by being there rather than by being seen.

    + */ + public static double apparentMagnitude(double absolute, double distanceLightYears, + double extinctionMagnitudes) { + if (Double.isInfinite(absolute)) { + return Double.POSITIVE_INFINITY; + } + double parsecs = Math.max(0d, distanceLightYears) / LIGHT_YEARS_PER_PARSEC; + double modulus = (parsecs <= 1e-9d) ? 0d : 5d * Math.log10(parsecs / 10d); + return absolute + modulus + Math.max(0d, extinctionMagnitudes); + } + + /** The same, straight from a star's own bulk — the form a detection stage calls. */ + public static double apparentMagnitudeOf(StellarBody star, double distanceLightYears, + double extinctionMagnitudes) { + return apparentMagnitude(absoluteMagnitude(luminositySuns(star)), distanceLightYears, + extinctionMagnitudes); + } + + /** + * How far a star of {@code luminositySuns} stays above {@code limitMagnitude} in CLEAR sky, in + * light years — the inverse of the distance modulus, and the number that TRUNCATES a survey. + * + *

    This is what replaces a configured horizon. An instrument's reach is the range of the + * brightest thing it could possibly see: past that nothing is detectable at any density, so the + * walk stops rather than being stopped. Dust only ever shortens it, so a reach computed with no + * extinction is an upper bound and a survey that walks it misses nothing.

    + * + *

    Zero for a star that emits nothing.

    + */ + public static double detectionRangeLightYears(double luminositySuns, double limitMagnitude) { + double absolute = absoluteMagnitude(luminositySuns); + if (Double.isInfinite(absolute)) { + return 0d; + } + double parsecs = Math.pow(10d, (limitMagnitude - absolute) / 5d + 1d); + if (Double.isInfinite(parsecs) || Double.isNaN(parsecs)) { + return Double.MAX_VALUE; + } + return Math.max(0d, parsecs * LIGHT_YEARS_PER_PARSEC); + } + + /** + * The reach of an instrument of {@code limitMagnitude} against the brightest of + * {@code archetypes} — the physical horizon of a survey aimed with it. + * + *

    Every archetype is asked and the widest wins, because a survey does not know what it is + * about to find. The brightest is not the hottest nor the largest but the one whose {@code R^2T^4} + * is greatest, which is why this is computed rather than read off the end of the table.

    + * + *

    A generator with no archetypes at all reaches nothing, and that is the honest answer: an + * empty universe has nothing to see, and a survey of it should be instantly complete rather than + * long and fruitless.

    + */ + public static double instrumentReachLightYears(Iterable archetypes, + double limitMagnitude) { + if (archetypes == null) { + return 0d; + } + double best = 0d; + for (GalaxyGenConfig.StarType type : archetypes) { + // The archetype's BRIGHTEST realisation: a star's size is drawn from a band, and the reach + // has to cover the brightest star the band can produce or the walk would stop short of + // something it can see. + double luminosity = luminositySuns(type.maxSize, type.temperature); + best = Math.max(best, detectionRangeLightYears(luminosity, limitMagnitude)); + } + return best; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/universe/SystemBody.java b/src/main/java/zmaster587/advancedRocketry/universe/SystemBody.java index 93c97e831..21fd80524 100644 --- a/src/main/java/zmaster587/advancedRocketry/universe/SystemBody.java +++ b/src/main/java/zmaster587/advancedRocketry/universe/SystemBody.java @@ -39,26 +39,70 @@ 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; + + /** + * Sentinel for {@link #radiusEarths()}: this body has no radius of its own — a belt, a POI, + * anything that is not a sphere. NOT "we forgot to set one": a consumer draws such a body at its + * minimum size rather than guessing, because guessing is how a moon and a gas giant came to be + * drawn identically. + */ + public static final double RADIUS_UNKNOWN = 0d; + 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; + /** This body's own radius in EARTH radii, or {@link #RADIUS_UNKNOWN}. See {@link #radiusEarths()}. */ + private final double radiusEarths; /** * A body at rest in a STATIC frame — the reading for a POI, a fixture, or anything derived * 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(requireAddress(address).cellCentre(), CellFrame.staticAt(address), + /** + * 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 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); + 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) { + this(name, frame, offsetLaw, kind, dimId, starId, orbitalDistance, RADIUS_UNKNOWN); + } + + public SystemBody(GalacticCoord name, CellFrame frame, BodyEphemeris offsetLaw, + SystemBodyKind kind, int dimId, int starId, int orbitalDistance, + double radiusEarths) { if (name == null) { throw new NullPointerException("name"); } @@ -71,6 +115,28 @@ public SystemBody(GalacticCoord name, CellFrame frame, BodyEphemeris offsetLaw, this.kind = kind; this.dimId = dimId; this.starId = starId; + this.orbitalDistance = orbitalDistance; + this.radiusEarths = Double.isNaN(radiusEarths) || radiusEarths < 0d + ? RADIUS_UNKNOWN : radiusEarths; + } + + /** + * How big this body actually is, in EARTH radii, or {@link #RADIUS_UNKNOWN}. + * + *

    A body's size is a property of the body, and it travels with it because nothing downstream + * can recover it: a procedural world has no dimension to look it up in until somebody lands on + * it, and the render feed reaches a client that cannot see the universe registry at all. Until + * this existed the sky sized every body by DISTANCE alone, so a moon and a gas giant beside each + * other drew identically.

    + */ + public double radiusEarths() { + return radiusEarths; + } + + /** The same body, carrying {@code radiusEarths}. The generators' way of stating a body's size. */ + public SystemBody withRadius(double newRadiusEarths) { + return new SystemBody(name, frame, offsetLaw, kind, dimId, starId, orbitalDistance, + newRadiusEarths); } private static GalacticCoord requireAddress(GalacticCoord address) { @@ -85,6 +151,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; } @@ -138,6 +209,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; @@ -150,7 +246,8 @@ public boolean isDescendTarget() { */ public boolean definesFrame() { return kind == SystemBodyKind.STAR || kind == SystemBodyKind.PLANET - || kind == SystemBodyKind.GAS_GIANT || kind == SystemBodyKind.ASTEROID_BELT; + || kind == SystemBodyKind.GAS_GIANT || kind == SystemBodyKind.ASTEROID_BELT + || kind == SystemBodyKind.ROGUE_PLANET; } /** @@ -161,7 +258,8 @@ 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, + radiusEarths); } public void writeToNBT(NBTTagCompound nbt) { @@ -171,6 +269,12 @@ 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); + } + if (radiusEarths != RADIUS_UNKNOWN) { + nbt.setDouble("radiusEarths", radiusEarths); + } } public static SystemBody readFromNBT(NBTTagCompound nbt) { @@ -184,7 +288,9 @@ 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"), + nbt.getDouble("radiusEarths")); } @Override @@ -197,6 +303,8 @@ public boolean equals(Object o) { } SystemBody other = (SystemBody) o; return dimId == other.dimId && starId == other.starId && kind == other.kind + && orbitalDistance == other.orbitalDistance + && Double.compare(radiusEarths, other.radiusEarths) == 0 && name.equals(other.name) && offsetLaw.equals(other.offsetLaw) && frame.equals(other.frame); } @@ -207,6 +315,8 @@ public int hashCode() { result = 31 * result + kind.hashCode(); result = 31 * result + dimId; result = 31 * result + starId; + result = 31 * result + orbitalDistance; + result = 31 * result + Double.hashCode(radiusEarths); result = 31 * result + offsetLaw.hashCode(); return result; } @@ -217,10 +327,41 @@ public String toString() { + (offsetLaw.isStatic() ? "" : " +orbit") + "]"; } + /** + * An in-cell offset held inside the cell, reporting the first time it has to. + * + *

    The clamp itself is right: a body outside its own neighbourhood would be a body in a + * different cell, so saturating is the only safe answer. What was wrong is that it was SILENT. + * An orbit that overflows does not fail — every point of it beyond the face collapses onto the + * face, so a giant's outer moons stack at one spot and stop moving, which is a defect that gets + * looked for in the renderer, in the ephemeris and in the frame before anyone suspects a clamp. + * One line per axis per JVM run, naming the overflow, turns a week into a grep.

    + */ private static long clampInCell(long v) { if (v > MAX_IN_CELL) { + reportOverflow(v, MAX_IN_CELL); return MAX_IN_CELL; } - return v < -GalacticCoord.HALF_CELL ? -GalacticCoord.HALF_CELL : v; + if (v < -GalacticCoord.HALF_CELL) { + reportOverflow(v, -GalacticCoord.HALF_CELL); + return -GalacticCoord.HALF_CELL; + } + return v; } + + /** Said ONCE per distinct overflow magnitude: a flooded log is a log nobody reads either. */ + private static void reportOverflow(long raw, long clamped) { + if (REPORTED_OVERFLOWS.add(raw / GalacticCoord.CELL)) { + LOGGER.error("a body's in-cell offset {} is outside its own cell (half-cell {}) and was " + + "flattened onto the face at {}. Every further point of that orbit lands " + + "on the same spot, so the body will appear to stop moving: its orbit is " + + "wider than the cell that names it.", + raw, GalacticCoord.HALF_CELL, clamped); + } + } + + private static final org.apache.logging.log4j.Logger LOGGER = + org.apache.logging.log4j.LogManager.getLogger("advancedrocketry/universe"); + private static final java.util.Set REPORTED_OVERFLOWS = + java.util.Collections.newSetFromMap(new java.util.concurrent.ConcurrentHashMap()); } diff --git a/src/main/java/zmaster587/advancedRocketry/universe/SystemBodyKind.java b/src/main/java/zmaster587/advancedRocketry/universe/SystemBodyKind.java index 67bda217b..a985f563f 100644 --- a/src/main/java/zmaster587/advancedRocketry/universe/SystemBodyKind.java +++ b/src/main/java/zmaster587/advancedRocketry/universe/SystemBodyKind.java @@ -19,10 +19,38 @@ public enum SystemBodyKind { * rather than a {@link #PLANET}. Appended last on purpose: this ordinal travels on the render wire * ({@code PacketSystemBodiesSync}), so the existing kinds keep the numbers they already had. */ - GAS_GIANT; + GAS_GIANT, + /** + * A world with no star: a planet that was thrown out of the system it formed in, and now stands + * alone as the PRIMARY of its own cell. Its warmth is what is left of its own formation, so + * everything a star decides for an ordinary world — insolation, a year, a zone — it decides for + * itself or not at all. + * + *

    It is a kind of its own rather than a cold {@link #PLANET} around a cold {@link #STAR}, and + * that is the whole point of it existing: the arithmetic of a tiny 30 K star does come out right, + * and it would leave the model holding a {@code STAR} that is not a star. What a name is for is + * being true.

    + * + *

    Appended last, like {@link #GAS_GIANT} before it: this ordinal travels on the render wire + * ({@code PacketSystemBodiesSync}), so the existing kinds keep the numbers they already had.

    + */ + ROGUE_PLANET; - /** {@code true} for the body kinds that can back a walkable dimension (planets and moons). */ + /** + * {@code true} for the body kinds that can back a walkable dimension (planets and moons). + * + *

    A {@link #ROGUE_PLANET} is not among them yet, and that is a bound of the DIMENSION model + * rather than of the world. A realized dimension resolves its sky colour, its insolation, its + * year and its temperature through a star it is required to have, in some thirty unguarded places; + * a starless world is that model's own piece of work. Until it is done a rogue is a place a ship + * flies to and looks at, and the descent trigger never fires on one rather than failing at it.

    + */ public boolean canDescend() { return this == PLANET || this == MOON; } + + /** {@code true} for the kinds that are a WORLD — something with a surface, lit or not. */ + public boolean isWorld() { + return this == PLANET || this == MOON || this == ROGUE_PLANET; + } } diff --git a/src/main/java/zmaster587/advancedRocketry/universe/SystemContent.java b/src/main/java/zmaster587/advancedRocketry/universe/SystemContent.java index 485d93337..c41369f75 100644 --- a/src/main/java/zmaster587/advancedRocketry/universe/SystemContent.java +++ b/src/main/java/zmaster587/advancedRocketry/universe/SystemContent.java @@ -25,27 +25,34 @@ * 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; + + /** The floor an authored moon is lifted to, in parent radii — see {@link #moonLawOf}. */ + static final double MOON_MIN_PARENT_RADII = 2.5d; /** Cells kept clear of the super-cell faces when clamping a body cell into its system's box. */ static final int BOX_MARGIN_CELLS = 2; @@ -140,7 +147,8 @@ public static List bodiesOf(StellarBody star, GalacticCoord systemCo AbsolutePos anchorAbs = AbsolutePos.ofCellName(anchor); // The star sits at the anchor and does not move: a degenerate frame, not an exemption. bodies.add(new SystemBody(anchor, CellFrame.staticAt(anchor), BodyEphemeris.STATIC, - SystemBodyKind.STAR, Constants.INVALID_PLANET, starId)); + SystemBodyKind.STAR, Constants.INVALID_PLANET, starId, SystemBody.ORBIT_UNKNOWN, + AstronomicalBodyHelper.starRadiusEarths(star))); for (IDimensionProperties p : star.getPlanets()) { if (!(p instanceof DimensionProperties)) { @@ -150,8 +158,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(), planet.getRadius())); for (int moonId : planet.getChildPlanets()) { DimensionProperties moon = DimensionManager.getInstance().getDimensionProperties(moonId); @@ -160,8 +172,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(), moon.getRadius())); } } auditOneRealBodyPerCell(bodies, starId); @@ -175,7 +191,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); } @@ -183,8 +199,18 @@ 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); - return BodyEphemeris.orbit(moon.getOrbitalDist(), moon.baseOrbitTheta, moon.orbitalPhi, + moon.getOrbitalDist(), (float) parent.getOrbitalMass()); + // A FLOOR rather than a replacement: an authored pack keeps the spacing it wrote, unless what + // it wrote would put the moon inside its parent. That became possible only when bodies got a + // real radius — an Earth is 25 513 blocks across, so an authored orbit of 100 units (20 000 + // blocks) is under the surface. The pack's intent is kept where it is expressible. + int authored = moon.getOrbitalDist(); + double parentRadiusBlocks = Math.max(0.05d, parent.getRadius()) + * AstronomicalBodyHelper.EARTH_RADIUS_BLOCKS; + long floorUnits = Math.round(parentRadiusBlocks * MOON_MIN_PARENT_RADII + / (double) MOON_UNIT_BLOCKS); + int orbit = (int) Math.max(authored, Math.max(1L, Math.min(Integer.MAX_VALUE, floorUnits))); + return BodyEphemeris.orbit(orbit, moon.baseOrbitTheta, moon.orbitalPhi, moon.isRetrograde, periodTicks, MOON_UNIT_BLOCKS); } @@ -291,15 +317,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); } @@ -314,16 +340,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 @@ -337,12 +377,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/TelescopeScan.java b/src/main/java/zmaster587/advancedRocketry/universe/TelescopeScan.java index f930f9c10..904d3d02d 100644 --- a/src/main/java/zmaster587/advancedRocketry/universe/TelescopeScan.java +++ b/src/main/java/zmaster587/advancedRocketry/universe/TelescopeScan.java @@ -1,11 +1,16 @@ package zmaster587.advancedRocketry.universe; -import java.util.Map; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; import java.util.function.IntFunction; import net.minecraft.item.ItemStack; +import zmaster587.advancedRocketry.api.ARConfiguration; import zmaster587.advancedRocketry.api.Constants; +import zmaster587.advancedRocketry.api.dimension.solar.StellarBody; import zmaster587.advancedRocketry.dimension.DimensionProperties; import zmaster587.advancedRocketry.item.ItemMemoryCrystal; import zmaster587.advancedRocketry.navigation.CrystalEntry; @@ -13,24 +18,53 @@ import zmaster587.advancedRocketry.space.GalacticCoord; /** - * Turns surveyed cells into addresses a ship can navigate by. + * Turns a pointing into addresses a ship can navigate by. * *

    This is the discovery instrument, so it asks the registry what is THERE rather than what is * already known: an instrument that only reported what the player had already found could never find * anything.

    * - *

    What a cell yields is its system's bodies, one address each, at the coarsest detail an - * observation can carry. That grade is not a formality — it is what the navigation console reads to - * decide which of a body's fields it may show, and at telescope grade that is already the whole - * global set: name, mass, stellar class, rings, sky colour, topology, atmosphere and its density, - * temperature, water. A cell whose system has no resolvable content still yields its bare - * coordinate, so the address is learned even when nothing can yet be said about it.

    + *

    Two stages, because they are two different questions and only one of them is expensive.

    + *
      + *
    1. Detection ({@link #detect}) — is anything in this direction, and is it bright enough + * to register? An anchor lookup and a magnitude, both O(1), with no bodies built and no + * retinue derived. This is what a survey spends its looks on.
    2. + *
    3. Characterisation ({@link #characterise}) — what IS it? The system's bodies, one + * address each. Paid only where the first stage found something.
    4. + *
    + * + *

    They used to be one call, so the cheap question could never be asked without paying for the + * expensive one. {@link InfoTier} already distinguished the two grades of knowledge; what was missing + * was an instrument that could hold one without the other.

    + * + *

    What a look sees is bounded by BRIGHTNESS, never by distance. A star registers when its + * apparent magnitude from the observatory — its own luminosity, dimmed by distance and by whatever + * dust lies between — is above the aperture's limit. So the same instrument reaches a blue giant + * eighty times farther than a red dwarf, and a starless world it never reaches at all: a rogue + * planet emits nothing, and finding one is a thing you do by going there.

    + * + *

    What a cell yields once characterised is its system's bodies, one address each, at the + * coarsest detail an observation can carry. That grade is not a formality — it is what the navigation + * console reads to decide which of a body's fields it may show, and at telescope grade that is + * already the whole global set: name, mass, stellar class, rings, sky colour, topology, atmosphere + * and its density, temperature, water.

    */ public final class TelescopeScan { private TelescopeScan() { } + /** + * The most seats one look will enumerate inside its own territory before it goes back to + * sampling — see {@link IGalaxyGenerator#anchorsInTerritory}. + * + *

    Sized by what a UNIFORMLY divided field can hold, not by a feel for a good batch: a lattice + * divided {@code k} ways per axis puts {@code k³} seats in a territory, and 64 covers every + * division up to four. Past that the divider is a star cluster, where a survey samples rather + * than counts and always has.

    + */ + public static final int MAX_SEATS_PER_LOOK = 64; + /** How production names a body: by its dimension, the way every other GUI does. */ public static IntFunction dimensionNames() { return dimId -> { @@ -41,17 +75,206 @@ public static IntFunction dimensionNames() { } /** - * Resolve the next {@code count} cells of {@code scan} onto {@code crystal}. + * One point the instrument registered: where it is, and how it looked from where the instrument + * stands. + * + *

    The magnitude and the dust are carried rather than recomputed because the second stage needs + * them to decide how much it can make out — and because a detection is a fact about a LOOK, not + * about a system: the same star is a different detection from somewhere else.

    + */ + public static final class Detection { + + private final GalacticCoord anchor; + private final double apparentMagnitude; + private final double distanceLightYears; + private final double extinctionMagnitudes; + private final boolean resolvable; + + /** A detection of unstated provenance — always resolvable; see {@link #resolvable()}. */ + public Detection(GalacticCoord anchor, double apparentMagnitude, double distanceLightYears, + double extinctionMagnitudes) { + this(anchor, apparentMagnitude, distanceLightYears, extinctionMagnitudes, true); + } + + public Detection(GalacticCoord anchor, double apparentMagnitude, double distanceLightYears, + double extinctionMagnitudes, boolean resolvable) { + this.anchor = anchor; + this.apparentMagnitude = apparentMagnitude; + this.distanceLightYears = distanceLightYears; + this.extinctionMagnitudes = extinctionMagnitudes; + this.resolvable = resolvable; + } + + /** + * Whether this look was bright enough for the instrument to make out what is IN the system, + * as opposed to merely registering that it is there. + * + *

    Decided where the aperture is known — in {@link #detect} — and carried. It cannot + * be recomputed later against the configured instrument, because the instrument that took + * this look is not necessarily the one the configuration describes: a caller states the limit + * it is observing with, and asking the config afterwards would let those two disagree + * silently. A detection is a fact about a LOOK, and so is this.

    + */ + public boolean resolvable() { + return resolvable; + } + + /** The anchor cell of the system that was registered. */ + public GalacticCoord anchor() { + return anchor; + } + + /** How bright it looked from the instrument. Magnitudes: smaller is brighter. */ + public double apparentMagnitude() { + return apparentMagnitude; + } + + /** How far away it stands, in light years. */ + public double distanceLightYears() { + return distanceLightYears; + } + + /** How much dust lies between, in magnitudes of extinction. */ + public double extinctionMagnitudes() { + return extinctionMagnitudes; + } + + @Override + public String toString() { + return "Detection[" + anchor.cellKey() + ", m=" + String.format("%.2f", apparentMagnitude) + + ", " + String.format("%.1f", distanceLightYears) + " ly" + + (resolvable ? "" : ", point only") + "]"; + } + } + + /** + * STAGE ONE. Everything in {@code look}'s star territory that is bright enough to register from + * {@code observer}. + * + *

    The territory and not the point. A survey strides by the star territory, so a look + * that resolved only the point it landed on would report one seat in however many the generator + * divides that cube into — a fraction of the sky, presented as the sky. Asking for the + * territory's anchors makes the answer independent of how finely the field happens to be + * divided, which is the property a survey needs and a stride cannot give it.

    + * + *

    A null observer means the look is free of geometry: no distance, no dust, and + * everything present registers. That is what a caller with no position can honestly claim, and + * what every look was before an instrument had somewhere to stand.

    + */ + public static List detect(UniverseRegistry registry, GalacticCoord look, + GalacticCoord observer, double limitMagnitude) { + if (registry == null || look == null) { + return Collections.emptyList(); + } + List anchors = registry.anchorsInTerritory(look, MAX_SEATS_PER_LOOK); + if (anchors.isEmpty()) { + return Collections.emptyList(); + } + List hits = new ArrayList<>(anchors.size()); + double resolveLimit = limitMagnitude - resolveMarginMagnitudes(); + for (GalacticCoord anchor : anchors) { + if (observer == null) { + hits.add(new Detection(anchor, Double.NEGATIVE_INFINITY, 0d, 0d)); + continue; + } + // The STATIC-frame separation, which is the right one here and not an approximation: an + // anchor's frame really does sit at sector*CELL forever, and a survey looks at anchors. + double cells = observer.cellCentre().staticFrameDistanceTo(anchor.cellCentre()) + / (double) GalacticCoord.CELL; + double lightYears = UniverseScale.lightYearsForCells(cells); + StellarBody star = registry.starAt(anchor).orElse(null); + // CLEAR SKY FIRST, and this ordering is not a micro-optimisation — it is the difference + // between a survey that runs and one that does not. Measuring the dust on a sight line + // means integrating a cloud field along the whole of it, which is by far the dearest + // thing on this path, and extinction can only ever make a star DIMMER. So anything + // already too faint in a clear sky is rejected without asking about the dust, and a + // full pointing pays for the integral a dozen times instead of half a million. + double clearSky = StellarMagnitude.apparentMagnitudeOf(star, lightYears, 0d); + if (clearSky > limitMagnitude) { + continue; + } + double extinction = registry.extinctionBetween(observer, anchor); + double magnitude = clearSky + extinction; + if (magnitude <= limitMagnitude) { + hits.add(new Detection(anchor, magnitude, lightYears, extinction, + magnitude <= resolveLimit)); + } + } + return hits; + } + + /** + * STAGE TWO. Write down what {@code hit} turns out to be. + * + *

    A look is a touch. Everything here hands the operator something durable — an address + * he can fly to, a body he can name — out of a derivation that a later seed, config or generator + * edit would answer differently. Pinning first freezes the system into the save before a word of + * it is written down, so what the crystal holds and what the sky holds cannot come apart. The + * unit is the whole SYSTEM and not the bodies enumerated, because a system is what a pin can key. + * Idempotent and free for anything already authored or pinned.

    + * + *

    An unresolvable look still yields an address. Whether the dust was too thick or the + * operator has the instrument set to record positions only, the bare coordinate is written: the + * operator learns that something is there and has to go and see what. That is the whole + * mechanic — a reason to FLY somewhere rather than survey it from home — and it is why + * concealment costs detail and never the look itself.

    + * + *

    Brightness decides this too, not only the operator. A system registered near the + * aperture's limit is a point of light and nothing more — see {@link #resolveLimitMagnitude()}. + * The operator's choice can only ever ask for LESS than the instrument could have told him.

    + * + * @param wholeSystem whether to enumerate the system's bodies, or record the address alone. The + * operator's own choice: a full characterisation is the instrument's dear + * setting and fills a crystal far faster + * @return how many entries the memory gained or refreshed + */ + public static int characterise(UniverseRegistry registry, Detection hit, CrystalMemory memory, + long observedTick, IntFunction nameOf, + boolean wholeSystem) { + if (registry == null || hit == null || memory == null) { + return 0; + } + GalacticCoord anchor = hit.anchor(); + registry.pinSystem(anchor); + int written = 0; + boolean namedSomething = false; + if (wholeSystem && hit.resolvable() && !isObscuredAt(hit.extinctionMagnitudes())) { + for (SystemBody body : registry.systemBodiesAt(anchor)) { + namedSomething = true; + if (memory.record(entryFor(body, observedTick, nameOf))) { + written++; + } + } + } + if (!namedSomething) { + PlanetarySystem system = registry.systemForCoord(anchor).orElse(null); + if (memory.record(entryForSystem(anchor, system, observedTick))) { + written++; + } + } + return written; + } + + /** + * Resolve the next {@code count} looks of {@code scan} onto {@code crystal}. * * @return how many entries the crystal gained or refreshed */ public static int resolveBatch(UniverseRegistry registry, RegionScan scan, int from, int count, ItemStack crystal, long observedTick, IntFunction nameOf) { + return resolveBatch(registry, scan, from, count, crystal, observedTick, nameOf, null, true); + } + + /** The same, resolved from a stated observer, so distance and dust decide what registers. */ + public static int resolveBatch(UniverseRegistry registry, RegionScan scan, int from, int count, + ItemStack crystal, long observedTick, IntFunction nameOf, + GalacticCoord observer, boolean wholeSystem) { if (!ItemMemoryCrystal.isCrystal(crystal)) { return 0; } CrystalMemory memory = ItemMemoryCrystal.memoryOf(crystal); - int written = resolveBatch(registry, scan, from, count, memory, observedTick, nameOf); + int written = resolveBatch(registry, scan, from, count, memory, observedTick, nameOf, + observer, wholeSystem); if (written > 0) { ItemMemoryCrystal.writeMemory(crystal, memory); } @@ -61,47 +284,104 @@ public static int resolveBatch(UniverseRegistry registry, RegionScan scan, int f /** The same, onto an already-opened memory. This is where the discovery actually happens. */ public static int resolveBatch(UniverseRegistry registry, RegionScan scan, int from, int count, CrystalMemory memory, long observedTick, IntFunction nameOf) { + return resolveBatch(registry, scan, from, count, memory, observedTick, nameOf, null, true); + } + + /** + * The same, resolved from a stated OBSERVER — the form that can see how far away and how dim + * something is. + */ + public static int resolveBatch(UniverseRegistry registry, RegionScan scan, int from, int count, + CrystalMemory memory, long observedTick, IntFunction nameOf, + GalacticCoord observer, boolean wholeSystem) { if (registry == null || scan == null || memory == null) { return 0; } + double limit = limitMagnitude(); int written = 0; for (int index = from; index < from + count && index < scan.totalCells(); index++) { - written += resolveCell(registry, scan.cellAt(index), memory, observedTick, nameOf); + written += resolveLook(registry, scan.cellAt(index), memory, observedTick, nameOf, + observer, limit, wholeSystem); } return written; } /** - * Resolve ONE cell: every body of the system standing there, or the bare coordinate when the - * system has no content the registry can name. + * ONE look, both stages: what is in this direction's territory, and what those things are. + * + *

    The question a look asks is which systems this territory holds, never "is a star + * seated exactly at this point". A system is a neighbourhood: its star holds the anchor cell and + * every planet holds one of its own, so a cell that is a system's planet — or simply the space + * between its bodies — is a cell that resolves to that system. Asking whether the cell IS the + * seat means a survey discovers a system only by landing on its star's own address, which for a + * lattice thousands of cells wide is a thing that never happens.

    */ - public static int resolveCell(UniverseRegistry registry, GalacticCoord cell, CrystalMemory memory, - long observedTick, IntFunction nameOf) { - if (registry == null || cell == null || memory == null) { - return 0; - } - Map here = registry.systemsInRegion(cell, cell); - if (here.isEmpty()) { - return 0; - } + public static int resolveLook(UniverseRegistry registry, GalacticCoord look, CrystalMemory memory, + long observedTick, IntFunction nameOf, + GalacticCoord observer, double limitMagnitude, + boolean wholeSystem) { int written = 0; - boolean namedSomething = false; - for (SystemBody body : registry.systemBodiesAt(cell)) { - namedSomething = true; - if (memory.record(entryFor(body, observedTick, nameOf))) { - written++; - } - } - if (!namedSomething) { - for (Map.Entry system : here.entrySet()) { - if (memory.record(entryForSystem(system.getKey(), system.getValue(), observedTick))) { - written++; - } - } + for (Detection hit : detect(registry, look, observer, limitMagnitude)) { + written += characterise(registry, hit, memory, observedTick, nameOf, wholeSystem); } return written; } + /** The aperture the running game is configured with. Magnitudes: larger is fainter. */ + public static double limitMagnitude() { + return ARConfiguration.getCurrentConfig().telescopeLimitingMagnitude; + } + + /** + * How bright a system must be before this instrument can make out what is IN it — the aperture, + * less the margin characterisation costs. + * + *

    Seeing that a point of light is there and measuring what orbits it are two different + * observations, and the second wants far more photons: detection is conventionally called at + * a signal-to-noise of about 5, a usable spectrum at about 100, and signal-to-noise grows as the + * square root of what you collect — a flux ratio of 400, i.e. 6.5 magnitudes + * ({@link ARConfiguration#telescopeResolveMarginMagnitudes}).

    + * + *

    Everything registered but not resolved is still written down as a POSITION. That is the + * whole shape of the mechanic: a weak instrument hands back a list of places worth flying to, + * and a better one — or a visit — says what is at them.

    + */ + public static double resolveLimitMagnitude() { + return limitMagnitude() - resolveMarginMagnitudes(); + } + + /** How much brighter than its detection limit a system must be to be made out. Never negative. */ + public static double resolveMarginMagnitudes() { + return Math.max(0d, ARConfiguration.getCurrentConfig().telescopeResolveMarginMagnitudes); + } + + /** + * Whether a look from {@code observer} to {@code target} is OBSCURED — a cloud between them thick + * enough that a survey can no longer make out what is there, only that something is. + * + *

    The threshold is read in magnitudes of extinction, the unit the sky is measured in, and its + * shipped default is the astronomical boundary at which faint objects behind a cloud disappear. + * Zero or less turns the whole mechanic off, which is what "disable the flag" has to mean.

    + * + *

    It COMPOSES with the aperture rather than duplicating it, on the same currency: the same + * dust is added to the star's apparent magnitude, so a thick enough cloud takes the system below + * the limit and it is never detected at all. Between the two lies the interesting band — bright + * enough to see, dim enough that nothing about it can be made out.

    + */ + public static boolean isObscured(UniverseRegistry registry, GalacticCoord observer, + GalacticCoord target) { + if (registry == null || observer == null || target == null) { + return false; + } + return isObscuredAt(registry.extinctionBetween(observer, target)); + } + + /** The same decision against an extinction already measured — what a detection carries. */ + public static boolean isObscuredAt(double extinctionMagnitudes) { + double threshold = ARConfiguration.getCurrentConfig().telescopeObscuredAtMagnitudes; + return threshold > 0d && extinctionMagnitudes >= threshold; + } + /** One body's address, at the coarsest grade, dated by when it was seen. */ public static CrystalEntry entryFor(SystemBody body, long observedTick, IntFunction nameOf) { String name = ""; @@ -113,12 +393,35 @@ public static CrystalEntry entryFor(SystemBody body, long observedTick, IntFunct } /** - * A system with nothing the registry can enumerate: the address alone, so a pilot can still aim - * at the light and go look. It names no body, because none has been resolved. + * A system nothing has been resolved of: the address alone, so a pilot can still aim at the light + * and go look. It names no body, because none has been resolved. + */ + public static CrystalEntry entryForSystem(GalacticCoord coord, PlanetarySystem system, long observedTick) { + // The system's own name and its own PRIMARY KIND: a starless system recorded as a STAR would + // send a pilot out expecting a sun, and the address is the whole content of this entry. + String name = system == null ? "" : system.name(); + SystemBodyKind kind = system == null ? SystemBodyKind.STAR : system.primaryKind(); + return new CrystalEntry(coord.cellCentre(), name, kind, InfoTier.TELESCOPE, observedTick); + } + + /** + * The bodies of the system owning {@code cell}, written down without any photometry — the form + * an instrument standing INSIDE a system uses to report what it is standing in. + * + *

    Kept as its own entry point rather than folded into a look, because it answers a different + * question: not "what can I see from here" but "what is here". Nothing about brightness applies + * to a system you are inside.

    */ - public static CrystalEntry entryForSystem(GalacticCoord coord, StarSystem system, long observedTick) { - String name = system != null && system.star() != null ? system.star().getName() : ""; - return new CrystalEntry(coord.cellCentre(), name, SystemBodyKind.STAR, InfoTier.TELESCOPE, - observedTick); + public static int resolveCell(UniverseRegistry registry, GalacticCoord cell, CrystalMemory memory, + long observedTick, IntFunction nameOf) { + if (registry == null || cell == null || memory == null) { + return 0; + } + Optional anchor = registry.anchorForCell(cell); + if (!anchor.isPresent()) { + return 0; + } + return characterise(registry, new Detection(anchor.get(), Double.NEGATIVE_INFINITY, 0d, 0d), + memory, observedTick, nameOf, true); } } 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/UniverseLawsV0.java b/src/main/java/zmaster587/advancedRocketry/universe/UniverseLawsV0.java new file mode 100644 index 000000000..cbc27dfa6 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/UniverseLawsV0.java @@ -0,0 +1,71 @@ +package zmaster587.advancedRocketry.universe; + +/** + * Schema version 0's metric and expansion — every number exactly as {@link UniverseScale} and + * {@link Cosmology} state it. + * + *

    A pure forwarder, for the same reason {@link BodyDerivationV0} is one: the arithmetic stays where + * its constants are documented next to the observations they come from, and this class is only the + * handle a schema holds it by. A version 2 is a second implementation, never an edit to those two + * classes — editing them in place would change the universe under every world already made, which is + * what {@code universeLawsFingerprint} exists to catch. + * + *

    Stateless, so one instance serves every world. + */ +public final class UniverseLawsV0 implements IUniverseLaws { + + public static final UniverseLawsV0 INSTANCE = new UniverseLawsV0(); + + private UniverseLawsV0() { + } + + @Override + public long cellsForLightYears(double lightYears) { + return UniverseScale.cellsForLightYears(lightYears); + } + + @Override + public long cellsAt(double lightYears) { + return UniverseScale.cellsAt(lightYears); + } + + @Override + public double lightYearsForCells(double cells) { + return UniverseScale.lightYearsForCells(cells); + } + + @Override + public double lightYearsPerTick(double kilometresPerSecond) { + return UniverseScale.lightYearsPerTick(kilometresPerSecond); + } + + @Override + public long cellsForOrbitUnits(double orbitUnits) { + return UniverseScale.cellsForOrbitUnits(orbitUnits); + } + + @Override + public double orbitUnitsForCells(long cells) { + return UniverseScale.orbitUnitsForCells(cells); + } + + @Override + public long seatMarginCells(long spacingCells) { + return UniverseScale.seatMarginCells(spacingCells); + } + + @Override + public double retinueReachLy(double primaryRadiusLy) { + return UniverseScale.retinueReachLy(primaryRadiusLy); + } + + @Override + public double scaleFactorAt(long tick) { + return Cosmology.scaleFactorAt(tick); + } + + @Override + public long driftHorizonTicks() { + return Cosmology.DRIFT_HORIZON_TICKS; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/universe/UniverseRegistry.java b/src/main/java/zmaster587/advancedRocketry/universe/UniverseRegistry.java index bca782771..35552efc3 100644 --- a/src/main/java/zmaster587/advancedRocketry/universe/UniverseRegistry.java +++ b/src/main/java/zmaster587/advancedRocketry/universe/UniverseRegistry.java @@ -4,6 +4,8 @@ import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; +import java.util.Set; import java.util.List; import java.util.Map; import java.util.Optional; @@ -56,7 +58,16 @@ public final class UniverseRegistry extends WorldSavedData implements CellFrames public static final String STORAGE_KEY = "advancedrocketry_universe"; // v3: + durable cell names (derived once, then persisted and never re-derived) and their owning system. - private static final int NBT_VERSION = 3; + // v4: + the world-model stamp (schema version + galaxy-config fingerprint). + private static final int NBT_VERSION = 4; + + /** + * A save with no world-model stamp: a fresh world, or one written before the stamp existed. + * + *

    Negative on purpose. Version numbers start at ZERO — the alpha — so a sentinel of 0 would have + * read every alpha world as unstamped and silently re-adopted whatever the build shipped. + */ + public static final int UNSTAMPED = -1; // A self-contained logger rather than AdvancedRocketry.logger: loading the mod class triggers Forge // bootstrap (FluidRegistry.enableUniversalBucket), which would break pure unit tests of this registry. @@ -89,6 +100,45 @@ public final class UniverseRegistry extends WorldSavedData implements CellFrames private final Map namesByDim = new HashMap<>(); /** Latch: authored anchors drain into the store exactly once (unless a config XML reset is forced). */ private boolean anchorsSeeded = false; + /** + * The world model this save was generated under — {@link UniverseSchema#version()}, or + * {@link #UNSTAMPED} for a world made before the stamp existed. + * + *

    Deliberately SEPARATE from {@link #NBT_VERSION}: that one is the layout of these tags and + * moves whenever a field is added here, while this one is the identity of the universe those tags + * describe. A save whose tag layout is a version behind still describes the same sky; a save whose + * schema is a version behind describes a different one. + */ + private int schemaVersion = UNSTAMPED; + /** + * The fingerprint of the {@code } configuration this save was generated under. The + * schema decides HOW space is derived; these knobs decide the particular universe it derives, and + * an edit to either one moves every system nobody has touched yet. + */ + private String configFingerprint = ""; + /** + * The fingerprint of the LAWS this save was generated under — the metric and the expansion. + * + *

    Kept apart from the configuration's because the two are edited by different people for + * different reasons: the configuration is the pack author's, the laws are the mod's. Sharing one + * stamp would let a refusal blame the wrong one, and the remedies are not the same. + */ + private String lawsFingerprint = ""; + /** + * Set by an operator's upgrade, consumed by the NEXT load: permission, given once, to accept a + * {@code } that has changed. + * + *

    It exists because the refusal and its remedy cannot both live inside a running server. A + * fingerprint is one-way, so a save whose configuration has changed cannot be opened under the + * universe it was made with — the load has to refuse — and a command inside a server that refuses + * to start cannot be typed. So the acceptance is armed while the world still loads, which is also + * the only moment crystals can be read and the systems on them frozen, and it is spent at the boot + * after. + * + *

    Consumed exactly once, and only when the configuration actually differs, so an accidental + * edit a year later is refused like any other. + */ + private boolean upgradeArmed = false; // ─── Transient, re-derived per load ─────────────────────────────────────── /** The world seed fed to the generator; set by {@link #bindWorldSeed}, never persisted. */ @@ -106,9 +156,28 @@ public final class UniverseRegistry extends WorldSavedData implements CellFrames // the forward coord->system path is unit-testable without booting DimensionManager, and so an addon can // supply fabricated systems. private static volatile IntFunction starLookup = UniverseRegistry::lookupCatalogueStar; - private static Map pendingAnchors = new HashMap<>(); + private static Map pendingAnchors = new HashMap<>(); + /** + * The pack's {@code } configuration for this session, staged while dimensions load and + * paired with the save's schema stamp at {@link #populate}. Null means the pack declares none. + */ + private static volatile GalaxyGenConfig packGalaxyConfig = null; + /** The model {@link #populate} put in force for this session; null until it has run. */ + private static volatile UniverseSchema activeSchema = null; private static boolean pendingReset = false; + /** + * What each authored star was DECLARED as, keyed by star id — the galaxy-local form, kept so the + * catalogue can be written back in the language it was written in. Never persisted: it is re-read + * from XML on every load. + */ + private final Map declaredAnchors = new HashMap<>(); + + /** How this star was declared, if it was declared at all rather than given a fallback cell. */ + public GalacticAnchor declaredAnchorFor(int starId) { + return declaredAnchors.get(starId); + } + private static StellarBody lookupCatalogueStar(int starId) { return DimensionManager.getInstance().getStar(starId); } @@ -158,7 +227,7 @@ public static UniverseRegistry get(World world) { * a planet's own zone cell, or the void between bodies of one system — resolves to its owning system. * Resolution order: pinned → authored store → the procedural generator. Empty means void space. */ - public Optional systemForCoord(GalacticCoord coord) { + public Optional systemForCoord(GalacticCoord coord) { Optional anchor = anchorForCell(coord); if (!anchor.isPresent()) { return Optional.empty(); @@ -242,16 +311,17 @@ private static String neighbourSuperKey(GalacticCoord cell, int spacing, int dx, } /** The system AT a known anchor cell: pinned content → catalogued star → procedural generator. */ - private Optional systemAtAnchor(GalacticCoord anchor) { + private Optional systemAtAnchor(GalacticCoord anchor) { String key = anchor.cellKey(); PinnedSystem pinned = pinnedSystems.get(key); if (pinned != null) { - return Optional.of(new StarSystem(pinned.toStar())); + return Optional.of(pinned.toSystem()); } Integer id = byCell.get(key); if (id != null) { StellarBody star = starLookup.apply(id); - return star == null ? Optional.empty() : Optional.of(new StarSystem(star)); + return star == null ? Optional.empty() + : Optional.of(PlanetarySystem.ofStar(star)); } return generator.systemAt(worldSeed, anchor); } @@ -288,6 +358,26 @@ private static String superKey(GalacticCoord cell, int spacing) { } /** The stored (registered) system's star-id at this cell, or empty. Ignores the procedural generator. */ + /** + * Every anchor seated in the star TERRITORY {@code cell} falls in — what one look of a survey + * owes the direction it is pointed in (see {@link IGalaxyGenerator#anchorsInTerritory}). + * + *

    An authored or pinned anchor still wins over the whole territory, exactly as it does in + * {@link #anchorForCell}: a pack that placed a system there placed THE system there, and a + * procedural seat in the same cube would be a second answer to a question that has one.

    + */ + public List anchorsInTerritory(GalacticCoord cell, int limit) { + GalacticCoord c = cell.cellCentre(); + if (byCell.containsKey(c.cellKey())) { + return Collections.singletonList(c); + } + GalacticCoord stored = storedAnchorNear(c); + if (stored != null) { + return Collections.singletonList(stored); + } + return generator.anchorsInTerritory(worldSeed, c, limit); + } + public OptionalInt starIdForCoord(GalacticCoord coord) { Integer id = byCell.get(coord.cellCentre().cellKey()); return id == null ? OptionalInt.empty() : OptionalInt.of(id); @@ -303,7 +393,7 @@ public boolean hasOverrideAt(GalacticCoord coord) { } /** Every stored system whose cell falls inside the inclusive sector box, merged over the generator. */ - public Map systemsInRegion(GalacticCoord min, GalacticCoord max) { + public Map systemsInRegion(GalacticCoord min, GalacticCoord max) { // Normalise the box once (per axis) so the generator and the override scan see the same ordered // bounds — a real generator is entitled to assume min <= max. GalacticCoord lo = GalacticCoord.ofSectorLocal( @@ -314,7 +404,7 @@ public Map systemsInRegion(GalacticCoord min, Galacti Math.max(min.sectorX(), max.sectorX()), Math.max(min.sectorY(), max.sectorY()), Math.max(min.sectorZ(), max.sectorZ()), 0L, 0L, 0L); - Map out = new HashMap<>(generator.systemsInRegion(worldSeed, lo, hi)); + Map out = new HashMap<>(generator.systemsInRegion(worldSeed, lo, hi)); for (Map.Entry e : byStar.entrySet()) { GalacticCoord c = e.getValue(); if (c.sectorX() >= lo.sectorX() && c.sectorX() <= hi.sectorX() @@ -322,7 +412,7 @@ public Map systemsInRegion(GalacticCoord min, Galacti && c.sectorZ() >= lo.sectorZ() && c.sectorZ() <= hi.sectorZ()) { StellarBody star = starLookup.apply(e.getKey()); if (star != null) { - out.put(c, new StarSystem(star)); // overrides win over any procedural entry at the same cell + out.put(c, PlanetarySystem.ofStar(star)); // overrides win over any procedural entry here } } } @@ -449,10 +539,12 @@ private List allSystemBodies(GalacticCoord anchor) { Integer id = byCell.get(key); if (id != null) { StellarBody star = starLookup.apply(id); - return star == null - ? new ArrayList() - : SystemContent.bodiesOf(star, anchor, generator.minSpacingCells(), - this::durableName); + if (star == null) { + return new ArrayList(); + } + List authored = SystemContent.bodiesOf(star, anchor, + generator.minSpacingCells(), this::durableName); + return withDerivedRetinue(anchor, star, id, authored); } return new ArrayList<>(generator.bodiesFor(worldSeed, anchor)); } @@ -542,6 +634,28 @@ public boolean forgetName(int dimId) { * Server-side convenience for the dimension lifecycle: forget {@code dimId}'s recorded name on * whatever registry is reachable. A no-op with no server (a client, a unit test). */ + /** + * The bodies standing in {@code cell}, resolved through the running server's registry — for + * callers that hold an ADDRESS and no way to reach a registry, which is most of the space layer's + * entry path. An empty list when there is no server, no registry, or nothing there. + */ + public static List bodiesAtOnServer(GalacticCoord cell) { + if (cell == null) { + return Collections.emptyList(); + } + UniverseRegistry reg; + try { + reg = get(net.minecraftforge.fml.common.FMLCommonHandler.instance() + .getMinecraftServerInstance()); + } catch (Throwable noServer) { + // No Forge bootstrap at all — a pure unit context. "There is no server, so there is + // nothing standing in that cell" is the honest answer here and the caller's own fallback + // (the flat ring) is the right behaviour, so this is not swallowed error handling. + return Collections.emptyList(); + } + return (reg == null) ? Collections.emptyList() : reg.systemBodiesAt(cell); + } + public static void forgetNameOnServer(int dimId) { UniverseRegistry reg = get(net.minecraftforge.fml.common.FMLCommonHandler.instance() .getMinecraftServerInstance()); @@ -615,19 +729,108 @@ public boolean pinSystem(GalacticCoord coord) { if (byCell.containsKey(key)) { return false; // authored, or pinned already (pin places into byCell below) } - Optional sys = generator.systemAt(worldSeed, anchor); + Optional sys = generator.systemAt(worldSeed, anchor); if (!sys.isPresent()) { return false; } List bodies = new ArrayList<>(generator.bodiesFor(worldSeed, anchor)); - place(anchor, sys.get().starId()); - StellarBody star = sys.get().star(); - pinnedSystems.put(key, new PinnedSystem(sys.get().starId(), star.getTemperature(), star.getSize(), - star.getName(), bodies)); + place(anchor, sys.get().systemId()); + // A star's temperature and size are frozen HERE, because they are drawn values that a later + // seed or config edit would otherwise move under the planets already derived from them. A + // system with no star has neither, and freezing a zero for each would be inventing two + // properties it does not have — its primary's physics is derived from the cell like any + // other body's, and the cell is what the pin is keyed by. + PlanetarySystem system = sys.get(); + PinnedSystem snapshot = system.star().isPresent() + ? PinnedSystem.ofStar(system.systemId(), system.star().get(), bodies) + : PinnedSystem.ofRogue(system.systemId(), system.name(), bodies); + pinnedSystems.put(key, snapshot); markDirty(); 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.

    + * + *

    Empty means two different things and a caller has to tell them apart: there is no system here + * at all, or there IS one and its primary is not a star (a rogue world out in the void). Ask + * {@link #systemForCoord} when the difference matters.

    + */ + 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 pinned.toSystem().star(); + } + Integer id = byCell.get(anchor.cellKey()); + if (id != null) { + return Optional.ofNullable(starLookup.apply(id)); + } + Optional sys = generator.systemAt(worldSeed, anchor); + return sys.isPresent() ? 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()); @@ -731,6 +934,20 @@ public Optional coordForPlanet(int dimId) { return coordForPlanet(DimensionManager.getInstance().getDimensionProperties(dimId)); } + /** + * How much the diffuse matter between two cells dims what is behind it, in magnitudes of + * visual extinction — the unit astronomy states dust in. + * + *

    Zero in clear space, and zero for a universe with no clusters. What the number MEANS: + * ~1 is noticeable dimming, ~5 is where faint things behind a cloud disappear, ~10 is opaque in + * the visible. The calibration from this model's density to magnitudes lives on + * {@link Nebula#MAGNITUDES_PER_DENSITY_LIGHT_YEAR} with its anchor written out; what a given + * mechanic does at a given number of magnitudes is that mechanic's own (tunable) business.

    + */ + public double extinctionBetween(GalacticCoord from, GalacticCoord to) { + return Nebula.magnitudesForColumn(generator.columnDensityBetween(worldSeed, from, to)); + } + /** * Whether the system at {@code coord} is known. DERIVED, never stored: a system is known iff any of its * member bodies with a real dimension is in the global known set ({@link DimensionManager#isPlanetKnown}). @@ -802,7 +1019,14 @@ public boolean remove(GalacticCoord coord) { * are what makes a written-down coordinate keep denoting its body; clearing them here would mean * exactly the guarantee the store exists to give fails in the one case it is needed most.

    */ - public void applyAnchors(Map anchors, boolean reset) { + public void applyAnchors(Map anchors, boolean reset) { + // Remembered BEFORE the seeded early-return: the declaration is what the catalogue gets + // written back as, and on a restart the anchors are already placed while the XML still has to + // round-trip. It is re-read from XML on every load, which is exactly its lifetime. + declaredAnchors.clear(); + if (anchors != null) { + declaredAnchors.putAll(anchors); + } if (anchorsSeeded && !reset) { return; } @@ -810,16 +1034,38 @@ public void applyAnchors(Map anchors, boolean reset) { List ids = new ArrayList<>(anchors.keySet()); Collections.sort(ids); for (Integer id : ids) { - GalacticCoord c = anchors.get(id); - if (c != null) { - place(c, id); + GalacticAnchor anchor = anchors.get(id); + if (anchor == null) { + continue; } + place(resolveAnchor(anchor, id), id); } } anchorsSeeded = true; markDirty(); } + /** + * Turn a galaxy-local declaration into the absolute cell name everything downstream uses. Done + * ONCE, here, at the reference angle — afterwards the authored system is named by a cell exactly + * like a procedural one and rotates with its galaxy exactly like one. + * + *

    An anchor reaching past the radius its galaxy is GUARANTEED is a loud error and never a + * silent clamp: beyond that wall the position is valid on some seeds and intergalactic on others, + * and a pack author has to learn that from a log line rather than from a player's bug report.

    + */ + private GalacticCoord resolveAnchor(GalacticAnchor anchor, int starId) { + double guaranteed = generator.guaranteedAuthoredReachLy(); + if (guaranteed > 0d && anchor.reachLy() > guaranteed) { + LOGGER.error("star " + starId + " is authored at " + anchor + + ", which is " + (long) anchor.reachLy() + " light years from its galaxy's centre" + + " against a guaranteed radius of " + (long) guaranteed + ". On a seed whose" + + " galaxy comes out smaller than that, this system will sit in intergalactic" + + " space. Move it inside the guaranteed radius."); + } + return anchor.resolve(generator.declarationOriginOf(worldSeed, anchor.galaxy())); + } + /** * Give every catalogued star that still lacks a placement a deterministic fallback cell, so * planet→coord is total over the legacy galaxy. Sol (id 0) defaults to the origin; others take the @@ -862,17 +1108,243 @@ public long worldSeed() { return worldSeed; } + // ─── The world-model stamp (schema version + config fingerprint) ─────────── + + /** The world model this save was generated under, or {@link #UNSTAMPED}. */ + public int schemaVersion() { + return schemaVersion; + } + + /** + * The world model in force, or empty before {@link #populate} has resolved one. + * + *

    What a caller usually wants this for is {@link UniverseSchema#isStable()} — whether the world + * it is about to touch was generated by an ALPHA model that may be replaced rather than carried + * forward. + */ + public static Optional activeSchema() { + return Optional.ofNullable(activeSchema); + } + + /** How many procedural systems this save has frozen — what an upgrade would carry over untouched. */ + public int pinnedSystemCount() { + return pinnedSystems.size(); + } + + /** The galaxy-config fingerprint this save was generated under; empty when unstamped. */ + public String configFingerprint() { + return configFingerprint; + } + + /** The laws fingerprint (metric + expansion) this save was generated under; empty when unstamped. */ + public String lawsFingerprint() { + return lawsFingerprint; + } + + /** + * The identity of a set of laws, taken by MEASURING them rather than by listing their constants. + * + *

    Fixed inputs through every conversion, plus the expansion at fixed ticks, hashed. Two reasons + * it is done this way. It works for any implementation, so a schema version 2 with its own metric + * needs no fingerprinting code of its own. And it catches what a declaration cannot: an + * implementation whose internal constant moved while whatever list it publishes stayed the same. + */ + public static String lawsFingerprintOf(IUniverseLaws laws) { + StringBuilder sb = new StringBuilder(256); + sb.append("laws1;"); + double[] lightYears = {0.1d, 1d, 4.23d, 100d, 50_000d}; + for (double ly : lightYears) { + sb.append(laws.cellsForLightYears(ly)).append(',').append(laws.cellsAt(ly)).append(';'); + } + long[] cells = {1L, 1_000_000L, 5_002_361L}; + for (long c : cells) { + sb.append(Fingerprint.bits(laws.lightYearsForCells(c))).append(',') + .append(Fingerprint.bits(laws.orbitUnitsForCells(c))).append(',') + .append(laws.seatMarginCells(c)).append(';'); + } + sb.append(Fingerprint.bits(laws.lightYearsPerTick(1d))).append(';'); + sb.append(laws.cellsForOrbitUnits(1d)).append(';'); + sb.append(Fingerprint.bits(laws.retinueReachLy(1d))).append(';'); + for (long tick : new long[]{0L, 24_000L, 24_000_000L}) { + sb.append(Fingerprint.bits(laws.scaleFactorAt(tick))).append(';'); + } + sb.append(laws.driftHorizonTicks()); + return Fingerprint.hex16(sb.toString()); + } + + /** What THIS build's newest schema measures with — what a fresh world is stamped against. */ + public static String currentLawsFingerprint() { + return lawsFingerprintOf(UniverseSchemas.current().laws()); + } + + /** + * Decide which world model this save must be read under, and stamp it if it has none yet. + * + *

    The version comes from the SAVE, never from the pack. That inversion is the whole + * mechanism: a world generated under schema 1 keeps being derived by schema 1 after the mod ships + * schema 2, so the mod is free to move — new mechanics, new blocks, new balance — while the sky a + * player has already charted stays where he charted it. Only {@code upgrade} moves a world. + * + *

    Two refusals, and both are recoverable from outside the game. A stamp naming a version + * this build does not carry (a world from a newer jar, or one whose version was dropped) and a + * configuration that has been edited since the world was made. Neither can be honoured by + * substituting something close: continuing would answer a different universe under an unchanged + * save, and the player would find out by flying somewhere his notes describe. + * + *

    Note that a pack edit which merely ADDS — one more authored anchor naming a new galaxy, one + * more star archetype — changes the fingerprint like any other, and that is correct rather than + * strict: a reserved galaxy is a galaxy forced into a cell that had its own contents, and one more + * weight moves every draw that walks the table. + * + * @param config the pack's {@code } configuration, or {@code null} for an + * authored-anchors-only universe + * @return the schema to install for this world + * @throws UniverseSchemaMismatchException when the save cannot be honoured by this build + */ + public UniverseSchema reconcileSchema(GalaxyGenConfig config) { + String fingerprint = fingerprintOf(config); + if (schemaVersion == UNSTAMPED) { + UniverseSchema schema = UniverseSchemas.current(); + if (!byCell.isEmpty() || !pinnedSystems.isEmpty()) { + // A world with content but no stamp predates the stamp. Nothing records what generated + // it, so adopting the current model is the only move available — said out loud, because + // it is the one case where this class cannot prove the sky is unchanged. + LOGGER.warn("Universe save carries content but no world-model stamp; adopting schema {} " + + "and configuration {}. If this world was generated by a different build, its " + + "untouched systems may have moved.", schema.version(), fingerprint); + } + stampSchema(schema.version(), fingerprint); + return schema; + } + Optional saved = UniverseSchemas.of(schemaVersion); + if (!saved.isPresent()) { + throw new UniverseSchemaMismatchException( + "This world was generated under universe schema " + schemaVersion + + ", which this build does not carry (it has " + UniverseSchemas.released() + + "). Install a build that carries schema " + schemaVersion + + " to open this world."); + } + // Measured against the laws of the schema THIS SAVE is owed, not the newest ones. A build that + // ships a new metric ships it as a new schema version, and this world simply keeps using its own + // — which is why a mismatch here does not mean "the mod moved on". It means schema + // %d's laws in this jar are not the ones that made this world, i.e. a released version was + // edited in place. That is a developer error, and there is nothing a player or an operator can + // do about it, so it is not something an upgrade may accept. + String laws = lawsFingerprintOf(saved.get().laws()); + if (!lawsFingerprint.isEmpty() && !lawsFingerprint.equals(laws)) { + throw new UniverseSchemaMismatchException( + "Universe schema " + schemaVersion + " in this build does not measure the way it did " + + "when this world was generated: the world was made under laws " + + lawsFingerprint + " and this build's schema " + schemaVersion + " states " + + laws + ". A released schema's metric and expansion may never change — a " + + "changed metric ships as a NEW schema version, which old worlds simply do " + + "not use. This build is broken; install one whose schema " + schemaVersion + + " is intact."); + } + if (!configFingerprint.equals(fingerprint)) { + if (upgradeArmed) { + // Permission was given last session, by an operator, on a world that was still loading + // — which is when the crystals could be read and their systems frozen. Spend it. + LOGGER.warn("Accepting the changed for this world: {} -> {}. This was armed " + + "by an operator's upgrade. Systems already frozen keep exactly what they held; " + + "everything else is re-derived from here.", configFingerprint, fingerprint); + upgradeArmed = false; + stampSchema(UniverseSchemas.CURRENT, fingerprint); + return UniverseSchemas.current(); + } + throw new UniverseSchemaMismatchException( + "The configuration has changed since this world was generated: it was " + + "made under " + configFingerprint + " and this pack states " + fingerprint + + ". Every system nobody has visited yet would move, so this world will not " + + "open under it. " + + "To go back: restore the previous and start again. " + + "To accept the change: restore the previous , start, run " + + "\"/stellurgy universe upgrade confirm\" (that freezes every system anyone " + + "has seen, including the addresses on the memory crystals of players who " + + "are online), stop, put the new configuration back, and start again."); + } + return saved.get(); + } + + /** + * Accept {@code config} (and the current schema) as this world's model from now on — the write half + * of the upgrade, after everything already seen has been pinned. + * + * @return the schema now in force + */ + /** + * Whether this world is holding an operator's one-shot permission to accept a changed + * {@code } at its next load. + */ + public boolean isUpgradeArmed() { + return upgradeArmed; + } + + /** + * Give that permission — the half of an upgrade that a running server can perform for a change it + * cannot see yet. It is spent by the next load, and only if the configuration has actually moved. + */ + public void armUpgrade() { + if (!upgradeArmed) { + upgradeArmed = true; + markDirty(); + } + } + + public UniverseSchema adoptSchema(GalaxyGenConfig config) { + UniverseSchema schema = UniverseSchemas.current(); + stampSchema(schema.version(), fingerprintOf(config)); + return schema; + } + + /** The fingerprint a {@code null} (authored-anchors-only) configuration has its own name for. */ + public static String fingerprintOf(GalaxyGenConfig config) { + return (config == null) ? GalaxyGenConfig.noGeneratorFingerprint() : config.fingerprint(); + } + + private void stampSchema(int version, String fingerprint) { + String laws = currentLawsFingerprint(); + if (schemaVersion == version && configFingerprint.equals(fingerprint) + && lawsFingerprint.equals(laws)) { + return; + } + schemaVersion = version; + configFingerprint = fingerprint; + lawsFingerprint = laws; + markDirty(); + } + // ─── Static staging + population (server lifecycle) ──────────────────────── /** * Buffer XML-authored anchor coords parsed during {@code createAndLoadDimensions} (before worlds load, so * the registry is not yet reachable). Drained by {@link #populate} once worlds are up. */ - public static void stageAnchors(Map anchors, boolean reset) { - pendingAnchors = (anchors == null) ? new HashMap() : new HashMap<>(anchors); + public static void stageAnchors(Map anchors, boolean reset) { + pendingAnchors = (anchors == null) ? new HashMap() : new HashMap<>(anchors); pendingReset = reset; } + /** + * Hand over the pack's {@code } configuration, read while dimensions load — before the + * save is reachable, so before anything can know which model this world is owed. + * + *

    The pack states the KNOBS; the save states the VERSION. {@link #populate} puts the two together + * and installs the generator, which is why the generator is no longer built at the XML site: doing + * it there would make the pack the authority on a question that belongs to the world. + * + *

    It is kept for the session rather than drained, because an upgrade run later needs the same + * configuration to stamp. + */ + public static void stageGalaxyConfig(GalaxyGenConfig config) { + packGalaxyConfig = config; + } + + /** The pack's {@code } configuration for this session, or {@code null} if it declares none. */ + public static GalaxyGenConfig packGalaxyConfig() { + return packGalaxyConfig; + } + /** * Server-start hook (call once worlds are loaded): bind the world seed, drain staged anchors, and give * every remaining catalogued star a fallback coord. Idempotent across restarts. @@ -889,6 +1361,23 @@ public static void populate(MinecraftServer server) { if (overworld != null) { reg.bindWorldSeed(overworld.getSeed()); } + // Raise the world model from the SAVE and install its generator, BEFORE anything derives. + // applyAnchors resolves declared positions through the generator, so an anchor placed under the + // wrong model would be placed wrongly and then persisted. + UniverseSchema schema = reg.reconcileSchema(packGalaxyConfig); + activeSchema = schema; + setGenerator(schema.generator(packGalaxyConfig)); + LOGGER.info("Universe schema {} ({}) in force, configuration {}", schema.version(), + schema.label(), reg.configFingerprint()); + if (!schema.isStable()) { + // Loud, and at WARN, because it is a statement about the FUTURE of this save rather than + // about anything wrong with it now: an alpha model may be replaced outright, and a world + // built on one is not promised a way forward. + LOGGER.warn("Universe generator {} is an ALPHA. Its leading zero means the world model may " + + "be REPLACED in a later release rather than extended: worlds generated under it " + + "are not guaranteed to be carried forward, and only what has already been seen is " + + "frozen. Do not start a world you intend to keep for years on it.", schema.label()); + } reg.applyAnchors(pendingAnchors, pendingReset); reg.assignFallbackCoords(DimensionManager.getInstance().getStars()); pendingAnchors = new HashMap<>(); @@ -957,6 +1446,14 @@ public void readFromNBT(NBTTagCompound nbt) { namesByDim.clear(); anchorsBySuper = null; anchorsSeeded = nbt.getBoolean("anchorsSeeded"); + // Read through hasKey, NEVER through the value alone. NBT answers 0 for an absent integer, and + // 0 is a real version number — the alpha — so taking the default would report every stampless + // save as "generated by the alpha" and quietly skip the adoption that a fresh world is owed. + // This is also why UNSTAMPED is negative: no version is. + schemaVersion = nbt.hasKey("schemaVersion") ? nbt.getInteger("schemaVersion") : UNSTAMPED; + configFingerprint = nbt.getString("galaxyConfigFingerprint"); + lawsFingerprint = nbt.getString("universeLawsFingerprint"); + upgradeArmed = nbt.getBoolean("universeUpgradeArmed"); NBTTagList names = nbt.getTagList("cellNames", 10 /* NBTTagCompound */); for (int i = 0; i < names.tagCount(); i++) { NBTTagCompound e = names.getCompoundTagAt(i); @@ -991,7 +1488,15 @@ public void readFromNBT(NBTTagCompound nbt) { for (int j = 0; j < bodyList.tagCount(); j++) { bodies.add(SystemBody.readFromNBT(bodyList.getCompoundTagAt(j))); } - pinnedSystems.put(anchor.cellKey(), new PinnedSystem(e.getInteger("starId"), + SystemBodyKind primaryKind = SystemBodyKind.STAR; + if (e.hasKey("primaryKind")) { + try { + primaryKind = SystemBodyKind.valueOf(e.getString("primaryKind")); + } catch (IllegalArgumentException ex) { + primaryKind = SystemBodyKind.STAR; // a kind this build does not know: read it as a star + } + } + pinnedSystems.put(anchor.cellKey(), PinnedSystem.read(e.getInteger("starId"), primaryKind, e.getInteger("temperature"), e.getFloat("size"), e.getString("name"), bodies)); } } @@ -1000,6 +1505,10 @@ public void readFromNBT(NBTTagCompound nbt) { public NBTTagCompound writeToNBT(NBTTagCompound nbt) { nbt.setInteger("version", NBT_VERSION); nbt.setBoolean("anchorsSeeded", anchorsSeeded); + nbt.setInteger("schemaVersion", schemaVersion); + nbt.setString("galaxyConfigFingerprint", configFingerprint); + nbt.setString("universeLawsFingerprint", lawsFingerprint); + nbt.setBoolean("universeUpgradeArmed", upgradeArmed); NBTTagList list = new NBTTagList(); for (Map.Entry e : byStar.entrySet()) { NBTTagCompound entry = new NBTTagCompound(); @@ -1036,6 +1545,11 @@ public NBTTagCompound writeToNBT(NBTTagCompound nbt) { NBTTagCompound entry = new NBTTagCompound(); anchor.writeToNBT(entry); entry.setInteger("starId", p.starId); + // Written only when it is not a star, so a stellar system's snapshot is byte-identical to + // what it was before starless systems existed. + if (p.primaryKind != SystemBodyKind.STAR) { + entry.setString("primaryKind", p.primaryKind.name()); + } entry.setInteger("temperature", p.temperature); entry.setFloat("size", p.size); entry.setString("name", p.name == null ? "" : p.name); @@ -1052,29 +1566,87 @@ public NBTTagCompound writeToNBT(NBTTagCompound nbt) { return nbt; } - /** A pinned procedural system's content snapshot (A#1a pin-on-touch): fabricated star + body list. */ + /** + * A pinned procedural system's content snapshot (A#1a pin-on-touch): its primary's drawn + * properties plus its full body list. + * + *

    {@code primaryKind} is what a re-read reconstructs the system FROM, and it is stored rather + * than inferred from a zero temperature: a star that happens to be cold and a system that has no + * star are different facts, and telling them apart by their arithmetic is exactly the confusion + * the kind exists to end.

    + */ private static final class PinnedSystem { final int starId; + final SystemBodyKind primaryKind; final int temperature; final float size; final String name; final List bodies; - PinnedSystem(int starId, int temperature, float size, String name, List bodies) { + private PinnedSystem(int starId, SystemBodyKind primaryKind, int temperature, float size, + String name, List bodies) { this.starId = starId; + this.primaryKind = primaryKind; this.temperature = temperature; this.size = size; this.name = name; this.bodies = bodies; } - StellarBody toStar() { + static PinnedSystem ofStar(int starId, StellarBody star, List bodies) { + return new PinnedSystem(starId, SystemBodyKind.STAR, star.getTemperature(), star.getSize(), + star.getName(), bodies); + } + + /** A system anchored on a starless world: an id, a name, and nothing a star would have had. */ + static PinnedSystem ofRogue(int starId, String name, List bodies) { + return new PinnedSystem(starId, SystemBodyKind.ROGUE_PLANET, 0, 0f, name, bodies); + } + + static PinnedSystem read(int starId, SystemBodyKind primaryKind, int temperature, float size, + String name, List bodies) { + return new PinnedSystem(starId, primaryKind, temperature, size, name, bodies); + } + + PlanetarySystem toSystem() { + if (primaryKind != SystemBodyKind.STAR) { + return PlanetarySystem.ofRogue(starId, name); + } StellarBody star = new StellarBody(); star.setId(starId); star.setTemperature(temperature); star.setSize(size); star.setName(name); - return star; + return PlanetarySystem.ofStar(star); } } + + /** + * An authored system's bodies, plus the DERIVED worlds its pack asked for. + * + *

    An authored system used to be filled by a second world-making model: a random generator seeded + * on {@code System.currentTimeMillis()} that registered Forge dimensions up front at world + * creation. It meant two saves of one seed differed, and every defect in this family had to be + * found and fixed twice in two models that answered the same question differently. The pack-facing + * knob survives as {@link StellarBody#getMaxRetinueBodies()}; the second model does not, and the + * worlds it used to mint are now derived from {@code (seed, cell)} and realized on arrival like + * every other world in the game.

    + * + *

    The authored bodies always win: their cells are handed to the derivation as already taken, so + * nothing derived can land on one. A system that asks for none is untouched.

    + */ + private List withDerivedRetinue(GalacticCoord anchor, StellarBody star, int starId, + List authored) { + int asked = star.getMaxRetinueBodies(); + if (asked <= 0 || generator == null) { + return authored; + } + Set taken = new HashSet<>(); + for (SystemBody b : authored) { + taken.add(b.name().cellKey()); + } + List all = new ArrayList<>(authored); + all.addAll(generator.authoredRetinueFor(worldSeed, anchor, star, starId, asked, taken)); + return all; + } } 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..ffbb11757 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/UniverseScale.java @@ -0,0 +1,334 @@ +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.
    • + *
    + * + *

    One level up, the same pair says how big a galaxy is and how far apart galaxies stand — see the + * galaxy-lattice section below. It is the same scheme applied twice, which is the point: a system is + * seated in a cube, and so is the galaxy that holds it.

    + * + *

    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 — and the lattice is now built to + * PRODUCE it rather than to use it as a cube edge. + * + *

    4.23 is the observed figure for a solar neighbourhood, and it is the quantity this layer + * actually means; the cube edge is machinery underneath it. Until 2026-08-19 this number was + * consumed directly as {@link #DEFAULT_SPACING_CELLS}, i.e. as the EDGE, which is a different + * quantity: a cube of edge {@code e} occupied with probability {@code p} puts its neighbours + * {@code e / p^(1/3)} apart, so the field stood 6.0 ly apart at the shipped occupancy while + * this constant said 4.23. Measured on the shipped generator before the fix: 4913 territories + * around the origin seated 1574 systems, occupancy 0.320, mean separation 6.18 ly — 42 % above + * what the name promised, and the javadoc attributed the excess to the lattice being stratified + * when the dominant term was the occupancy.

    + * + *

    See {@link #DEFAULT_STAR_OCCUPANCY} for the knob that closes the gap, and + * {@link #DEFAULT_SPACING_CELLS} for the edge that now follows from both.

    + */ + public static final double MEAN_STAR_SEPARATION_LY = 4.23d; + + /** + * What fraction of star territories hold a system at a galaxy's densest point — the lattice's fill, + * and a balance knob rather than an observation. + * + *

    It lives here beside the separation because the two together decide the edge, and a knob that + * silently changes a measured quantity belongs next to the quantity it changes. A pack may still + * override the occupancy through {@code }; what it cannot do is move the + * separation without saying so, because the separation is what the edge is derived from.

    + */ + public static final double DEFAULT_STAR_OCCUPANCY = 0.35d; + + /** + * 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 — machinery, derived from the + * two quantities that mean something: the separation the field should show and the fraction of + * territories that hold anything. + * + *

    {@code edge = separation × occupancy^(1/3)}, the inverse of the relation in + * {@link #MEAN_STAR_SEPARATION_LY}: a sparser lattice needs a smaller cube to put its neighbours the + * same distance apart. At the shipped 4.23 ly and 0.35 that is 2.98 ly of edge, down from the 4.23 + * this used to take verbatim — a finer partition, and the field lands where the constant says.

    + * + *

    The clear space a seat needs is unaffected and remains far below the new edge: + * {@link #SEPARATION_FLOOR_AU} is 10 000 AU = 0.158 ly, i.e. about 5 % of this edge rather than the + * 3.7 % it was. A balance knob, overridable from the generator's configuration, 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 * Math.cbrt(DEFAULT_STAR_OCCUPANCY) + * AstronomicalBodyHelper.BLOCKS_PER_LIGHT_YEAR / (double) GalacticCoord.CELL))); + + /** + * The mean separation a lattice of {@code edgeCells} at occupancy {@code occupancy} actually + * produces, in light years — the relation stated once, so a caller can ask instead of re-deriving. + */ + public static double meanSeparationLy(long edgeCells, double occupancy) { + double edgeLy = lightYearsForCells(edgeCells); + double p = Math.min(1d, Math.max(1e-9d, occupancy)); + return edgeLy / Math.cbrt(p); + } + + // ─── The galaxy lattice ──────────────────────────────────────────────────── + // One level up, and the same scheme: a cube that holds at most one galaxy, and a galaxy seated + // inside it. What is stated here is a REFERENCE SIZE and a RATIO; the separation follows from + // them, and an individual galaxy's radius is drawn per type. + // + // The reference is a REAL galaxy, and the separation is the real one, so the whole layer is at + // its physical scale. It used to be about a thirtieth of that, to keep a galaxy cube inside one + // long of BLOCKS; nothing stores a position that way — every position in this layer is a sector + // triple plus an in-cell offset — so the compression was paying for a representation nobody + // built. What the sector triple actually gives at this scale is measured in GalaxyFieldTest. + + /** + * The size a galaxy is quoted against, in light years — a mid-sized spiral, i.e. the Milky Way, + * holding of the order of 1011 systems at {@link #MEAN_STAR_SEPARATION_LY}. + * + *

    It is not itself a bound on anything: it anchors {@link #MEAN_GALAXY_SEPARATION_LY} and it + * is the size {@code GalaxyGenConfig}'s type table is written against. Those bands are stated as + * ABSOLUTE light years, so they can be checked against a real catalogue rather than read as + * ratios nobody can verify — which means that moving this number does not move them, and they + * must be RE-DERIVED from real radii rather than scaled. Scaling the old table by the same + * factor produced dwarf galaxies larger than real spirals.

    + * + *

    The compressed value it replaces was chosen so that a galaxy CUBE fit inside one + * {@code long} of blocks, because a void position was believed to be a block offset from its + * cell's origin. It is not: it is a sector triple plus an in-cell offset, and the sector space + * carries this scale with six orders of headroom. The one place a whole separation is still + * expressed as a block {@code long} is a {@link zmaster587.advancedRocketry.space.BlockDelta}, + * which is now able to say when it could not hold one.

    + */ + public static final double REFERENCE_GALAXY_RADIUS_LY = 50_000d; + + /** + * How far apart galaxies stand, in galaxy DIAMETERS. This is the real number — galaxies in a + * group sit tens of diameters apart — and it is what the separation below is derived from, so + * shrinking the reference size shrinks the whole layer coherently instead of leaving galaxies + * marooned at a real separation. + */ + public static final double GALAXY_SEPARATION_IN_DIAMETERS = 25d; + + /** Edge of the cube that holds at most one galaxy, in light years. */ + public static final double MEAN_GALAXY_SEPARATION_LY = + GALAXY_SEPARATION_IN_DIAMETERS * 2d * REFERENCE_GALAXY_RADIUS_LY; + + /** + * The same edge in cells — the default {@code galaxySpacing}. A {@code long}, not an {@code int}: + * the galaxy lattice is five orders coarser than the star lattice and does not fit one. + */ + public static final long DEFAULT_GALAXY_SPACING_CELLS = + cellsForLightYears(MEAN_GALAXY_SEPARATION_LY); + + /** + * The radius a galaxy holding AUTHORED content is guaranteed to have at least, in light years. A + * galaxy's size is hash-drawn, so without a floor a pack that places content a few hundred light + * years out would work on one seed and put that content outside its own galaxy on the next. The + * floor is expressed as a constraint on which TYPES such a galaxy may be drawn from, never as a + * clamp applied afterwards. + * + *

    It is set at the smallest DISC GIANT a real catalogue holds, which is what makes the + * qualifying set "the spirals and the ellipticals" and excludes both dwarf classes. It is a + * separate number from any type's band on purpose: the two are not the same statement, and the + * day a pack widens the spiral band downwards this floor should keep its meaning rather than + * follow it.

    + */ + public static final double MIN_AUTHORED_GALAXY_RADIUS_LY = 15_000d; + + // ─── A galaxy's retinue ──────────────────────────────────────────────────── + // The lattice holds at most one galaxy per cube and the cube is 25 diameters across, so on the + // lattice alone the nearest galaxy is always 25 diameters away. That is the distance to the nearest + // equal GIANT — Milky Way to Andromeda — and it was standing in for the distance to the nearest + // galaxy of any kind. Real giants keep company far closer: the Large Magellanic Cloud is 1.6 + // diameters out, the Sagittarius dwarf is inside the halo. + // + // So a galaxy draws SATELLITES as children inside its own cube, exactly as a system draws moons + // inside its primary's cell. Nothing about the representation moves: the cube keeps its size, the + // sector space is untouched, and a satellite is a Galaxy value produced from (seed, cell) like any + // other. + + /** + * How far a satellite is seated from its primary, in the primary's DIAMETERS. The band real + * companions occupy: the LMC sits at about 1.6, and the more distant members of a group run to a + * few. + * + *

    Its floor is what keeps a satellite OUTSIDE its primary — a satellite is at least one whole + * diameter out, so even the largest one clears the primary's edge by a comfortable margin, and two + * galaxies never overlap. That is not cosmetic: overlapping spheres would make "which galaxy is + * this point in" a question with two answers, and the whole layer is built on it having one.

    + */ + public static final double MIN_SATELLITE_DISTANCE_IN_DIAMETERS = 1d; + public static final double MAX_SATELLITE_DISTANCE_IN_DIAMETERS = 3d; + + /** + * How large a satellite may be, as a fraction of its primary's radius. A satellite is drawn from + * the galaxy types whose whole band fits under this, so "smaller than what it orbits" is a property + * of the DRAW rather than a clamp applied to its result — the same shape as the authored-content + * floor above. + * + *

    Measured against the real pair it is named for: the LMC is 0.14 of the Milky Way's radius and + * the SMC 0.07, and M32 is about 0.1 of Andromeda. The bound is loose enough to admit a dwarf + * irregular around a large spiral and tight enough to exclude a second giant.

    + */ + public static final double MAX_SATELLITE_RADIUS_FRACTION = 0.3d; + + /** + * How far a galaxy's whole RETINUE reaches from its centre, in light years — the primary's own + * radius, its farthest satellite seat, and that satellite's own radius. + * + *

    This, and not the primary's radius, is what a galaxy must be seated clear of its cube's faces + * by. A margin sized to the primary alone would let a galaxy seated near a face keep satellites + * OUTSIDE the cube, and a galaxy outside its own lattice cell is one the index attributes to a + * neighbour — the single-answer invariant, broken by a number that was right before satellites + * existed.

    + */ + public static double retinueReachLy(double primaryRadiusLy) { + double radius = Math.max(0d, primaryRadiusLy); + double farthestSeat = MAX_SATELLITE_DISTANCE_IN_DIAMETERS * 2d * radius; + return farthestSeat + MAX_SATELLITE_RADIUS_FRACTION * radius; + } + + /** + * Where the universe ORIGIN sits inside the home galaxy, as a fraction of its radius — and it is + * emphatically not the centre. + * + *

    The home galaxy is seated AROUND the origin rather than ON it, because the origin is where + * authored content lives and the centre of a galaxy is its nucleus: the densest, most violent + * place in it, and the last address a shipped solar system should have. Sol sits at about half + * the Milky Way's disc radius; this puts the origin in the same neighbourhood, out in the disc.

    + * + *

    The offset lies IN the galaxy's plane, so the origin is disc material and not halo.

    + */ + public static final double HOME_GALAXY_ORIGIN_FRACTION = 0.55d; + + /** + * How far from the DECLARATION ORIGIN authored content is guaranteed to stay inside its galaxy, + * in light years. Derived, not chosen: it is what is left of the smallest galaxy that may hold + * authored content once the origin has been moved off its centre. + * + *

    Beyond it a position is valid on some seeds and intergalactic on others, which is a thing an + * author must be TOLD rather than left to discover — hence a loud error and never a clamp.

    + */ + public static final double GUARANTEED_AUTHORED_REACH_LY = + (1d - HOME_GALAXY_ORIGIN_FRACTION) * MIN_AUTHORED_GALAXY_RADIUS_LY; + + /** + * The smallest lattice cell a system can be more than a lone star in, in cells. + * + *

    Derived from the rest: a cell must leave room for a body at one orbit unit after the seat + * margin and the neighbourhood margin are taken out. It bounds how finely a star cluster may + * refine the lattice — a cluster cannot conjure room that its coarse cell never had, and a + * spacing too tight to be refined is a degenerate galaxy rather than an error.

    + */ + public static final long MIN_LATTICE_EDGE_CELLS = 9L; + + private UniverseScale() { + } + + /** How many cells a length in light years spans. Rounded up: a reach must not come out short. */ + public static long cellsForLightYears(double lightYears) { + double blocks = Math.max(0d, lightYears) * AstronomicalBodyHelper.BLOCKS_PER_LIGHT_YEAR; + return (long) Math.ceil(blocks / (double) GalacticCoord.CELL); + } + + /** A SIGNED length in light years as a whole number of cells, rounded to the nearest. */ + public static long cellsAt(double lightYears) { + return Math.round(lightYears * AstronomicalBodyHelper.BLOCKS_PER_LIGHT_YEAR + / (double) GalacticCoord.CELL); + } + + /** The length in light years that {@code cells} cells span. */ + public static double lightYearsForCells(double cells) { + return cells * (double) GalacticCoord.CELL + / (double) AstronomicalBodyHelper.BLOCKS_PER_LIGHT_YEAR; + } + + /** + * A speed quoted in km/s as light years per TICK — the form an angular rate is evaluated in. + * + *

    Galactic velocities are stated the way astronomy states them and converted once, here, + * rather than being pre-divided into a per-tick literal that no longer says what it measures.

    + */ + public static double lightYearsPerTick(double kilometresPerSecond) { + double metresPerYear = kilometresPerSecond * 1_000d * AstronomicalBodyHelper.SECONDS_PER_YEAR; + return metresPerYear / AstronomicalBodyHelper.METRES_PER_LIGHT_YEAR + / AstronomicalBodyHelper.TICKS_PER_YEAR; + } + + /** 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/universe/UniverseSchema.java b/src/main/java/zmaster587/advancedRocketry/universe/UniverseSchema.java new file mode 100644 index 000000000..cdc6a7c42 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/UniverseSchema.java @@ -0,0 +1,99 @@ +package zmaster587.advancedRocketry.universe; + +/** + * One released version of the WORLD MODEL — the thing a save is generated under and must keep being + * read under for the life of that save. + * + *

    What is inside the version number. The schema is not the generator alone; it is + * {@link IGalaxyGenerator} + {@link PlanetDerivation} + {@link UniverseScale} + {@link Cosmology} + * together. Everything a telescope PROMISES versions as one unit: an address is worth nothing if the + * derivation that gives that address a radius, a temperature and an orbit has moved underneath it, and + * a light year is worth nothing if the metric that converts it to cells has. + * + *

    All four are versioned the same way: by implementation. A version hands out a generator, + * and the generator carries the other two — {@link IGalaxyGenerator#derivation()} and + * {@link IGalaxyGenerator#laws()} — so selecting a version selects all of it. Nothing about a released + * world model reads a global, which is what lets one build hold a new model for new worlds and the old + * one for the worlds already made under it. That is the whole purpose: the mod moves on, and a world + * does not have to. + * + *

    What stays global, and why that is not a hole. The lattice DEFAULTS + * ({@code DEFAULT_SPACING_CELLS} and friends) decide only what a NEW world is given — an existing one + * carries its own numbers in its {@code GalaxyGenConfig}. The drive-band constants price a machine + * rather than measure space, and a rebalanced drive is a mod feature, which is exactly what an old + * world is supposed to keep receiving. + * + *

    The stamp that remains is a tripwire, not a barrier. + * {@code UniverseRegistry.lawsFingerprintOf} measures a version's laws — fixed inputs through every + * conversion — and the save records what its own version measured when the world was made. A mismatch + * therefore no longer means "the mod moved on"; it means a RELEASED version was edited in place, which + * is a developer error nobody downstream can accept away. + * + *

    All of it is backed by one mechanical check: the golden corpus renders what the whole chain + * produces — placement, derivation, metric and expansion — and compares it byte for byte, so a change + * in any of the four turns a test red and forces the version decision rather than shipping as a + * surprise. + * + *

    How a new version is written. As a DECORATOR over the one before it, delegating everything + * it does not deliberately change: + * + *

    + * final class UniverseSchemaV2 implements UniverseSchema {
    + *     private final UniverseSchema previous = new UniverseSchemaV0();
    + *     public int version() { return 2; }
    + *     public IGalaxyGenerator generator(GalaxyGenConfig config) { ...the one thing that changed... }
    + * }
    + * 
    + * + *

    Delegation rather than a fresh implementation is what makes the invariants inherited instead of + * re-typed: a v2 that only re-prices rogues has one method of its own, and every other guarantee is + * still v1's code rather than a copy of it that will drift. + * + *

    Implementations are immutable and hold no world state. A schema may be instantiated many + * times, and two instances of the same version must be indistinguishable. + */ +public interface UniverseSchema { + + /** + * The released version number. Stamped into the save and used to find this schema again when that + * save is opened by a later build. Never reused, never renumbered — it is an identifier, like an + * NBT key. + */ + int version(); + + /** + * The human name of this version — {@code MAJOR.MINOR}, and the MAJOR is a promise. + * + *

    A leading zero means ALPHA: the model may be replaced outright rather than extended, and + * nothing about it is guaranteed to survive to the next release. That is not a disclaimer, it is + * the whole meaning of the digit, and players are told so on the world where it applies. + * + *

    It is a separate thing from {@link #version()} because the two answer different questions. The + * number is the world's IDENTITY: it is stamped into saves, keys the registry, and may never be + * reused or reordered. The label is a STATEMENT ABOUT MATURITY, and several successive alphas can + * be shipped — {@code "0.1"}, {@code "0.2"} — each with its own identity, none of them stable. + */ + String label(); + + /** + * Whether this version is a stable release. False for anything whose {@link #label()} begins with + * {@code 0.} — an alpha, which a player is warned about and which may be replaced rather than + * carried forward. + */ + default boolean isStable() { + return !label().startsWith("0."); + } + + /** + * The generator this schema produces for {@code config}, or the empty generator when the pack + * declares no {@code } (an authored-anchors-only universe, which is a legitimate world + * rather than a missing configuration). + */ + IGalaxyGenerator generator(GalaxyGenConfig config); + + /** + * The metric and expansion this version measures with. One source: the generator this schema builds + * is handed this very instance, so the two can never describe different universes. + */ + IUniverseLaws laws(); +} diff --git a/src/main/java/zmaster587/advancedRocketry/universe/UniverseSchemaMismatchException.java b/src/main/java/zmaster587/advancedRocketry/universe/UniverseSchemaMismatchException.java new file mode 100644 index 000000000..4de71a4f3 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/UniverseSchemaMismatchException.java @@ -0,0 +1,22 @@ +package zmaster587.advancedRocketry.universe; + +/** + * Thrown when a save cannot be opened under the world model this build would give it — a schema version + * this jar does not carry, or a {@code } configuration that has been edited since the world + * was made. + * + *

    Why this is fatal rather than a warning. The universe is derived, not stored: a save keeps + * what has been touched and re-derives everything else from {@code (seed, cell)}. Continuing under a + * different model does not corrupt the file — it quietly answers a DIFFERENT universe, and the player + * finds out by flying to an address he wrote down and finding nothing there. A refusal to load is + * recoverable from outside the game (restore the configuration, or install the build that carries the + * version); a world silently regenerated around the player's notes is not. + */ +public class UniverseSchemaMismatchException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public UniverseSchemaMismatchException(String message) { + super(message); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/universe/UniverseSchemaV0.java b/src/main/java/zmaster587/advancedRocketry/universe/UniverseSchemaV0.java new file mode 100644 index 000000000..d384f4aaa --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/UniverseSchemaV0.java @@ -0,0 +1,44 @@ +package zmaster587.advancedRocketry.universe; + +/** + * Schema version 0 ("0.1", the ALPHA) — the clustered galaxy field as first released: nested galaxy and star lattices, + * cluster sub-lattices, seated nebulae, the unbound population out in the void, and the body + * derivation those systems are filled with. + * + *

    Deliberately thin. A schema version exists to be NAMED and found again, not to hold logic; the + * behaviour lives in the classes it selects, and this class is the record that this particular set of + * them was once shipped. + * + *

    The zero is a promise about maturity, not a placeholder. This model may be replaced outright + * in a later release rather than extended, so a world generated under it is not guaranteed a future — + * and the player is told exactly that when he loads one. + */ +public final class UniverseSchemaV0 implements UniverseSchema { + + public static final int VERSION = 0; + + /** The alpha, and its leading zero says so. */ + public static final String LABEL = "0.1"; + + @Override + public int version() { + return VERSION; + } + + @Override + public String label() { + return LABEL; + } + + @Override + public IUniverseLaws laws() { + return UniverseLawsV0.INSTANCE; + } + + @Override + public IGalaxyGenerator generator(GalaxyGenConfig config) { + return (config == null) + ? new EmptyGalaxyGenerator() + : new ClusteredGalaxyGenerator(config, BodyDerivationV0.INSTANCE, laws()); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/universe/UniverseSchemas.java b/src/main/java/zmaster587/advancedRocketry/universe/UniverseSchemas.java new file mode 100644 index 000000000..d8fa5b3a3 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/universe/UniverseSchemas.java @@ -0,0 +1,75 @@ +package zmaster587.advancedRocketry.universe; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Supplier; + +/** + * Every released world model this build can still speak, keyed by version. + * + *

    The jar carries all of them, and that is the point. A save stamped with version n is + * opened under version n forever, whatever the mod has moved on to — so the mod can be updated + * freely (mechanics, blocks, balance, whole new machines) without the sky changing under a world that + * has already been explored. Only the player's explicit upgrade moves a world onto a newer model. + * + *

    A RELEASED version is added here and never removed: dropping one makes every save carrying its + * stamp unopenable. A version that has not shipped is a different matter — it may be edited in place + * and even replaced outright, because no world outside the branch was ever generated under it and it + * therefore owes nobody compatibility. "Shipped" means merged to the release branch, not landed + * on a feature branch; the freeze begins at the merge, and that is the moment a version stops being + * editable and starts being history. + * + *

    Registering a supplier rather than an instance keeps construction lazy and makes it explicit that + * a schema is cheap to build and holds no world state. + */ +public final class UniverseSchemas { + + private static final Map> REGISTRY = + new LinkedHashMap>(); + + static { + register(UniverseSchemaV0.VERSION, new Supplier() { + @Override + public UniverseSchema get() { + return new UniverseSchemaV0(); + } + }); + } + + /** The newest released version — what a fresh world is stamped with. */ + public static final int CURRENT = UniverseSchemaV0.VERSION; + + private UniverseSchemas() { + } + + private static void register(int version, Supplier supplier) { + REGISTRY.put(version, supplier); + } + + /** The schema for {@code version}, or empty when this build does not carry it. */ + public static Optional of(int version) { + Supplier supplier = REGISTRY.get(version); + return (supplier == null) ? Optional.empty() : Optional.of(supplier.get()); + } + + /** The newest released schema — what a world with no stamp of its own is generated under. */ + public static UniverseSchema current() { + Optional schema = of(CURRENT); + if (!schema.isPresent()) { + throw new IllegalStateException("the current universe schema " + CURRENT + + " is not registered"); + } + return schema.get(); + } + + /** Every version this build carries, ascending — for diagnostics and for the refusal message. */ + public static List released() { + List versions = new ArrayList<>(REGISTRY.keySet()); + Collections.sort(versions); + return Collections.unmodifiableList(versions); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/util/AstronomicalBodyHelper.java b/src/main/java/zmaster587/advancedRocketry/util/AstronomicalBodyHelper.java index 3a8e81f44..cc34a8e2e 100644 --- a/src/main/java/zmaster587/advancedRocketry/util/AstronomicalBodyHelper.java +++ b/src/main/java/zmaster587/advancedRocketry/util/AstronomicalBodyHelper.java @@ -4,6 +4,150 @@ 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 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; + /** + * Seconds in one Julian year. What carries a speed stated per SECOND — the unit orbital and + * galactic velocities are quoted in — into the per-year frame the calendar below counts in. + */ + public static final double SECONDS_PER_YEAR = 31_557_600d; + + /** 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; + + /** + * The smallest orbit, in {@code orbitalDistance} units, that can carry an ADDRESS of its own — + * one cell's worth. A body closer in than this shares its star's cell, and a cell is a + * destination: two bodies in one would be one indistinguishable address that neither a jump nor + * an arrival could resolve. + * + *

    So it is the addressing granularity of the whole universe layer, and it is derived from + * the cell edge, never picked. It used to be picked — the companion band's floor was a + * literal {@code 1} with a comment saying it was one cell's worth, which was true at a 4M cell + * (0.67 units) and stopped being true the moment the cell grew. A number whose javadoc states a + * derivation should BE that derivation.

    + * + *

    What it costs, said plainly: a bigger cell buys reach and spends inner resolution. At a 32M + * cell this is 6 units = 0.06 AU, so a contact binary or a body orbiting closer than that cannot + * be a separate destination — it is not generated rather than being generated unreachable.

    + */ + public static final int MIN_ADDRESSABLE_ORBIT_UNITS = (int) Math.max(1L, + (zmaster587.advancedRocketry.space.GalacticCoord.CELL + BLOCKS_PER_ORBIT_UNIT - 1L) + / BLOCKS_PER_ORBIT_UNIT); + + /** + * Earth radii in one SOLAR radius (696 340 km / 6 378 km). A star states its size in solar radii + * and every other body in Earth radii, so anything that draws them on one scale needs this. + */ + public static final double EARTH_RADII_PER_SOLAR_RADIUS = 109.17d; + + /** + * A star's radius in EARTH radii — the unit the render feed sizes every body in. + * + *

    {@code StellarBody.getSize()} is in solar radii, so a star fed straight into a body-sized + * channel would be drawn a hundred times too small. A star with no stated size falls back to one + * solar radius rather than to zero: a sun that vanishes is worse than a sun of the wrong size.

    + */ + public static double starRadiusEarths(zmaster587.advancedRocketry.api.dimension.solar.StellarBody star) { + if (star == null) { + return EARTH_RADII_PER_SOLAR_RADIUS; + } + double solarRadii = star.getSize(); + if (Double.isNaN(solarRadii) || solarRadii <= 0d) { + solarRadii = 1d; + } + return solarRadii * EARTH_RADII_PER_SOLAR_RADIUS; + } + + /** 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 + * 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". + // 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 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; + /** 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; + /** + * Ticks in one year — the two above composed, so a rate stated per year has ONE conversion into + * the clock the game actually counts. A galactic rotation is quoted per year and evaluated per + * tick, and writing that product at the call site is how the two calendars drift apart. + */ + public static final int TICKS_PER_YEAR = DAYS_PER_YEAR * TICKS_PER_DAY; + /** * 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,43 +156,59 @@ 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; } /** * 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 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, 3) / starMassSolar, 0.5d); } /** * 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 - return 8d * Math.pow(Math.pow((orbitalDistance / 100d), 3) / planetaryMass, 0.5d); + //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); } /** * 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)); } /** @@ -59,10 +219,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 = 24000d * 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; } @@ -72,12 +232,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)); } @@ -87,10 +247,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 = 24000d * getMoonOrbitalPeriod(orbitalDistance, parentGravitationalMultiplier); + double periodTicks = (double) TICKS_PER_DAY + * getMoonOrbitalPeriod(orbitalDistance, parentMassEarths); if (!(periodTicks > 0d) || Double.isInfinite(periodTicks)) { return 0d; } @@ -100,19 +261,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) * 24000) / 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; } @@ -126,15 +287,30 @@ 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; - //Gives output in AU - float planetaryOrbitalRadius = orbitalDistance / 100f; - //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 * 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)))); } /** @@ -150,31 +326,32 @@ 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() / 100f; - float planetaryOrbitalRadius = orbitalDistance / 100f; - //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; - } - } - } - //There's no real easy way to get the light emitted by an accretion disc, so this substitutes - if (blackHole) - lightMultiplier *= 0.25; + float planetaryOrbitalRadius = orbitalDistance / (float) DISTANCE_UNITS_PER_AU; + // 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. + // + // 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 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 = - lightMultiplier * - ((Math.pow(star.getSize(), 2) * Math.pow(normalizedStarTemperature, 4)) / - Math.pow(planetaryOrbitalRadius, 2)); + 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 if (!Double.isFinite(brightness) || brightness < MIN_BRIGHTNESS) { @@ -183,6 +360,57 @@ 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. + * 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/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 c2815343b..e30f09f4f 100644 --- a/src/main/java/zmaster587/advancedRocketry/util/XMLPlanetLoader.java +++ b/src/main/java/zmaster587/advancedRocketry/util/XMLPlanetLoader.java @@ -26,8 +26,13 @@ import zmaster587.advancedRocketry.dimension.TerrainSource; import zmaster587.advancedRocketry.space.GalacticCoord; import zmaster587.advancedRocketry.universe.ClusteredGalaxyGenerator; +import zmaster587.advancedRocketry.universe.GalacticAnchor; import zmaster587.advancedRocketry.universe.GalaxyGenConfig; +import zmaster587.advancedRocketry.universe.GalaxyKey; 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,10 +66,41 @@ 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"; + private static final String ELEMENT_GALAXYTYPE = "galaxyType"; + private static final String ATTR_PROFILE = "profile"; + private static final String ATTR_MINRADIUS = "minRadius"; + private static final String ATTR_MAXRADIUS = "maxRadius"; + private static final String ATTR_THICKNESS = "thickness"; + private static final String ATTR_ARMS = "arms"; + private static final String ATTR_ROTATIONSPEED = "rotationSpeed"; + private static final String ATTR_COREFRACTION = "coreFraction"; + private static final String ATTR_MINSATELLITES = "minSatellites"; + private static final String ATTR_MAXSATELLITES = "maxSatellites"; + // 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"; - private static final String ATTR_VOIDFRACTION = "voidFraction"; + private static final String ATTR_GALAXYSPACING = "galaxySpacing"; + private static final String ATTR_GALAXYDENSITY = "galaxyDensity"; + private static final String ATTR_ROGUEABUNDANCE = "rogueAbundance"; + private static final String ATTR_ROGUEGIANTFRACTION = "rogueGiantFraction"; + private static final String ATTR_EJECTAFALLOFF = "ejectaFalloff"; private static final String ATTR_MINSIZE = "minSize"; private static final String ATTR_MAXSIZE = "maxSize"; private static final String ELEMENT_PLANET = "planet"; @@ -76,10 +112,12 @@ public class XMLPlanetLoader { // Explicit galactic address of an authored anchor system: "sectorX,sectorY,sectorZ" (cell indices). // Absent -> the system falls back to a deterministic cell (Sol -> origin). See UniverseRegistry. private static final String ATTR_GALACTIC_COORD = "galacticCoord"; + private static final String ATTR_GALAXY = "galaxy"; 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"; @@ -92,6 +130,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"; @@ -107,6 +149,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"; @@ -156,7 +199,16 @@ public XMLPlanetLoader() { * Resolve a system's authored galactic coordinate from the live universe registry, or {@code null} when * no server/registry is reachable (so a no-server unit-test export simply omits the attribute). */ - private static GalacticCoord anchorCoordForWrite(int starId) { + /** + * How this star's address is written back. + * + *

    In the language it was DECLARED in, when it was declared: a galaxy-local anchor writes its + * galaxy and its offset, and would otherwise be written as the absolute cell it resolved to and + * then read back on the next load as an offset from that same galaxy — shifted twice, and further + * every save. A star that was never declared writes the absolute cell it was given, which is what + * it has.

    + */ + private static GalacticAnchor anchorForWrite(int starId) { MinecraftServer server; try { server = FMLCommonHandler.instance().getMinecraftServerInstance(); @@ -172,7 +224,12 @@ private static GalacticCoord anchorCoordForWrite(int starId) { if (registry == null) { return null; } - return registry.coordForSystem(starId).orElse(null); + GalacticAnchor declared = registry.declaredAnchorFor(starId); + if (declared != null) { + return declared; + } + GalacticCoord placed = registry.coordForSystem(starId).orElse(null); + return placed == null ? null : GalacticAnchor.inHome(placed); } private static String attr(Node node, String name) { @@ -196,6 +253,19 @@ private static int attrInt(Node node, String name, int def) { } } + private static long attrLong(Node node, String name, long def) { + String v = attr(node, name); + if (v == null || v.trim().isEmpty()) { + return def; + } + try { + return Long.parseLong(v.trim()); + } catch (NumberFormatException e) { + AdvancedRocketry.logger.warn("Invalid " + name + " in : " + v); + return def; + } + } + private static double attrDouble(Node node, String name, double def) { String v = attr(node, name); if (v == null || v.trim().isEmpty()) { @@ -209,13 +279,32 @@ private static double attrDouble(Node node, String name, double def) { } } + /** + * The galaxy an authored anchor is declared against — {@code home} when unstated, which is what a + * pack that never thinks about galaxies gets and is always the right answer for it. + */ + private static GalaxyKey readGalaxyKey(Node node, String starName) { + String raw = attr(node, ATTR_GALAXY); + if (raw == null || raw.trim().isEmpty()) { + return GalaxyKey.HOME; + } + GalaxyKey key = GalaxyKey.parse(raw); + if (key == null) { + AdvancedRocketry.logger.warn("star '" + starName + "' names galaxy \"" + raw + + "\", which is neither \"" + GalaxyKey.HOME_NAME + "\" nor a \"gx,gy,gz\" lattice" + + " index. Placing it in the home galaxy."); + return GalaxyKey.HOME; + } + return key; + } + /** Parse a {@code } element (attrs + {@code } children) into a config. */ private GalaxyGenConfig readGalaxyGen(Node node) { GalaxyGenConfig defaults = GalaxyGenConfig.defaults(); double density = attrDouble(node, ATTR_DENSITY, defaults.density); int minSpacing = attrInt(node, ATTR_MINSPACING, defaults.minSpacing); - int clusterScale = attrInt(node, ATTR_CLUSTERSCALE, defaults.clusterScale); - double voidFraction = attrDouble(node, ATTR_VOIDFRACTION, defaults.voidFraction); + long galaxySpacing = attrLong(node, ATTR_GALAXYSPACING, defaults.galaxySpacing); + double galaxyDensity = attrDouble(node, ATTR_GALAXYDENSITY, defaults.galaxyDensity); List types = new ArrayList<>(); NodeList children = node.getChildNodes(); @@ -229,16 +318,282 @@ private GalaxyGenConfig readGalaxyGen(Node node) { attrInt(child, ATTR_WEIGHT, 1))); } } - // An empty list falls back to the default archetypes (handled by the config ctor). - return new GalaxyGenConfig(density, minSpacing, clusterScale, voidFraction, types); + List galaxyTypes = new ArrayList<>(); + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + if (ELEMENT_GALAXYTYPE.equalsIgnoreCase(child.getNodeName())) { + galaxyTypes.add(readGalaxyType(child)); + } + } + // The UNBOUND population. Its defaults are measured quantities rather than balance picks, so + // an element that says nothing about rogues gets the sky as it is observed to be. + GalaxyGenConfig.RogueTuning rogueDefaults = defaults.rogue; + GalaxyGenConfig.RogueTuning rogue = new GalaxyGenConfig.RogueTuning( + attrDouble(node, ATTR_ROGUEABUNDANCE, rogueDefaults.abundance), + attrDouble(node, ATTR_ROGUEGIANTFRACTION, rogueDefaults.giantFraction), + attrDouble(node, ATTR_EJECTAFALLOFF, rogueDefaults.ejectaFalloff), + rogueDefaults.types); + // Empty / lists fall back to the stock archetypes (config ctor). + return new GalaxyGenConfig(minSpacing, density, galaxySpacing, galaxyDensity, types, + galaxyTypes).withRogueTuning(rogue); + } + + /** + * Parse one {@code } element into a galaxy archetype. + * + *
    {@code
    +     * 
    +     * }
    + * + *

    Every SHAPE attribute defaults to the stock spiral's value, so a pack that wants to change + * only how flat a disc is writes only {@code thickness}. Those defaults are READ OFF + * {@link GalaxyGenConfig#stockSpiral()} rather than written here: they were literals once, and the + * copy went stale the moment the galaxy scale moved. {@code weight} is the deliberate exception — + * it defaults to {@code 1}, the rarest, because a type a pack did not weight should not silently + * inherit a spiral's abundance.

    + */ + private static GalaxyGenConfig.GalaxyType readGalaxyType(Node node) { + String profileName = attr(node, ATTR_PROFILE); + GalaxyGenConfig.GalaxyProfile profile = GalaxyGenConfig.GalaxyProfile.DISC; + if (profileName != null && !profileName.trim().isEmpty()) { + try { + profile = GalaxyGenConfig.GalaxyProfile.valueOf(profileName.trim().toUpperCase()); + } catch (IllegalArgumentException bad) { + AdvancedRocketry.logger.warn("Unknown galaxy profile \"" + profileName + + "\" in ; using DISC"); + } + } + String name = attr(node, ATTR_NAME); + GalaxyGenConfig.GalaxyType stock = GalaxyGenConfig.stockSpiral(); + return new GalaxyGenConfig.GalaxyType( + (name == null || name.trim().isEmpty()) ? "Galaxy" : name.trim(), + profile, + attrDouble(node, ATTR_MINRADIUS, stock.minRadiusLy), + attrDouble(node, ATTR_MAXRADIUS, stock.maxRadiusLy), + attrDouble(node, ATTR_THICKNESS, stock.scaleHeightRatio), + attrInt(node, ATTR_ARMS, stock.armCount), + attrDouble(node, ATTR_ROTATIONSPEED, stock.rotationSpeedKmS), + attrDouble(node, ATTR_COREFRACTION, stock.coreRadiusFraction), + attrInt(node, ATTR_MINSATELLITES, stock.minSatellites), + attrInt(node, ATTR_MAXSATELLITES, stock.maxSatellites), + attrInt(node, ATTR_WEIGHT, 1)); + } + + /** + * 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)); e.setAttribute(ATTR_MINSPACING, Integer.toString(cfg.minSpacing)); - e.setAttribute(ATTR_CLUSTERSCALE, Integer.toString(cfg.clusterScale)); - e.setAttribute(ATTR_VOIDFRACTION, Double.toString(cfg.voidFraction)); + e.setAttribute(ATTR_GALAXYSPACING, Long.toString(cfg.galaxySpacing)); + e.setAttribute(ATTR_GALAXYDENSITY, Double.toString(cfg.galaxyDensity)); + // Written back for the same reason the tables are: this file is REWRITTEN on every world save, + // so anything the reader did not turn into model state is silently lost on the first one. + e.setAttribute(ATTR_ROGUEABUNDANCE, Double.toString(cfg.rogue.abundance)); + e.setAttribute(ATTR_ROGUEGIANTFRACTION, Double.toString(cfg.rogue.giantFraction)); + e.setAttribute(ATTR_EJECTAFALLOFF, Double.toString(cfg.rogue.ejectaFalloff)); for (GalaxyGenConfig.StarType t : cfg.starTypes) { Element st = doc.createElement(ELEMENT_STARTYPE); st.setAttribute(ATTR_TEMP, Integer.toString(t.temperature)); @@ -247,9 +602,62 @@ private static Element writeGalaxyGen(Document doc, GalaxyGenConfig cfg) { st.setAttribute(ATTR_WEIGHT, Integer.toString(t.weight)); e.appendChild(st); } + // The galaxy table is written back for the same reason the star table is: this file is + // REWRITTEN on every world save, so anything the reader did not turn into model state is lost. + // A pack that flattened its discs would silently get the stock ones back on the first save. + for (GalaxyGenConfig.GalaxyType t : cfg.galaxyTypes) { + Element gt = doc.createElement(ELEMENT_GALAXYTYPE); + gt.setAttribute(ATTR_NAME, t.name); + gt.setAttribute(ATTR_PROFILE, t.profile.name()); + gt.setAttribute(ATTR_MINRADIUS, Double.toString(t.minRadiusLy)); + gt.setAttribute(ATTR_MAXRADIUS, Double.toString(t.maxRadiusLy)); + gt.setAttribute(ATTR_THICKNESS, Double.toString(t.scaleHeightRatio)); + gt.setAttribute(ATTR_ARMS, Integer.toString(t.armCount)); + gt.setAttribute(ATTR_ROTATIONSPEED, Double.toString(t.rotationSpeedKmS)); + gt.setAttribute(ATTR_COREFRACTION, Double.toString(t.coreRadiusFraction)); + gt.setAttribute(ATTR_MINSATELLITES, Integer.toString(t.minSatellites)); + gt.setAttribute(ATTR_MAXSATELLITES, Integer.toString(t.maxSatellites)); + gt.setAttribute(ATTR_WEIGHT, Integer.toString(t.weight)); + e.appendChild(gt); + } return e; } + /** + * The two things a pack author has to know BEFORE the first save, written into the file itself. + * + *

    Both are discoverable only from source otherwise, and by the time either is discovered the + * damage is done: the author has already placed a system in intergalactic space, or has already + * rerolled a universe that had a save attached to it. This file is rewritten on every world save, + * so the notice is emitted by the WRITER rather than shipped in a template that the first save + * would replace.

    + */ + private static final String AUTHORING_NOTICE = "\n" + + " READ BEFORE EDITING\n" + + "\n" + + " 1. A star's galacticCoord is GALAXY-LOCAL, not absolute. It is an offset in cells\n" + + " from the centre of the galaxy named by the star's `galaxy` attribute, which\n" + + " defaults to \"home\". The home galaxy is centred on the origin and always exists,\n" + + " whatever the world seed, and it is always at least 800 light years in radius, so\n" + + " anything you place inside that radius is valid on every seed. Beyond it your\n" + + " system may land in intergalactic space on some seeds; you get a loud error in the\n" + + " log if it does. Naming another galaxy (`galaxy=\"4,-1,2\"`) forces that lattice\n" + + " cell to hold one.\n" + + "\n" + + " Why: a galaxy fills about three thousandths of a percent of its own lattice cell,\n" + + " so a hand-picked absolute coordinate is in the void with probability 99.997%.\n" + + "\n" + + " 2. CHANGING ANY PARAMETER MID-SAVE IS UNDEFINED BEHAVIOUR. Nothing about\n" + + " a procedural system is stored: every star, planet and generated name is derived\n" + + " from (seed, coordinate) on every query. Change density, minSpacing, galaxySpacing,\n" + + " galaxyDensity or the archetype tables and you get a DIFFERENT UNIVERSE, in which\n" + + " every coordinate a player wrote down, every memory crystal and every route points\n" + + " at nothing. There is no migration and there cannot be one, because there is no old\n" + + " universe on disk to migrate. If you change these, start a new world.\n" + + "\n" + + " Comments you add to this file do not survive a world save; this one is regenerated.\n" + + " Full reference: docs/README_PLANETDEFS.md\n"; + public static String writeXML(IGalaxy galaxy) { Document doc; @@ -262,6 +670,7 @@ public static String writeXML(IGalaxy galaxy) { doc = docBuilder.newDocument(); Element galaxyElement = doc.createElement(ELEMENT_GALAXY); doc.appendChild(galaxyElement); + galaxyElement.appendChild(doc.createComment(AUTHORING_NOTICE)); Collection stars = galaxy.getStars(); @@ -273,9 +682,13 @@ public static String writeXML(IGalaxy galaxy) { nodeStar.setAttribute(ATTR_TEMP, Integer.toString(star.getTemperature())); nodeStar.setAttribute(ATTR_X, Integer.toString(star.getPosX())); nodeStar.setAttribute(ATTR_Y, Integer.toString(star.getPosZ())); - GalacticCoord starCoord = anchorCoordForWrite(star.getId()); - if (starCoord != null) { - nodeStar.setAttribute(ATTR_GALACTIC_COORD, UniverseRegistry.formatAnchor(starCoord)); + GalacticAnchor starAnchor = anchorForWrite(star.getId()); + if (starAnchor != null) { + nodeStar.setAttribute(ATTR_GALACTIC_COORD, + UniverseRegistry.formatAnchor(starAnchor.local())); + if (!starAnchor.galaxy().isHome()) { + nodeStar.setAttribute(ATTR_GALAXY, starAnchor.galaxy().toString()); + } } nodeStar.setAttribute(ATTR_SIZE, Float.toString(star.getSize())); nodeStar.setAttribute(ATTR_NUMPLANETS, "0"); @@ -288,7 +701,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); } @@ -302,8 +717,16 @@ public static String writeXML(IGalaxy galaxy) { // Emit the active procedural generator's config so a re-read (resetFromXml) round-trips it. IGalaxyGenerator activeGenerator = UniverseRegistry.getGenerator(); - if (activeGenerator instanceof ClusteredGalaxyGenerator) { - galaxyElement.appendChild(writeGalaxyGen(doc, ((ClusteredGalaxyGenerator) activeGenerator).config())); + java.util.Optional tuning = + activeGenerator.tuning(); + if (tuning.isPresent()) { + galaxyElement.appendChild(writeGalaxyGen(doc, tuning.get())); + // 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(); @@ -394,6 +817,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 @@ -402,7 +838,7 @@ private static Node writePlanet(Document doc, DimensionProperties properties) { nodePlanet.appendChild(createTextNode(doc, ELEMENT_BASEORBITTHETA, Math.toDegrees(properties.baseOrbitTheta))); nodePlanet.appendChild(createTextNode(doc, ELEMENT_PHI, properties.orbitalPhi)); nodePlanet.appendChild(createTextNode(doc, ELEMENT_RETROGRADE, properties.isRetrograde)); - nodePlanet.appendChild(createTextNode(doc, AVG_TEMPERATURE, properties.averageTemperature)); + nodePlanet.appendChild(createTextNode(doc, AVG_TEMPERATURE, properties.getAverageTemp())); nodePlanet.appendChild(createTextNode(doc, ELEMENT_PERIOD, properties.rotationalPeriod)); nodePlanet.appendChild(createTextNode(doc, ELEMENT_ATMDENSITY, properties.getAtmosphereDensity())); // Custom weather properties @@ -448,6 +884,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)); @@ -776,9 +1214,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 { @@ -836,46 +1301,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(","); @@ -914,7 +1340,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 @@ -1074,13 +1500,15 @@ 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)) 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(","); @@ -1169,8 +1597,13 @@ else if (planetPropertyNode.getNodeName().equalsIgnoreCase(ELEMENT_RINGCOLOR)) { //Star may not be registered at this time, use ID version instead properties.setStar(star.getId()); - //Set temperature - properties.averageTemperature = AstronomicalBodyHelper.getAverageTemperature(star, properties.getSolarOrbitalDistance(), properties.getAtmosphereDensity()); + // Set temperature. From the LOCAL star object, not through properties.getStar(): the star is + // not in the catalogue yet (see the line above), so the lookup would come back null here and + // the world would be born at the temperature of deep space. The albedo is the world's own, so + // an authored planet and a derived one are warmed by the same law (ledger #289). + properties.setAverageTemp(AstronomicalBodyHelper.getAverageTemperature(star, + properties.getSolarOrbitalDistance(), properties.getAtmosphereDensity(), + properties.getAlbedo())); //If no biomes are specified add some! if (properties.getBiomes().isEmpty()) @@ -1284,10 +1717,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.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 { - star.setStarSeparation(Float.parseFloat(nameNode.getNodeValue())); + // 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()); } @@ -1316,6 +1766,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; @@ -1324,12 +1779,22 @@ public DimensionPropertyCoupling readAllPlanets() { StellarBody star = readStar(masterNode); coupling.stars.add(star); - // Explicit galactic address for this authored anchor (optional). Staged into the universe - // registry after the catalogue is built; absent -> a deterministic fallback cell downstream. + // Explicit galactic address for this authored anchor (optional). It is GALAXY-LOCAL: an + // offset from the centre of the galaxy named by `galaxy` (default `home`), not an absolute + // cell. A galaxy fills about three thousandths of a percent of its own lattice cell, so an + // absolute declaration would land in intergalactic space on virtually every seed. + // + // Resolved into an absolute cell once, at population, when the world seed is known — the + // galaxy's centre is a hash draw and cannot be known here. if (masterNode.hasAttributes()) { Node coordNode = masterNode.getAttributes().getNamedItem(ATTR_GALACTIC_COORD); if (coordNode != null && !coordNode.getNodeValue().isEmpty()) { - coupling.anchorCoords.put(star.getId(), UniverseRegistry.parseAnchor(coordNode.getNodeValue())); + GalaxyKey key = readGalaxyKey(masterNode, star.getName()); + coupling.anchorCoords.put(star.getId(), GalacticAnchor.of(key, + UniverseRegistry.parseAnchor(coordNode.getNodeValue()))); + if (!key.isHome() && !coupling.declaredGalaxies.contains(key)) { + coupling.declaredGalaxies.add(key); + } } } @@ -1358,6 +1823,13 @@ public DimensionPropertyCoupling readAllPlanets() { masterNode = masterNode.getNextSibling(); } + // Every galaxy an anchor named is RESERVED. The keys are only known once the catalogue has + // been walked, which is after was read — so they are folded in here rather than + // making the document's element ORDER load-bearing. + if (coupling.galaxyGenConfig != null && !coupling.declaredGalaxies.isEmpty()) { + coupling.galaxyGenConfig = + coupling.galaxyGenConfig.withReservedGalaxies(coupling.declaredGalaxies); + } return coupling; } @@ -1389,9 +1861,15 @@ public static class DimensionPropertyCoupling { public List dims = new LinkedList<>(); // Authored galactic addresses, keyed by star id (parse order). Only anchors that declared an // explicit appear here; the rest get a deterministic fallback at population. - public Map anchorCoords = new HashMap<>(); + // GALAXY-LOCAL: resolved into absolute cells at population, once the world seed is known. + public Map anchorCoords = new HashMap<>(); + // Every non-home galaxy an anchor named. Each one is RESERVED — its cell holds a galaxy + // whatever the hash says — because authored content must exist under every seed. + public List declaredGalaxies = new ArrayList<>(); // 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/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..e917e6528 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); } @@ -532,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/main/java/zmaster587/advancedRocketry/world/weather/ARDimensionWorldInfo.java b/src/main/java/zmaster587/advancedRocketry/world/weather/ARDimensionWorldInfo.java index 7b0e07aa2..98172a35a 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); @@ -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/assets/advancedrocketry/lang/en_US.lang b/src/main/resources/assets/advancedrocketry/lang/en_US.lang index 6d8c298f2..a7972f2b3 100644 --- a/src/main/resources/assets/advancedrocketry/lang/en_US.lang +++ b/src/main/resources/assets/advancedrocketry/lang/en_US.lang @@ -317,6 +317,27 @@ commands.advancedrocketry.dev.dumpbiomes.usage=dumpBiomes - Dumps biome info to commands.advancedrocketry.dev.dumpbiomes.success=The file 'BiomeDump.txt' has been written to the instance directory commands.advancedrocketry.dev.runtests.usage=runTests - Runs rocket tests for debug only! +msg.advancedrocketry.universe.alpha=This world uses universe generator %s - an ALPHA. The leading zero means the world model may be replaced in a later release rather than extended, so this world is not guaranteed a way forward. Only what you have already seen is frozen. +commands.advancedrocketry.universe.usage=/stellurgy universe help - the world model this save was generated under +commands.advancedrocketry.universe.unavailable=The universe registry is not available on this world +commands.advancedrocketry.universe.status.usage=status - report the world model, the pack's configuration, and how much sky is frozen +commands.advancedrocketry.universe.status.schema=World model: schema %s (this build ships %s) +commands.advancedrocketry.universe.status.alpha=Generator %s is an ALPHA: a leading zero means this world model may be REPLACED in a later release, not extended. +commands.advancedrocketry.universe.status.stable=Generator %s is a stable release. +commands.advancedrocketry.universe.status.config=Configuration: world %s, pack %s +commands.advancedrocketry.universe.status.agrees=The pack states the same universe this world was generated under. +commands.advancedrocketry.universe.status.differs=The pack states a DIFFERENT universe. Restore the previous , or run /stellurgy universe upgrade to accept it. +commands.advancedrocketry.universe.status.frozen=Frozen by being seen: %s systems +commands.advancedrocketry.universe.status.released=Models this build can still read: %s +commands.advancedrocketry.universe.status.armed=An upgrade is armed: the next start will accept one change to . +commands.advancedrocketry.universe.upgrade.usage=upgrade [confirm] - freeze everything already seen, then accept the pack's current universe +commands.advancedrocketry.universe.upgrade.preview=This would move the world from configuration %s to %s. %s systems are already frozen. +commands.advancedrocketry.universe.upgrade.reach=Memory crystals will be read from the %s player(s) online. Crystals in chests, in unloaded chunks, or carried by players who are offline CANNOT be read - bring them in first. +commands.advancedrocketry.universe.upgrade.confirm=Run /stellurgy universe upgrade confirm to go ahead. This cannot be undone. +commands.advancedrocketry.universe.upgrade.done=Upgrade complete: %s crystals read, %s addresses, %s newly frozen. World model %s -> %s, configuration %s. +commands.advancedrocketry.universe.upgrade.armed=This world will also accept ONE change to at its next start. Stop the server, put the new configuration in place, and start again. +commands.advancedrocketry.universe.upgrade.seam=Charted space keeps exactly what it held; everything beyond it is re-derived under the new model. + commands.advancedrocketry.filldata.usage=/ar fillData OR /ar fillData chip (alias: fd) commands.advancedrocketry.filldata.chip.notheld=Hold an asteroid chip in your main hand to use /ar fillData chip commands.advancedrocketry.filldata.chip.success=Filled asteroid chip with %s data in composition, mass, and distance @@ -337,9 +358,6 @@ commands.advancedrocketry.planet.list.entry=DIM%d: %s commands.advancedrocketry.planet.delete.usage=planet delete commands.advancedrocketry.planet.delete.success=Dim %d deleted! commands.advancedrocketry.planet.delete.invalid=World still has players: -commands.advancedrocketry.planet.generate.usage=planet generate [moon] [gas] [atmosphere base] [distance base] [gravity base] -commands.advancedrocketry.planet.generate.invalid=Dimension: %s failed to generate! -commands.advancedrocketry.planet.generate.success=Dimension: %s generated! commands.advancedrocketry.planet.set.usage=planet set [dimId] commands.advancedrocketry.planet.set.success=Successfully set dimension %d's property %s to %s commands.advancedrocketry.planet.set.invalid=Property lookup failed, please check logs @@ -401,18 +419,23 @@ msg.observetory.scan.button=Scan! msg.observetory.scan.crystal=Memory crystal msg.observetory.scan.direction=Aimed at: msg.observetory.scan.direction.tooltip=Turn the instrument to the next patch of sky -msg.observetory.scan.distance=Distance (sectors): -msg.observetory.scan.distance.tooltip=How far out to look. Farther is a longer observation, and the instrument has a horizon. +msg.observetory.scan.distance=Distance (stars): +msg.observetory.scan.distance.tooltip=How far out to look, counted in neighbouring stars. Farther is a longer observation, and the instrument has a horizon. +msg.observetory.scan.lightyears= (%.1f ly) msg.observetory.scan.region=Observe -msg.observetory.scan.region.tooltip=Look at the chosen region and write every system it resolves onto the crystal -msg.observetory.scan.looking=Surveyed cells: +msg.observetory.scan.region.tooltip=Point the instrument along the chosen direction and write every system bright enough to register onto the crystal +msg.observetory.scan.looking=Looks taken: msg.observetory.scan.found=Addresses written: +msg.observetory.scan.obscured=Dust in the way - coordinates only: msg.observetory.scan.idle=Idle msg.observetory.scan.abort=Stop msg.observetory.scan.abort.tooltip=Stop the survey. Everything already resolved is already on the crystal. msg.observetory.scan.mode.active=Deep msg.observetory.scan.mode.passive=Local msg.observetory.scan.mode.tooltip=Local watches the neighbourhood and has its data ready; deep looks at a chosen distant region. One at a time - an instrument staring into deep space cannot see what is close. +msg.observetory.scan.detail.full=Full +msg.observetory.scan.detail.coords=Positions +msg.observetory.scan.detail.tooltip=Full names every body of every system the survey registers; Positions writes down only where they are. A deep pointing on Full fills a crystal many times faster - and a position is still somewhere you can fly to and look. msg.observetory.scan.keepcrystal=Keep a crystal in the machine: a broken observatory loses what it holds. msg.observetory.text.asteroids=Asteroids msg.observetory.text.composition=Composition @@ -916,8 +939,12 @@ msg.loginrestore.shipunknown=§cYour ship could not be found - the server has no msg.shipdescent.refused=§cThe descent could not start - the destination is not ready yet. Wait a moment and try again. msg.shipdescent.failed=§cThe descent failed - the ship could not cross onto the planet. msg.shipdescent.arrived=§aDescent complete - the ship is in the sky over the planet. Take her down. +msg.shipseam.refused=§cThe ship has left this neighbourhood, but space is saturated - no room to carry her across. Turn back. +msg.shipseam.failed=§cThe crossing failed - the ship could not be carried into the next neighbourhood. +msg.shipseam.arrived=§aThe ship has crossed into the next neighbourhood. msg.shiptransit.departed=§aJump engaged - the ship is under way. Helm control is offline until you arrive. msg.shiptransit.arrived=§aArrived - the ship is back in normal space. Helm control is yours again. +msg.shiptransit.directfailed=§cThe crossing failed - your ship is at its destination but not cleanly placed. It is safe; report this. msg.shiptransit.arrivalrecovered=§eThe jump completed, but not cleanly - your ship is at its arrival point rather than on course. It is safe; report this. msg.shiptransit.arrivalstalled=§cThe jump cannot finish right now. Your ship is not lost - it stays in transit and will arrive. Report this. @@ -1705,3 +1732,6 @@ msg.navcomputer.eta=Flight time: msg.navcomputer.flightcost=Energy for the flight: msg.navcomputer.hullexposed=Hull outside the window (blocks): msg.navcomputer.ready=Ready to jump +commands.advancedrocketry.planet.generate.usage=planet generate [moon] +commands.advancedrocketry.planet.generate.invalid=Dimension: %s failed to generate! +commands.advancedrocketry.planet.generate.success=Dimension: %s generated! diff --git a/src/main/resources/assets/advancedrocketry/lang/ru_RU.lang b/src/main/resources/assets/advancedrocketry/lang/ru_RU.lang index b22ddea47..859323e53 100644 --- a/src/main/resources/assets/advancedrocketry/lang/ru_RU.lang +++ b/src/main/resources/assets/advancedrocketry/lang/ru_RU.lang @@ -252,12 +252,14 @@ msg.observetory.scan.button=Сканировать! msg.observetory.scan.crystal=Кристалл памяти msg.observetory.scan.direction=Наведение: msg.observetory.scan.direction.tooltip=Повернуть инструмент к следующему участку неба -msg.observetory.scan.distance=Дальность (секторов): -msg.observetory.scan.distance.tooltip=Как далеко смотреть. Дальше — дольше наблюдение, и у инструмента есть горизонт. +msg.observetory.scan.distance=Дальность (звёзд): +msg.observetory.scan.distance.tooltip=Как далеко смотреть, в соседних звёздах. Дальше — дольше наблюдение, и у инструмента есть горизонт. +msg.observetory.scan.lightyears= (%.1f св. лет) msg.observetory.scan.region=Наблюдать msg.observetory.scan.region.tooltip=Осмотреть выбранную область и записать в кристалл все системы, которые она разрешит msg.observetory.scan.looking=Осмотрено ячеек: msg.observetory.scan.found=Записано адресов: +msg.observetory.scan.obscured=Мешает пыль — только координаты: msg.observetory.scan.idle=Простаивает msg.observetory.scan.abort=Стоп msg.observetory.scan.abort.tooltip=Прервать обзор. Всё, что уже разрешено, уже лежит в кристалле. @@ -514,8 +516,12 @@ msg.loginrestore.shipunknown=§cВаш корабль не найден — на msg.shipdescent.refused=§cСнижение не началось — точка прибытия ещё не готова. Подождите немного и попробуйте снова. msg.shipdescent.failed=§cСпуск не удался — корабль не смог перейти на планету. msg.shipdescent.arrived=§aСнижение выполнено — корабль в небе над планетой. Ведите его вниз. +msg.shipseam.refused=§cКорабль покинул эту окрестность, но космос перегружен — перенести его некуда. Поворачивайте назад. +msg.shipseam.failed=§cПереход не удался — корабль не смог перейти в соседнюю окрестность. +msg.shipseam.arrived=§aКорабль перешёл в соседнюю окрестность. msg.shiptransit.departed=§aПрыжок начат — корабль в пути. Управление отключено до прибытия. msg.shiptransit.arrived=§aПрибытие — корабль снова в обычном пространстве. Управление снова ваше. +msg.shiptransit.directfailed=§cПереход не удался — корабль в точке назначения, но размещён не чисто. Он цел; сообщите об этом. msg.shiptransit.arrivalrecovered=§eПрыжок завершён, но не чисто — корабль стоит в точке прибытия, а не на курсе. Он цел; сообщите об этом. msg.shiptransit.arrivalstalled=§cПрыжок сейчас не может завершиться. Корабль не потерян — он остаётся в полёте и прибудет. Сообщите об этом. 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/AdvancedRocketryTestConstants.java b/src/test/java/zmaster587/advancedRocketry/test/AdvancedRocketryTestConstants.java index d06db32a5..2b153082b 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/AdvancedRocketryTestConstants.java +++ b/src/test/java/zmaster587/advancedRocketry/test/AdvancedRocketryTestConstants.java @@ -1,5 +1,9 @@ package zmaster587.advancedRocketry.test; +import zmaster587.advancedRocketry.space.CellFrames; +import zmaster587.advancedRocketry.space.GalacticCoord; +import zmaster587.advancedRocketry.space.ShipTransitManager; + /** * Shared constants for AR test fixtures. Keep values stable across runs so * snapshot/round-trip assertions stay deterministic. @@ -14,6 +18,40 @@ public final class AdvancedRocketryTestConstants { /** Deterministic world seed for any worldgen scenario. */ public static final long DETERMINISTIC_WORLD_SEED = 0x4151544553544CL; // "AQTESTL" + /** + * How far apart the space fixtures put their two cells: one sector. + * {@code artest space transit-setup*} builds origin and target one sector apart, and it is the only + * distance a fixture jump is ever priced over. + * + *

    MEASURED through the same law the departure prices a jump with, never written down. It was + * written down once, as 4M, from a probe comment that predated the cell growing to 32M — and the + * speeds derived from it put a "hyperspace" fixture 2 560 ticks from its destination.

    + */ + public static final long FIXTURE_CELL_SPACING_BLOCKS = (long) Math.ceil( + CellFrames.STATIC.distanceBetween( + GalacticCoord.ofSectorLocal(0L, 0L, 0L, 0L, 0L, 0L), + GalacticCoord.ofSectorLocal(1L, 0L, 0L, 0L, 0L, 0L), 0L)); + + /** + * A jump speed that gives a REAL hyperspace flight over {@link #FIXTURE_CELL_SPACING_BLOCKS} — + * with a lane, a park and a mid-flight a stimulus can land inside. + * + *

    Derived from the rule that chooses the mechanism rather than written down beside it: a jump + * of at most {@link ShipTransitManager#DIRECT_CROSSING_MAX_TICKS} ticks is performed as a single + * crossing instead, so a fixture that means to test hyperspace must be slower than that — but only + * just. Every tick of the flight is a tick some test has to drive, so the margin is ten ticks and + * not a factor: a comfortable factor of two would double the cost of every hyperspace e2e in the + * suite for no coverage at all.

    + */ + public static final long HYPERSPACE_JUMP_SPEED = + FIXTURE_CELL_SPACING_BLOCKS / (ShipTransitManager.DIRECT_CROSSING_MAX_TICKS + 10L); + + /** + * A jump speed that makes the same distance a DIRECT cell→cell crossing: one tick of flight, + * so the rule fires and no hyperspace lane is ever allocated. + */ + public static final long DIRECT_JUMP_SPEED = FIXTURE_CELL_SPACING_BLOCKS; + /** Stable dimension ids the test fixtures assume. */ public static final int TEST_PLANET_EARTHLIKE_DIM = 9001; public static final int TEST_PLANET_VACUUM_DIM = 9002; diff --git a/src/test/java/zmaster587/advancedRocketry/test/ServerTicks.java b/src/test/java/zmaster587/advancedRocketry/test/ServerTicks.java new file mode 100644 index 000000000..826254c5e --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/ServerTicks.java @@ -0,0 +1,105 @@ +package zmaster587.advancedRocketry.test; + +import com.github.stannismod.forge.testing.TestTimeouts; +import com.github.stannismod.forge.testing.server.TestClient; + +import java.time.Duration; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * A wait that actually waits: let the SERVER's world clock advance by N ticks. + * + *

    Console commands are drained on the server thread — the one thread that advances world time — + * so a probe handler that polls the clock is blocking the very thing it is watching. Measured + * 2026-08-17: {@code artest server wait 0 20} reports {@code elapsedTicks=0} on the OVERWORLD, a + * world that ticks by definition. Every call site that read that verb as "N ticks have now happened" + * was reading a sleep, and two wrong diagnoses were paid for it.

    + * + *

    So the waiting lives here, in the TEST jvm, which is idle while the server ticks: read the + * clock, sleep, read again. The same shape the client half already uses — {@code ClientBot.waitTicks} + * polls a counter from the bridge thread rather than from the client thread.

    + * + *

    What a caller gets that a sleep never gave it: the returned value is observed, off the + * world's own clock, and a wait that does not happen fails loudly instead of passing silently.

    + */ +public final class ServerTicks { + + /** One game tick, nominal. The server may be slower; it is never faster. */ + private static final long TICK_MS = 50L; + + /** + * How much longer than nominal a wait may take before it is called a failure. A headless server + * under concurrent forks runs behind, and {@link TestTimeouts} scales this again by fork count — + * this factor covers ordinary slack (chunk loads, GC), not contention. + */ + private static final int SLACK_FACTOR = 4; + + /** Floor for the ceiling: a short wait still gets room for one slow round-trip. */ + private static final Duration MIN_BUDGET = Duration.ofSeconds(3); + + /** Ceiling for the ceiling — a runaway wait must surface as a red, not as a hung suite. */ + private static final Duration MAX_BUDGET = Duration.ofSeconds(60); + + private static final Pattern TICK_FIELD = Pattern.compile("\"tick\":(-?\\d+)"); + + private ServerTicks() { } + + /** The world's own clock, right now. One round-trip, no waiting. */ + public static long count(TestClient client, int dim) throws Exception { + String reply = String.join("\n", client.execute("artest server tick-count " + dim)); + Matcher matcher = TICK_FIELD.matcher(reply); + if (!matcher.find()) { + throw new AssertionError("artest server tick-count " + dim + + " did not report a clock (is the dimension loaded?): " + reply); + } + return Long.parseLong(matcher.group(1)); + } + + /** + * Block the calling test until dimension {@code dim}'s clock has advanced by at least + * {@code ticks}, and return how far it actually advanced (never less than {@code ticks}). + * + * @throws AssertionError if the clock does not get there inside the budget — which is the + * interesting case, and the one the old probe reported as success. + */ + public static long await(TestClient client, int dim, int ticks) throws Exception { + return await(client, dim, ticks, budgetFor(ticks)); + } + + /** As {@link #await(TestClient, int, int)}, with a caller-chosen ceiling. */ + public static long await(TestClient client, int dim, int ticks, Duration budget) throws Exception { + if (ticks <= 0) { + throw new IllegalArgumentException("ticks must be positive, got " + ticks); + } + long start = count(client, dim); + long target = start + ticks; + long deadlineNanos = System.nanoTime() + budget.toNanos(); + + long observed = start; + while (observed < target) { + if (System.nanoTime() > deadlineNanos) { + throw new AssertionError("dim " + dim + " advanced only " + (observed - start) + + " of the " + ticks + " ticks asked for, inside " + budget.toMillis() + + " ms. Either the world is not ticking (no players, no forced chunks," + + " a slot world nobody drives) or the server is stalled — both are" + + " findings, and neither is a wait."); + } + // Sleep the time the remaining ticks would take at nominal rate, so a long wait costs + // one or two round-trips rather than one per tick. Bounded so a slow server is noticed + // early rather than at the deadline. + long remaining = target - observed; + Thread.sleep(Math.max(TICK_MS, Math.min(500L, remaining * TICK_MS))); + observed = count(client, dim); + } + return observed - start; + } + + /** The default ceiling for {@code ticks}: nominal duration with slack, load-scaled and clamped. */ + static Duration budgetFor(int ticks) { + Duration nominal = Duration.ofMillis(ticks * TICK_MS * SLACK_FACTOR); + Duration base = nominal.compareTo(MIN_BUDGET) < 0 ? MIN_BUDGET : nominal; + Duration scaled = TestTimeouts.scaled(base); + return scaled.compareTo(MAX_BUDGET) > 0 ? MAX_BUDGET : scaled; + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/BoundarySkyRendersInSlotCellE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/BoundarySkyRendersInSlotCellE2ETest.java index 8e3247f9d..15b4336d3 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/client/BoundarySkyRendersInSlotCellE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/client/BoundarySkyRendersInSlotCellE2ETest.java @@ -117,8 +117,17 @@ public class BoundarySkyRendersInSlotCellE2ETest extends AbstractClientE2ETest { private static final String SKY_CLASS = "zmaster587.advancedRocketry.client.render.planet.BoundarySky"; - /** Cell the ship settles in. sy=5000 dodges the fallback stars (all at sy=sz=0). */ - private static final String CELL = "0 5000 0"; + /** + * Cell the ship settles in — FOUND at run time, never written down. See {@link #findEmptyCell()}. + * + *

    It used to be the constant {@code "0 5000 0"}, with the note "dodges the fallback stars". That + * was true while a star's neighbourhood was a few hundred cells wide; once the star lattice became + * metric-true a system owns millions of cells around itself, the constant landed deep inside the + * home system's territory, and the arrangement below ("no body may be synced for the slot yet") + * became false with nothing wrong in production. A cell distance expressed as a bare number expires + * the next time the universe's scale moves — so this one is asked for instead.

    + */ + private String cell; /** * The cell's contents: {@code localX localY localZ kind dimId}. The ship settles at the cell CENTRE, @@ -131,14 +140,24 @@ public class BoundarySkyRendersInSlotCellE2ETest extends AbstractClientE2ETest { * feed carries both kinds and so must the subject.

    */ private static final String[][] SYSTEM = { - {"768", "-1072", "-2652", "MOON", "0"}, // ~2 961 - the nearest descend target - {"-23443", "11940", "10363", "MOON", "0"}, // ~28 275 - {"-30108", "-13988", "11037", "MOON", "0"}, // ~34 985 - {"7644", "34614", "-16382", "GAS_GIANT", "-1"}, // ~39 050 - not a descend target - {"-42912", "-23517", "-24475", "MOON", "0"}, // ~54 713 - {"-39818", "28442", "-33418", "MOON", "0"}, // ~59 255 + {"768", "-1072", "-2652", "MOON", "0", "0.27"}, // ~2 961 - the nearest descend target + {"-23443", "11940", "10363", "MOON", "0", "0.27"}, // ~28 275 + {"-30108", "-13988", "11037", "MOON", "0", "0.27"}, // ~34 985 + {"7644", "34614", "-16382", "GAS_GIANT", "-1", "11.0"}, // ~39 050 - not a descend target + {"-42912", "-23517", "-24475", "MOON", "0", "0.27"}, // ~54 713 + {"-39818", "28442", "-33418", "MOON", "0", "0.27"}, // ~59 255 }; + /** A body's radius in Earth radii, as the fixture states it — the sixth column above. */ + private static double radiusEarths(int index) { + return Double.parseDouble(SYSTEM[index][5]); + } + + /** The same, in the chart blocks the feed sends and the renderer sizes with. */ + private static double radiusBlocks(int index) { + return radiusEarths(index) * zmaster587.advancedRocketry.util.AstronomicalBodyHelper.EARTH_RADIUS_BLOCKS; + } + /** The nearest descend target: the body a pilot has to find and fly at to descend at all. */ private static final int NEAREST = 0; /** The gas giant: a non-descend body, which takes the other tint and no texture of its own. */ @@ -265,7 +284,8 @@ public void aPilotInASlotCellSeesTheBodiesAndStars() throws Exception { // with it and the pilot is put into it. String setup = exec("artest space entry-setup 1"); assertTrue("entry-setup must install the stack: " + setup, setup.contains("\"ok\":true")); - String settle = exec("artest space ledger-settle " + CELL + " 0"); + cell = findEmptyCell(); + String settle = exec("artest space ledger-settle " + cell + " 0"); assertTrue("ledger-settle must succeed: " + settle, settle.contains("\"ok\":true")); Matcher boundM = BOUND_DIM.matcher(settle); assertTrue("the settle must report which slot the cell was bound to: " + settle, boundM.find()); @@ -308,8 +328,11 @@ public void aPilotInASlotCellSeesTheBodiesAndStars() throws Exception { emptyBefore = capture(slotDim, CELL_CAPTURE_Y, EMPTY_YAW, EMPTY_PITCH, "before_empty"); for (String[] body : SYSTEM) { - String poi = exec("artest space add-poi " + CELL + " " + body[0] + " " + body[1] + " " - + body[2] + " " + body[3] + " " + body[4] + " 7"); + // The radius is stated, not implied: since 2026-08-16 the sky sizes a body by the + // ANGLE it subtends, so a fixture that named no radius would draw six identical + // markers and the size legs below would be measuring nothing. + String poi = exec("artest space add-poi " + cell + " " + body[0] + " " + body[1] + " " + + body[2] + " " + body[3] + " " + body[4] + " 7 " + body[5]); assertTrue("add-poi must register the body: " + poi, poi.contains("\"ok\":true")); } @@ -452,6 +475,138 @@ public void aPilotInASlotCellSeesTheBodiesAndStars() throws Exception { descendTargets, boundariesWithBodies); } + /** + * A pilot in a cell near a molecular cloud sees the cloud. + * + *

    A star cluster is invisible from outside it — it can be told apart only by counting its stars, + * which nobody will do — so the cloud wrapping it is the one landmark the universe layer has. This + * measures whether it reaches the screen at all.

    + * + *

    Counted, not photographed, and that is deliberate. A nebula is haze whose alpha falls to + * zero at its rim; a pixel-difference test would be measuring the tuning of {@code NEBULA_MAX_ALPHA} + * as much as the feed, and would go red the first time the haze was made subtler. The renderer's own + * per-frame counter answers "did a cloud reach the rasterizer" exactly. It is read BESIDE + * {@code skyFramesDrawn}, because a zero means "no cloud was drawn" only if the sky renderer ran at + * all — the two are different questions and one counter cannot tell them apart.

    + * + *

    Where the cloud is comes from the SERVER, not from this test. A cloud's position is a + * fact about the seed; a hard-coded cell would pin this test to one world's generation and would + * fail as an accusation against the renderer the first time the seed moved. The probe is asked where + * to stand.

    + */ + @Test + public void aPilotNearACloudSeesIt() throws Exception { + JsonObject rd = bot().setRenderDistance(SKY_RENDER_DISTANCE); + int previousRenderDistance = rd.get("previous").getAsInt(); + assertTrue("the sky pass gate must be open, read back off the client's own field: " + rd, + rd.get("skyPassEnabled").getAsBoolean()); + String health = exec("artest player health"); + Matcher nameM = PLAYER_NAME.matcher(health); + assertTrue("player health must echo the player name: " + health, nameM.find()); + botName = nameM.group(1); + try { + String setup = exec("artest space entry-setup 1"); + assertTrue("entry-setup must install the stack: " + setup, setup.contains("\"ok\":true")); + + // A universe with clusters in it. Without a world has no galaxies, hence no + // clusters, hence no gas — and an empty sky would be honest for the wrong reason. + String gen = exec("artest space gen-install 0.9 8"); + assertTrue("the procedural generator must install: " + gen, gen.contains("\"ok\":true")); + + String found = exec("artest space nebula-find 512 64"); + assertTrue("the generator must be able to name a cell with a cloud in reach: " + found, + found.contains("\"found\":true")); + Matcher sectorM = Pattern.compile("\"sectorX\":(-?\\d+)").matcher(found); + assertTrue("the find must report the cell it found: " + found, sectorM.find()); + String cloudCell = sectorM.group(1) + " 0 0"; + + String settle = exec("artest space ledger-settle " + cloudCell + " 0"); + assertTrue("ledger-settle must succeed: " + settle, settle.contains("\"ok\":true")); + Matcher boundM = BOUND_DIM.matcher(settle); + assertTrue("the settle must report which slot the cell was bound to: " + settle, + boundM.find()); + int slotDim = Integer.parseInt(boundM.group(1)); + + // The server's own answer for that cell, as the cross-side oracle: what it will send. + String feed = exec("artest space nebulae " + cloudCell); + Matcher drawnM = Pattern.compile("\"drawn\":(\\d+)").matcher(feed); + assertTrue("the probe must report the cell's sky: " + feed, drawnM.find()); + int serverClouds = Integer.parseInt(drawnM.group(1)); + assertTrue("the cell the finder chose must actually have a cloud in its sky: " + feed, + serverClouds >= 1); + + exec("time set 18000"); + seat(slotDim, CELL_CAPTURE_Y); + + // Gate on the FEED reaching the client, then on a frame being drawn after it did. Waiting + // a fixed number of ticks would make a slow broadcast read as a renderer that draws nothing. + int drawn = 0; + long frames = 0L; + for (int attempt = 0; attempt < 30 && drawn == 0; attempt++) { + bot().waitTicks(10); + frames = Long.parseLong(bot().readStaticField(SKY_CLASS, "skyFramesDrawn") + .get("value").getAsString().trim()); + drawn = skyCounter("nebulaeDrawnLastFrame"); + } + + assertTrue("HARNESS CONTROL: the sky renderer never ran, so nothing below could mean" + + " anything (frames=" + frames + ")", frames > 0L); + assertTrue("the server had " + serverClouds + " cloud(s) in this cell's sky and the client" + + " drew " + drawn + ": a landmark that reaches the feed and not the frame is a" + + " landmark nobody can navigate by", drawn >= 1); + } finally { + try { + exec("artest space gen-reset"); + } catch (Exception ignored) { + // the generator is a JVM global: a shared client run must not inherit this one + } + bot().setRenderDistance(previousRenderDistance); + } + } + + /** + * A cell that belongs to no system, asked of the universe rather than assumed. + * + *

    This test supplies the whole contents of its cell itself, so its arrangement needs a cell the + * generator has put NOTHING in — otherwise the "before" captures already hold somebody else's + * planets and every difference below is attributed to the wrong cause. Emptiness is read with the + * feed's own predicate: {@code skyBodiesAt} is the union of the owning system's bodies and the + * cell's own, which {@code cell-info} reports as {@code systemBodies} and {@code bodiesAt}, so both + * must be zero.

    + * + *

    The search DOUBLES its distance instead of stepping by a territory, and that is the point: a + * territory's width is a property of the active generator, and the moment this test writes it down + * it inherits an assumption that expires. Doubling reaches past any width there will ever be — it + * only has to stop before {@code Integer.MAX_VALUE}, because the probe parses a sector as an int + * and would SILENTLY answer about cell 0/0/0 for anything wider. Which is why the echoed + * {@code cellKey} is checked against the cell that was asked for.

    + */ + private String findEmptyCell() throws Exception { + StringBuilder tried = new StringBuilder(); + for (long sy = 4096L; sy > 0L && sy <= Integer.MAX_VALUE; sy *= 2L) { + String info = exec("artest space cell-info 0 " + sy + " 0"); + assertTrue("cell-info must answer about the very cell it was asked about, or the sector" + + " overflowed the probe's int parse and it silently answered about the" + + " origin: " + info, + info.contains("\"cellKey\":\"0_" + sy + "_0\"")); + int system = intField(info, "systemBodies"); + int here = intField(info, "bodiesAt"); + tried.append(" 0/").append(sy).append("/0=").append(system).append('+').append(here); + if (system == 0 && here == 0) { + return "0 " + sy + " 0"; + } + } + throw new AssertionError("no cell within the probe's int-sized sector range is free of bodies," + + " so this test has nowhere to arrange its own system; tried (systemBodies+bodiesAt):" + + tried); + } + + private static int intField(String json, String name) { + Matcher m = Pattern.compile("\"" + name + "\":(\\d+)").matcher(json); + assertTrue("cell-info must report " + name + ": " + json, m.find()); + return Integer.parseInt(m.group(1)); + } + /** How many body labels the client's last rendered frame wrote. */ private int labelsDrawn() throws Exception { return skyCounter("labelsDrawnLastFrame"); @@ -519,7 +674,8 @@ private void assertBodyDrawn(int index, BufferedImage beforeFrame, BufferedImage * the client actually drew, so a build that drew nothing fails whatever the box is. */ private static double discRadiusOf(int index) { - return Math.toDegrees(Math.atan(ApparentSize.halfSizeFor(distanceOf(index)) / 90.0)) / 70.0; + return Math.toDegrees(Math.atan( + ApparentSize.halfSizeFor(radiusBlocks(index), distanceOf(index)) / 90.0)) / 70.0; } /** How far the configured body {@code index} is from the settled ship, in blocks. */ diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/MachineGuiClientGroupE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/MachineGuiClientGroupE2ETest.java index d928bb677..229955211 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/client/MachineGuiClientGroupE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/client/MachineGuiClientGroupE2ETest.java @@ -100,6 +100,8 @@ public class MachineGuiClientGroupE2ETest extends AbstractSharedClientE2ETest { // Observatory region-scan probe fields. private static final Pattern TELESCOPE_ORIGIN = Pattern.compile("\"origin\":\"([^\"]*)\""); private static final Pattern TELESCOPE_AIM_DISTANCE = Pattern.compile("\"aimDistance\":(\\d+)"); + /** What one step of the aim is worth in cells — the aim is counted in star territories. */ + private static final Pattern TELESCOPE_STEP_CELLS = Pattern.compile("\"stepCells\":(\\d+)"); private static final Pattern TELESCOPE_ADDRESSES = Pattern.compile("\"addresses\":(-?\\d+)"); // Railgun probe fields. @@ -504,9 +506,8 @@ public void theOperatorAimsTheTelescopeAndObservesWithNothingButClicks() throws // is what this drives. exec("artest config set planetsMustBeDiscovered false"); exec("artest config set telescopeScanBaseTicks 0"); - exec("artest config set telescopeScanTicksPerSector 1"); - exec("artest config set telescopeScanHalfWidthSectors 1"); - exec("artest config set telescopeScanRangeSectors 24"); + exec("artest config set telescopeLimitingMagnitude 30"); + exec("artest config set telescopeConeHalfAngleDegrees 20"); String crystal = exec("artest telescope crystal " + where); scenario().requireArranged("could not put a crystal in the observatory: " + crystal, crystal.contains("\"ok\":true")); @@ -535,9 +536,13 @@ public void theOperatorAimsTheTelescopeAndObservesWithNothingButClicks() throws assertTrue("clicking the distance button twice must move the aim out from 1: " + aimed, aimDistance > 1); - // Put a system exactly where the operator has it pointed — the default aim is +X, and the - // distance is whatever his clicks produced. - String system = exec("artest telescope system " + (Long.parseLong(home[0]) + aimDistance) + // Put a system where the operator has it pointed — the default aim is +X, and the distance is + // whatever his clicks produced. The aim is counted in STEPS of one star's territory, so the + // cell it lands on is that many strides out; the seat is offset inside the territory, since + // what a look must find is the system that OWNS the cell and not a star standing on it. + long stepCells = readInt(aimed, TELESCOPE_STEP_CELLS); + String system = exec("artest telescope system " + + (Long.parseLong(home[0]) + aimDistance * stepCells + 13L) + " " + home[1] + " " + home[2]); scenario().requireArranged("could not place a system to be found: " + system, system.contains("\"ok\":true")); 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..45c0b97b3 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/client/SpikeFarCoordinatePlayabilityTest.java @@ -0,0 +1,468 @@ +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 org.valkyrienskies.mod.common.ships.chunk_claims.ShipChunkAllocator; +import zmaster587.advancedRocketry.test.ServerTicks; + +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: + *
      + *
    1. 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.
    2. + *
    3. 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.
    4. + *
    5. Standing: {@code posY} within {@value #Y_TOLERANCE} of the floor top throughout.
    6. + *
    7. No rubber-band: server and client agree on {@code posX} to within + * {@value #SYNC_TOLERANCE} blocks at rest.
    8. + *
    + * + *

    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}, so + * the four cases below are decided before the run: one chunk under the X edge moves, the first + * reserved chunk does not, and a coordinate deep inside moves again once Z drops below the + * quadrant. A miss on ANY of the four falsifies the explanation. + * + *

    The edge is READ from the allocator rather than written down. It was written down once — + * as {@code cx >= 318401}, block X 5 094 416 — and then the constant was raised to give the + * cell its clearance, at which point this test went red saying the explanation had been + * falsified. It had not: the number had moved and the test had not been told. A test that + * pins a mechanism must be keyed to the mechanism's own constant, or it pins the day it was + * written.

    + */ + @Test + public void whereExactlyDoesADeliveryStopWorking() throws Exception { + bot().waitForWorld(); + exec("gamerule sendCommandFeedback false"); + exec("gamerule logAdminCommands false"); + + List report = new ArrayList<>(); + List wrong = new ArrayList<>(); + // The first reserved BLOCK X, straight out of the predicate the teleport is cancelled by. + final long edgeX = ((long) (ShipChunkAllocator.CHUNK_X_START + - ShipChunkAllocator.MAX_CHUNK_RADIUS)) << 4; + // Deep inside the quadrant, and derived so it stays inside whatever the edge becomes — + // a hard-coded 28M was inside the old quadrant and would not be inside a much later one. + final long deepX = edgeX + 1_000_000L; + // {x, z, expectedToMove} + double[][] cases = { + {edgeX - 16 + 0.5d, 0.5d, 1d}, // one chunk under the edge + {edgeX + 0.5d, 0.5d, 0d}, // the first reserved chunk + {deepX + 0.5d, 0.5d, 0d}, // deep inside the quadrant + {deepX + 0.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"); + ServerTicks.await(serverClient(), 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); + } + } + ServerTicks.await(serverClient(), 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"); + ServerTicks.await(serverClient(), 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)); + ServerTicks.await(serverClient(), 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/client/SpikeFarCoordinateRenderJitterTest.java b/src/test/java/zmaster587/advancedRocketry/test/client/SpikeFarCoordinateRenderJitterTest.java new file mode 100644 index 000000000..bbc184b3e --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/client/SpikeFarCoordinateRenderJitterTest.java @@ -0,0 +1,370 @@ +package zmaster587.advancedRocketry.test.client; + +import com.github.stannismod.forge.testing.junit.AbstractClientE2ETest; +import com.google.gson.JsonObject; + +import org.junit.Test; +import zmaster587.advancedRocketry.test.ServerTicks; + +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? + * + *

    The cell used to be 4,000,000 blocks, justified 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

    + *
      + *
    1. 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.
    2. + *
    3. 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.
    4. + *
    + * + *

    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)); + ServerTicks.await(serverClient(), 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..d54e84313 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/client/SpikeFarCoordinateShipTest.java @@ -0,0 +1,666 @@ +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 zmaster587.advancedRocketry.test.ServerTicks; + +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: + *
      + *
    1. assembly must produce a VS ship (the ship count rises), and it must LOAD ({@code managed});
    2. + *
    3. the pilot seat must be findable and mountable — crew retention through the far assembly;
    4. + *
    5. a real held vertical-up key must lift the server ship by more than + * {@value #MIN_LIFT_BLOCKS} block;
    6. + *
    7. 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.
    8. + *
    + * + *

    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: + *
      + *
    1. where the CLIENT thinks the player is (a client that never arrived explains everything);
    2. + *
    3. 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);
    4. + *
    5. where the SERVER holds that same dummy (so a client/server split is visible as one).
    6. + *
    + * + * @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)); + ServerTicks.await(serverClient(), 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..32dd56738 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/client/SpikeSubBlockPositionGranularityTest.java @@ -0,0 +1,299 @@ +package zmaster587.advancedRocketry.test.client; + +import com.github.stannismod.forge.testing.junit.AbstractClientE2ETest; +import com.google.gson.JsonObject; + +import org.junit.Test; +import zmaster587.advancedRocketry.test.ServerTicks; + +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: + *
      + *
    1. the SERVER's {@code posX} must equal the asked position within + * {@value #SERVER_TOLERANCE} blocks;
    2. + *
    3. the CLIENT's own {@code posX} must agree with it within {@value #CLIENT_TOLERANCE} blocks;
    4. + *
    5. every non-zero offset must read back DISTINCT from the offset-0 base — a quantum would + * collapse the small ones onto it.
    6. + *
    + * {@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)); + ServerTicks.await(serverClient(), 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)); + ServerTicks.await(serverClient(), 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)); + ServerTicks.await(serverClient(), 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); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/VSFlightSmoothnessAcrossJumpE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/VSFlightSmoothnessAcrossJumpE2ETest.java index bf9260b3c..d04bbed76 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/client/VSFlightSmoothnessAcrossJumpE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/client/VSFlightSmoothnessAcrossJumpE2ETest.java @@ -11,6 +11,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import static zmaster587.advancedRocketry.test.AdvancedRocketryTestConstants.HYPERSPACE_JUMP_SPEED; import static org.junit.Assert.assertTrue; /** @@ -196,7 +197,8 @@ public void aShipFliesAsSmoothlyAfterAJumpAsBeforeOne() throws Exception { String begin = exec("artest space transit-begin " + originDim + " " + (int) Math.round(readDouble(shipNow, "posX")) + " " + (int) Math.round(readDouble(shipNow, "posY")) - + " " + (int) Math.round(readDouble(shipNow, "posZ"))); + + " " + (int) Math.round(readDouble(shipNow, "posZ")) + + " " + HYPERSPACE_JUMP_SPEED); assertTrue("ARRANGEMENT: the transit must begin (departure crossing): " + begin, readBool(begin, "began")); @@ -204,7 +206,7 @@ public void aShipFliesAsSmoothlyAfterAJumpAsBeforeOne() throws Exception { String lastTick = ""; int arriveBudget = (int) (120 * TestTimeouts.factor()); for (int i = 0; i < arriveBudget && targetDim < 0; i++) { - lastTick = exec("artest space transit-tick"); + lastTick = exec("artest space transit-tick 10"); if (readIntOr(lastTick, "inTransit", -1) == 0) { targetDim = readIntOr(lastTick, "targetDim", -1); break; @@ -218,7 +220,7 @@ public void aShipFliesAsSmoothlyAfterAJumpAsBeforeOne() throws Exception { int reseatBudget = (int) (60 * TestTimeouts.factor()); String lastReseatTick = ""; for (int i = 0; i < reseatBudget && !seatedOnArrival; i++) { - lastReseatTick = exec("artest space transit-tick"); + lastReseatTick = exec("artest space transit-tick 10"); bot().waitTicks(2); seatedOnArrival = bot().reportRidingEntity().get("riding").getAsBoolean() && bot().reportWeather().get("dim").getAsInt() == targetDim; diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/VSJumpDriveFixtureBoardingE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/VSJumpDriveFixtureBoardingE2ETest.java index 6aee88ee0..cab0293de 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/client/VSJumpDriveFixtureBoardingE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/client/VSJumpDriveFixtureBoardingE2ETest.java @@ -250,13 +250,17 @@ public void aJumpCraftAssemblesWholeAndBothItsConsolesAnswerARealKeyPress() thro String cooled = exec("artest drive info 0 " + afcSub[0] + " " + afcSub[1] + " " + afcSub[2]); long cooldown = readLong(cooled, "cooldownTicks"); long burst = readLong(cooled, "burstCost"); + // The cooldown is now burst / the bank's ACCEPT rate — a best case at full inflow — and heat + // sinks are what raise that ceiling. So the shape under test is unchanged: read the implied + // throughput back out and compare it against what a bare controller alone would allow. long observedRate = cooldown > 0L ? burst / cooldown : Long.MAX_VALUE; - assertTrue("the HEAT SINKS must be cooling this ship's bank. A drained bank refilling at " - + observedRate + "/tick (burst " + burst + " over " + cooldown + " ticks) is " - + "what an uncooled controller alone does — the sinks rode into subspace but " - + "the bank is not walking to them. emptied=" + emptied + " info=" + cooled, + assertTrue("the HEAT SINKS must be raising this ship's bank throughput. A drained bank quoted " + + "at " + observedRate + "/tick (burst " + burst + " over " + cooldown + + " ticks) is what an uncooled controller alone allows — the sinks rode into " + + "subspace but the bank is not walking to them. emptied=" + emptied + + " info=" + cooled, cooldown >= 0L && burst > 0L - && observedRate > DriveTuning.CAPACITOR_BASE_CHARGE_RATE * 2L); + && observedRate > DriveTuning.CAPACITOR_BASE_ACCEPT_RATE * 2L); String gate = exec("artest nav gate 0 " + afcSub[0] + " " + afcSub[1] + " " + afcSub[2]); assertTrue("the ship must find its own NAVIGATION COMPUTER from the flight computer. That " diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/VSMidTransitRelogControlE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/VSMidTransitRelogControlE2ETest.java index a8786d5c9..bcfe8ecb7 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/client/VSMidTransitRelogControlE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/client/VSMidTransitRelogControlE2ETest.java @@ -14,6 +14,7 @@ import zmaster587.advancedRocketry.space.GalacticCoord; +import static zmaster587.advancedRocketry.test.AdvancedRocketryTestConstants.HYPERSPACE_JUMP_SPEED; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -150,9 +151,9 @@ public void aPilotWhoRelogsMidTransitRegainsControlOnArrival() throws Exception // ticks (the cells sit one 4M-block sector apart), so the relog lands INSIDE the transit // instead of racing a single-tick jump. --------------------------------------------------- String begin = exec("artest space transit-begin " + originDim - + " " + ax + " " + ay + " " + az + " 100000"); + + " " + ax + " " + ay + " " + az + " " + HYPERSPACE_JUMP_SPEED); assertTrue("the transit must begin (departure crossing): " + begin, readBool(begin, "began")); - String firstTick = exec("artest space transit-tick"); + String firstTick = exec("artest space transit-tick 10"); assertTrue("the ship must actually be IN TRANSIT when the pilot relogs — otherwise this " + "pins an ordinary relog, not the mid-transit one: " + firstTick, readInt(firstTick, "inTransit") >= 1); @@ -167,7 +168,7 @@ public void aPilotWhoRelogsMidTransitRegainsControlOnArrival() throws Exception String lastTick = ""; int arriveBudget = (int) (80 * TestTimeouts.factor()); for (int i = 0; i < arriveBudget && targetDim < 0; i++) { - lastTick = exec("artest space transit-tick"); + lastTick = exec("artest space transit-tick 10"); if (readInt(lastTick, "inTransit") == 0) { targetDim = readInt(lastTick, "targetDim"); break; @@ -183,7 +184,7 @@ public void aPilotWhoRelogsMidTransitRegainsControlOnArrival() throws Exception int reseatBudget = (int) (60 * TestTimeouts.factor()); String lastReseatTick = ""; for (int i = 0; i < reseatBudget && !seatedOnArrival; i++) { - lastReseatTick = exec("artest space transit-tick"); + lastReseatTick = exec("artest space transit-tick 10"); bot().waitTicks(2); seatedOnArrival = bot().reportRidingEntity().get("riding").getAsBoolean() && bot().reportWeather().get("dim").getAsInt() == targetDim; diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/VSShipEntryRefusedKeepsPilotSeatedE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/VSShipEntryRefusedKeepsPilotSeatedE2ETest.java index 3c79754c7..dafd13447 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/client/VSShipEntryRefusedKeepsPilotSeatedE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/client/VSShipEntryRefusedKeepsPilotSeatedE2ETest.java @@ -58,6 +58,9 @@ public class VSShipEntryRefusedKeepsPilotSeatedE2ETest { Pattern.compile("\"builderPos\":\\[(-?\\d+),(-?\\d+),(-?\\d+)]"); private static final Pattern POS_Y = Pattern.compile("\"posY\":(-?[0-9.E\\-]+)"); private static final Pattern DUMMY_ID = Pattern.compile("\"dummyId\":(-?\\d+)"); + /** Ledger #264 discriminator: the seat's own delivery counters, sampled across the climb. */ + private static final Pattern RECEIVED = Pattern.compile("\"received\":(\\d+)"); + private static final Pattern DELIVERED = Pattern.compile("\"delivered\":(\\d+)"); private static final Pattern LEDGER = Pattern.compile("\"ledger\":(-?\\d+)"); private static final Pattern SHIP_ID = Pattern.compile("\"id\":\"([0-9a-fA-F-]+)\""); private static final Pattern VEL_Y = Pattern.compile("\"velY\":(-?[0-9.E\\-]+)"); @@ -201,6 +204,7 @@ public void aRefusedEntryLeavesThePilotSeatedWithAMessage() throws Exception { String refusalLine = null; double maxShipY = yRest; StringBuilder climb = new StringBuilder(64); + StringBuilder diag = new StringBuilder(64); bot().holdKey(Keyboard.KEY_R); try { for (int attempt = 0; attempt < budget && (yControl - yRest) < MIN_CONTROL_CLIMB; attempt++) { @@ -233,6 +237,19 @@ public void aRefusedEntryLeavesThePilotSeatedWithAMessage() throws Exception { // force-loads the ship's subspace yard nor touches a chunk, so the climb it is // watching gets exactly the resources it would have got unwatched. String s = exec("artest vs ship-info 0 id " + shipUuid); + // THE DISCRIMINATOR for ledger #264, sampled ACROSS the dying climb rather than + // after it. Three candidate causes, and the climb trace alone cannot separate + // them: the tile instance is being replaced under the ship (afcIdentity changes), + // the computer is not ticking at all (controllerTicks flat), or the packet + // arrives and is refused at the seat's pilot guard (received climbs while + // delivered does not). Sampled at the same cadence as the altitude so the two + // timelines line up tick for tick. + if (diag.length() < 900) { + String d = exec("artest vs seat-delivery"); + diag.append(' ').append(attempt).append(":recv=") + .append(firstGroupOr(RECEIVED, d, "?")) + .append("/deliv=").append(firstGroupOr(DELIVERED, d, "?")); + } Matcher py = POS_Y.matcher(s); Matcher vy = VEL_Y.matcher(s); if (py.find()) { @@ -263,7 +280,9 @@ public void aRefusedEntryLeavesThePilotSeatedWithAMessage() throws Exception { assertTrue("a pilot whose entry is refused (pool exhausted) must be TOLD so in his own " + "chat - a silent refusal reads as a dead ship. chat=" + bot().reportChat(8) + " subsystem=" + exec("artest space subsystem-status") - + " maxShipY=" + maxShipY + " climb(attempt:y/velY)=[" + climb.toString().trim() + + " maxShipY=" + maxShipY + + " delivery(attempt:recv/deliv)=[" + diag.toString().trim() + "]" + + " climb(attempt:y/velY)=[" + climb.toString().trim() + "] gate=" + exec("artest space entry-gate 0 " + shipUuid), refusalLine != null); @@ -302,6 +321,13 @@ private ClientBot bot() { return clientHarness.bot(); } + /** First capture group of {@code p} in {@code s}, or {@code fallback} — a missing field must read + * as "not answered" and never as a number, which is how a dead probe reads as a real zero. */ + private static String firstGroupOr(Pattern p, String s, String fallback) { + Matcher m = p.matcher(s); + return m.find() ? m.group(1) : fallback; + } + private String exec(String cmd) throws Exception { return String.join("\n", serverHarness.client().execute(cmd)); } diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/VSTransitCrewGroupE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/VSTransitCrewGroupE2ETest.java index bee85411b..e602f7d23 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/client/VSTransitCrewGroupE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/client/VSTransitCrewGroupE2ETest.java @@ -9,6 +9,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import static zmaster587.advancedRocketry.test.AdvancedRocketryTestConstants.HYPERSPACE_JUMP_SPEED; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -143,7 +144,7 @@ private static long gameSeen(String traceJson) { } /** Blocks per tick for the jump. Slow enough that the ship stays parked for tens of ticks. */ -private static final long PARK_SPEED = 100_000L; +private static final long PARK_SPEED = HYPERSPACE_JUMP_SPEED; // ---- migrated: VSShipTransitCrewE2ETest ---- @@ -218,14 +219,14 @@ public void aSeatedCrewMemberSurvivesAHyperspaceTransitStillRiding() throws Exce + bot().reportRidingEntity(), bot().reportRidingEntity().get("riding").getAsBoolean()); // Depart into hyperspace at the ship anchor (1,64,1 from transit-setup-piloted). - String begin = exec("artest space transit-begin " + originDim + " 1 64 1"); + String begin = exec("artest space transit-begin " + originDim + " 1 64 1 " + HYPERSPACE_JUMP_SPEED); assertTrue("the transit must begin (departure crossing): " + begin, readBool(begin, "began")); // Advance the jump: tick until it arrives (inTransit == 0), capturing the target cell's slot dim. int targetDim = -1; String lastTick = ""; for (int i = 0; i < 80 && targetDim < 0; i++) { - lastTick = exec("artest space transit-tick"); + lastTick = exec("artest space transit-tick 10"); if (readInt(lastTick, "inTransit") == 0) { targetDim = readInt(lastTick, "targetDim"); break; @@ -238,7 +239,7 @@ public void aSeatedCrewMemberSurvivesAHyperspaceTransitStillRiding() throws Exce // drive the retries) and observe the CLIENT until it is riding again in the target dim, bounded. boolean crewSurvived = false; for (int i = 0; i < 60 && !crewSurvived; i++) { - exec("artest space transit-tick"); + exec("artest space transit-tick 10"); bot().waitTicks(2); crewSurvived = bot().reportRidingEntity().get("riding").getAsBoolean() && bot().reportWeather().get("dim").getAsInt() == targetDim; @@ -375,7 +376,7 @@ public void aSeatedCrewMemberIsAboardHisShipInHyperspaceWhileItIsStillFlying() t boolean ridingInFlight = false; String lastTick = ""; for (int i = 0; i < 120; i++) { - lastTick = exec("artest space transit-tick"); + lastTick = exec("artest space transit-tick 10"); if (readInt(lastTick, "inTransit") == 0) { break; // arrived - everything after this point is the far end, which is another test's } @@ -519,13 +520,13 @@ public void aCrewMemberIsReseatedOnArrivalWithNothingForcingTheShipLoaded() thro assertTrue("the bot must be seated on the ship BEFORE the jump (control): " + bot().reportRidingEntity(), bot().reportRidingEntity().get("riding").getAsBoolean()); - String begin = execEnvelope("artest space transit-begin " + originDim + " 1 64 1"); + String begin = execEnvelope("artest space transit-begin " + originDim + " 1 64 1 " + HYPERSPACE_JUMP_SPEED); assertTrue("the transit must begin (departure crossing): " + begin, readBool(begin, "began")); int targetDim = -1; String lastTick = ""; for (int i = 0; i < 80 && targetDim < 0; i++) { - lastTick = execEnvelope("artest space transit-tick"); + lastTick = execEnvelope("artest space transit-tick 10"); if (readInt(lastTick, "inTransit") == 0) { targetDim = readInt(lastTick, "targetDim"); break; @@ -539,7 +540,7 @@ public void aCrewMemberIsReseatedOnArrivalWithNothingForcingTheShipLoaded() thro // is nothing in this world to load it. boolean reseated = false; for (int i = 0; i < RESEAT_POLLS && !reseated; i++) { - execEnvelope("artest space transit-tick"); + execEnvelope("artest space transit-tick 10"); bot().waitTicks(2); reseated = bot().reportRidingEntity().get("riding").getAsBoolean() && bot().reportWeather().get("dim").getAsInt() == targetDim; @@ -694,7 +695,7 @@ public void aJumpAnnouncesItselfInChatOnTheHudAndInTheSky() throws Exception { long tunnelInFlight = -1L; String lastTick = ""; for (int i = 0; i < 120; i++) { - lastTick = exec("artest space transit-tick"); + lastTick = exec("artest space transit-tick 10"); if (readInt(lastTick, "inTransit") == 0) { break; } @@ -744,7 +745,7 @@ public void aJumpAnnouncesItselfInChatOnTheHudAndInTheSky() throws Exception { // ── ARRIVAL ───────────────────────────────────────────────────────────────────────────── for (int i = 0; i < 60 && readInt(lastTick, "inTransit") != 0; i++) { - lastTick = exec("artest space transit-tick"); + lastTick = exec("artest space transit-tick 10"); bot().waitTicks(2); } assertEquals("the transit must have finished for the arrival message to be owed: " + lastTick, @@ -941,7 +942,7 @@ public void aCrewMemberLivesInHyperspaceUntilHeStepsOffHisShip() throws Exceptio int hyperDim = -1; String lastTick = ""; for (int i = 0; i < 120; i++) { - lastTick = exec("artest space transit-tick"); + lastTick = exec("artest space transit-tick 10"); if (readInt(lastTick, "inTransit") == 0) { break; } @@ -1097,7 +1098,7 @@ public void aCrewMemberLivesInHyperspaceUntilHeStepsOffHisShip() throws Exceptio // scenario shares, with a crew record for a player who is no longer alive to be re-seated. // Ending the transit puts the shared world back the way this scenario found it. for (int i = 0; i < 200; i++) { - if (readInt(exec("artest space transit-tick"), "inTransit") == 0) { + if (readInt(exec("artest space transit-tick 10"), "inTransit") == 0) { break; } bot().waitTicks(2); @@ -1158,7 +1159,7 @@ public void aWalkingCrewMemberTravelsWithHisShipThroughHyperspace() throws Excep String captureInFlight = ""; String lastTick = ""; for (int i = 0; i < 120; i++) { - lastTick = exec("artest space transit-tick"); + lastTick = exec("artest space transit-tick 10"); if (readInt(lastTick, "inTransit") == 0) { break; // arrived — the far end is another scenario's subject } @@ -1204,7 +1205,7 @@ public void aWalkingCrewMemberTravelsWithHisShipThroughHyperspace() throws Excep // says nothing about the second. int targetDim = -1; for (int i = 0; i < 120 && targetDim < 0; i++) { - lastTick = exec("artest space transit-tick"); + lastTick = exec("artest space transit-tick 10"); if (readInt(lastTick, "inTransit") == 0) { targetDim = readInt(lastTick, "targetDim"); break; @@ -1221,7 +1222,7 @@ public void aWalkingCrewMemberTravelsWithHisShipThroughHyperspace() throws Excep // Drive the placement's retries and watch the CLIENT, exactly as the seated siblings do. boolean carriedOn = false; for (int i = 0; i < RESEAT_POLLS && !carriedOn; i++) { - exec("artest space transit-tick"); + exec("artest space transit-tick 10"); bot().waitTicks(2); carriedOn = bot().reportWeather().get("dim").getAsInt() == targetDim && readBool(exec("artest vs deck-capture"), "alreadyTracked"); @@ -1295,7 +1296,7 @@ public void aStandingCrewMemberStillSeesTheHyperspaceCorridor() throws Exception int hyperDim = -1; String lastTick = ""; for (int i = 0; i < 120; i++) { - lastTick = exec("artest space transit-tick"); + lastTick = exec("artest space transit-tick 10"); if (readInt(lastTick, "inTransit") == 0) { break; } @@ -1391,7 +1392,7 @@ public void aCrewMemberWhoStoodUpMidFlightArrivesOnHisFeet() throws Exception { int hyperDim = -1; String lastTick = ""; for (int i = 0; i < 120; i++) { - lastTick = exec("artest space transit-tick"); + lastTick = exec("artest space transit-tick 10"); if (readInt(lastTick, "inTransit") == 0) { break; } @@ -1422,7 +1423,7 @@ public void aCrewMemberWhoStoodUpMidFlightArrivesOnHisFeet() throws Exception { // ── FINISH THE JUMP ───────────────────────────────────────────────────────────────────── int targetDim = -1; for (int i = 0; i < 120 && targetDim < 0; i++) { - lastTick = exec("artest space transit-tick"); + lastTick = exec("artest space transit-tick 10"); if (readInt(lastTick, "inTransit") == 0) { targetDim = readInt(lastTick, "targetDim"); break; @@ -1441,7 +1442,7 @@ public void aCrewMemberWhoStoodUpMidFlightArrivesOnHisFeet() throws Exception { // neither the loss nor the window it happened in. boolean carriedOn = false; for (int i = 0; i < RESEAT_POLLS && !carriedOn; i++) { - exec("artest space transit-tick"); + exec("artest space transit-tick 10"); bot().waitTicks(2); JsonObject state = bot().reportState(); com.google.gson.JsonElement health = state.get("health"); diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/VehicleRideClientGroupE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/VehicleRideClientGroupE2ETest.java index 00cb066f3..93d71bab4 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/client/VehicleRideClientGroupE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/client/VehicleRideClientGroupE2ETest.java @@ -72,8 +72,8 @@ public class VehicleRideClientGroupE2ETest extends AbstractSharedClientE2ETest { private static final Pattern ENTITY_ID = Pattern.compile("\"entityId\":(-?\\d+)"); private static final Pattern RIDING_ID = Pattern.compile("\"ridingEntityId(?:Now)?\":(-?\\d+)"); - private static final Pattern POS_X = Pattern.compile("\"posX\":(-?\\d+(?:\\.\\d+)?)"); - private static final Pattern POS_Z = Pattern.compile("\"posZ\":(-?\\d+(?:\\.\\d+)?)"); + private static final Pattern POS_X = Pattern.compile("\"posX\":(-?\\d+(?:\\.\\d+)?(?:[eE][-+]?\\d+)?)"); + private static final Pattern POS_Z = Pattern.compile("\"posZ\":(-?\\d+(?:\\.\\d+)?(?:[eE][-+]?\\d+)?)"); @Override protected String subsystem() { diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/WorldCommandClientGroupE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/WorldCommandClientGroupE2ETest.java index 6a62ff299..ef7ccaeb7 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/client/WorldCommandClientGroupE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/client/WorldCommandClientGroupE2ETest.java @@ -60,11 +60,14 @@ public class WorldCommandClientGroupE2ETest extends AbstractSharedClientE2ETest private static final Pattern DIM_LINE = Pattern.compile("DIM(\\d+):"); private static final Pattern PLAYER_NAME = Pattern.compile("\"player\":\"([^\"]+)\""); private static final Pattern STATION_ID = Pattern.compile("\"id\":(-?\\d+)"); - private static final Pattern POS_X = Pattern.compile("\"posX\":(-?\\d+(?:\\.\\d+)?)"); + private static final Pattern POS_X = Pattern.compile("\"posX\":(-?\\d+(?:\\.\\d+)?(?:[eE][-+]?\\d+)?)"); /** 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"; @@ -238,7 +241,7 @@ public void arGotoTransfersPlayerToTargetDim() throws Exception { scenario().arranging("op the bot and generate a planet to travel to"); opTheBot(); String before = exec("ar planet list"); - exec("ar planet generate 0 GotoTarget 10 10 10"); + exec("ar planet generate 0 GotoTarget"); String after = exec("ar planet list"); int targetDim = newDimFromDiff(before, after); scenario().record("targetDim", targetDim); @@ -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"); + 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/client/WorldCommandFetchModeratorTest.java b/src/test/java/zmaster587/advancedRocketry/test/client/WorldCommandFetchModeratorTest.java index 54a7f2e0e..46f21a52f 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/client/WorldCommandFetchModeratorTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/client/WorldCommandFetchModeratorTest.java @@ -53,8 +53,8 @@ public class WorldCommandFetchModeratorTest { private static final String BOT1_NAME = "ModBot1"; private static final String BOT2_NAME = "ModBot2"; - private static final Pattern PLAYER_POS_X = Pattern.compile("\"playerPosX\":(-?\\d+(?:\\.\\d+)?)"); - private static final Pattern PLAYER_POS_Z = Pattern.compile("\"playerPosZ\":(-?\\d+(?:\\.\\d+)?)"); + private static final Pattern PLAYER_POS_X = Pattern.compile("\"playerPosX\":(-?\\d+(?:\\.\\d+)?(?:[eE][-+]?\\d+)?)"); + private static final Pattern PLAYER_POS_Z = Pattern.compile("\"playerPosZ\":(-?\\d+(?:\\.\\d+)?(?:[eE][-+]?\\d+)?)"); private RealDedicatedServerHarness server; private RealClientHarness bot1Harness; diff --git a/src/test/java/zmaster587/advancedRocketry/test/integration/SystemContentTest.java b/src/test/java/zmaster587/advancedRocketry/test/integration/SystemContentTest.java index c012eec9b..e801a1269 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/integration/SystemContentTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/integration/SystemContentTest.java @@ -10,9 +10,11 @@ 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; +import zmaster587.advancedRocketry.universe.ClusteredGalaxyGenerator; import zmaster587.advancedRocketry.universe.GalaxyGenConfig; import zmaster587.advancedRocketry.universe.SystemBody; import zmaster587.advancedRocketry.universe.SystemBodyKind; @@ -121,6 +123,71 @@ 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(GalaxyGenConfig.DEFAULT_MIN_SPACING, 1.0d, + GalaxyGenConfig.DEFAULT_GALAXY_SPACING, GalaxyGenConfig.DEFAULT_GALAXY_DENSITY, + null, null)); + long spacing = GalaxyGenConfig.DEFAULT_MIN_SPACING; + // SWEEP for an occupied super-cell rather than demanding one particular cube. Occupancy is a + // draw scaled by the galaxy's profile, so any single cube is a coin toss and a fixture that + // insists on one is testing the coin. + // It must be a seat with a STAR: the comparison is between one authored planet's orbit and one + // procedural planet's, and a starless system has no orbits at all to compare with. + // Asked what each TERRITORY holds, never what its corner point resolves to: the lattice is + // divided uniformly, so a point probe samples one seat in k-cubed and a sweep built on it + // reads a populated field as an almost empty one. + Optional seat = Optional.empty(); + for (long i = 1; i <= 16 && !seat.isPresent(); i++) { + for (GalacticCoord candidate : gen.anchorsInTerritory(0xBEEFL, + GalacticCoord.ofSectorLocal(i * spacing, spacing, spacing, 0L, 0L, 0L), 64)) { + if (gen.systemAt(0xBEEFL, candidate).get().star().isPresent()) { + seat = Optional.of(candidate); + break; + } + } + } + assertTrue("the fixture needs an occupied super-cell with a star in it", 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(); @@ -385,6 +452,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/integration/XMLPlanetLoaderTest.java b/src/test/java/zmaster587/advancedRocketry/test/integration/XMLPlanetLoaderTest.java index 97cea52f5..b4d4ebf7d 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/integration/XMLPlanetLoaderTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/integration/XMLPlanetLoaderTest.java @@ -16,7 +16,9 @@ import zmaster587.advancedRocketry.dimension.TerrainSource; import zmaster587.advancedRocketry.test.MinecraftBootstrap; import zmaster587.advancedRocketry.universe.ClusteredGalaxyGenerator; +import zmaster587.advancedRocketry.universe.GalacticAnchor; import zmaster587.advancedRocketry.universe.GalaxyGenConfig; +import zmaster587.advancedRocketry.universe.GalaxyKey; import zmaster587.advancedRocketry.universe.IGalaxyGenerator; import zmaster587.advancedRocketry.universe.UniverseRegistry; import zmaster587.advancedRocketry.util.XMLPlanetLoader; @@ -514,20 +516,151 @@ public void oreGenPropertiesSurviveWriteReadRoundTrip() throws Exception { @Test public void galaxyGenElementParsesIntoConfig() throws IOException { DimensionPropertyCoupling c = parse(galaxy( - "\n" + "\n" + " \n" + " \n" + "\n")); assertNotNull("a element must parse into a config", c.galaxyGenConfig); assertEquals(0.42d, c.galaxyGenConfig.density, 1e-9); assertEquals(7, c.galaxyGenConfig.minSpacing); - assertEquals(9, c.galaxyGenConfig.clusterScale); - assertEquals(0.3d, c.galaxyGenConfig.voidFraction, 1e-9); + assertEquals(900000L, c.galaxyGenConfig.galaxySpacing); + assertEquals(0.3d, c.galaxyGenConfig.galaxyDensity, 1e-9); + assertFalse("the stock galaxy archetypes stand in when XML declares none", + c.galaxyGenConfig.galaxyTypes.isEmpty()); assertEquals(2, c.galaxyGenConfig.starTypes.size()); assertEquals(55, c.galaxyGenConfig.starTypes.get(0).temperature); assertEquals(3, c.galaxyGenConfig.starTypes.get(0).weight); } + @Test + public void galaxyTypeChildrenReplaceTheStockTable() throws Exception { + // The one table a pack is most likely to want to touch, and the reason it is authorable at + // all: how flat a disc is has no derivation — it is a free parameter of the shape. + DimensionPropertyCoupling c = parse(galaxy( + "\n" + + " \n" + + "\n")); + assertNotNull(c.galaxyGenConfig); + assertEquals("one must REPLACE the stock table, not extend it", 1, + c.galaxyGenConfig.galaxyTypes.size()); + GalaxyGenConfig.GalaxyType t = c.galaxyGenConfig.galaxyTypes.get(0); + assertEquals("Fat Disc", t.name); + assertEquals(GalaxyGenConfig.GalaxyProfile.DISC, t.profile); + assertEquals(1000d, t.minRadiusLy, 1e-9); + assertEquals(1800d, t.maxRadiusLy, 1e-9); + assertEquals("disc thickness must be what the pack asked for", 0.25d, t.scaleHeightRatio, 1e-9); + assertEquals(3, t.armCount); + assertEquals(180d, t.rotationSpeedKmS, 1e-9); + assertEquals(0.2d, t.coreRadiusFraction, 1e-9); + assertEquals(5, t.weight); + } + + @Test + public void aPartiallySpecifiedGalaxyTypeInheritsTheSTOCKspiralNotAFrozenCopyOfIt() + throws Exception { + // The reason this test exists: the reader's defaults used to be literals — 900 / 2200 ly — and + // they went stale the moment the galaxy scale moved, so a pack that wrote only `thickness` got + // a "spiral" an order and a half under every real one, silently and only in the authored path. + // A pack writing one attribute must get the SHIPPED spiral for the rest. + GalaxyGenConfig.GalaxyType stock = GalaxyGenConfig.stockSpiral(); + DimensionPropertyCoupling c = parse(galaxy( + "\n" + + " \n" + + "\n")); + assertNotNull(c.galaxyGenConfig); + GalaxyGenConfig.GalaxyType t = c.galaxyGenConfig.galaxyTypes.get(0); + + assertEquals("thickness is what the pack asked for", 0.25d, t.scaleHeightRatio, 1e-9); + assertEquals("and the radius band is the SHIPPED spiral's", stock.minRadiusLy, + t.minRadiusLy, 1e-9); + assertEquals(stock.maxRadiusLy, t.maxRadiusLy, 1e-9); + assertEquals(stock.armCount, t.armCount); + assertEquals(stock.rotationSpeedKmS, t.rotationSpeedKmS, 1e-9); + assertEquals(stock.coreRadiusFraction, t.coreRadiusFraction, 1e-9); + assertEquals("weight is the deliberate exception: an unweighted type is the rarest", 1, + t.weight); + } + + @Test + public void galaxyTypesRoundTripThroughWriteXml() throws IOException { + // This file is REWRITTEN on every world save, so a table the writer does not emit is a table a + // pack silently loses the first time anybody saves. + GalaxyGenConfig parsed = parse(galaxy( + "\n" + + " \n" + + " \n" + + "\n")).galaxyGenConfig; + try { + UniverseRegistry.setGenerator(new ClusteredGalaxyGenerator(parsed)); + String written = XMLPlanetLoader.writeXML(DimensionManager.getInstance()); + File f = tempFolder.newFile(); + Files.write(f.toPath(), written.getBytes(StandardCharsets.UTF_8)); + XMLPlanetLoader loader = new XMLPlanetLoader(); + assertTrue(loader.loadFile(f)); + GalaxyGenConfig round = loader.readAllPlanets().galaxyGenConfig; + + assertNotNull(round); + assertEquals(2, round.galaxyTypes.size()); + assertEquals("Thin", round.galaxyTypes.get(0).name); + assertEquals(0.005d, round.galaxyTypes.get(0).scaleHeightRatio, 1e-9); + assertEquals(GalaxyGenConfig.GalaxyProfile.SPHEROID, round.galaxyTypes.get(1).profile); + assertEquals(11, round.galaxyTypes.get(1).weight); + } finally { + UniverseRegistry.setGenerator(null); + } + } + + @Test + public void anAuthoredAnchorIsDeclaredAgainstAGalaxy() throws Exception { + // A galaxy fills about three thousandths of a percent of its own lattice cell, so an absolute + // declaration would land in intergalactic space on virtually every seed. An unqualified + // declaration means `home`, which is what a pack that never thinks about galaxies gets. + DimensionPropertyCoupling c = parse(galaxy( + "\n" + + "\n" + + "\n")); + assertEquals(2, c.anchorCoords.size()); + GalacticAnchor sol = c.anchorCoords.get(c.stars.get(0).getId()); + GalacticAnchor far = c.anchorCoords.get(c.stars.get(1).getId()); + assertTrue("an unqualified anchor lives in the home galaxy", sol.galaxy().isHome()); + assertEquals(GalaxyKey.of(4L, -1L, 2L), far.galaxy()); + assertEquals(500L, far.local().sectorX()); + + assertEquals("every non-home galaxy an anchor named must be reserved", 1, + c.declaredGalaxies.size()); + assertTrue("and the config must carry it, so its cell is seated on every seed", + c.galaxyGenConfig.reservedGalaxies.contains(GalaxyKey.of(4L, -1L, 2L))); + } + + @Test + public void theWrittenCatalogueTellsAnAuthorWhatItCostsToEditIt() throws Exception { + // Both facts are otherwise discoverable only from source, and by then the damage is done: the + // system is already in the void, or the universe is already rerolled under a live save. The + // WRITER emits it, because this file is rewritten on every save and a shipped template would + // be replaced by the first one. + String written = XMLPlanetLoader.writeXML(DimensionManager.getInstance()); + assertTrue("the written catalogue must say an anchor is galaxy-local: " + written, + written.contains("GALAXY-LOCAL")); + assertTrue("and that the home galaxy always exists", + written.contains("home") && written.contains("800 light years")); + assertTrue("and that changing a generator parameter mid-save is undefined", + written.contains("UNDEFINED BEHAVIOUR")); + + // And it must still be a document that parses, or the notice would cost the catalogue. + File f = tempFolder.newFile(); + Files.write(f.toPath(), written.getBytes(StandardCharsets.UTF_8)); + XMLPlanetLoader loader = new XMLPlanetLoader(); + assertTrue("a catalogue carrying the notice must still load", loader.loadFile(f)); + assertNotNull(loader.readAllPlanets()); + } + @Test public void absentGalaxyGenLeavesConfigNull() throws IOException { DimensionPropertyCoupling c = parse(galaxy(star("Sol", ""))); @@ -537,7 +670,8 @@ public void absentGalaxyGenLeavesConfigNull() throws IOException { @Test public void galaxyGenRoundTripsThroughWriteXml() throws IOException { GalaxyGenConfig parsed = parse(galaxy( - "\n" + "\n" + " \n" + "\n")).galaxyGenConfig; @@ -554,8 +688,8 @@ public void galaxyGenRoundTripsThroughWriteXml() throws IOException { assertNotNull("the written galaxy must round-trip its ", round); assertEquals(0.25d, round.density, 1e-9); assertEquals(5, round.minSpacing); - assertEquals(12, round.clusterScale); - assertEquals(0.45d, round.voidFraction, 1e-9); + assertEquals(1200000L, round.galaxySpacing); + assertEquals(0.45d, round.galaxyDensity, 1e-9); assertEquals(60, round.starTypes.get(0).temperature); assertEquals(9, round.starTypes.get(0).weight); } finally { diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/AdvancementsTriggerTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/AdvancementsTriggerTest.java index d1d0c0399..4a2befc15 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/AdvancementsTriggerTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/AdvancementsTriggerTest.java @@ -6,6 +6,7 @@ import org.junit.Assume; import org.junit.Before; import org.junit.Test; +import zmaster587.advancedRocketry.test.ServerTicks; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -99,11 +100,10 @@ private void stationAndTick(int dim, double x, double y, double z, int ticks) th exec("artest chunk forceload " + dim + " " + (((int) x) >> 4) + " " + (((int) z) >> 4)); assertTrue("tick-living must succeed", exec("artest player tick-living " + ticks).contains("\"ok\":true")); - // Wait OFF the server thread: `artest server wait` runs inside a - // console command, i.e. ON the server thread — its sleep loop blocks - // ticking entirely. Sleeping in the test JVM lets the server - // free-run the requested ticks. - Thread.sleep(ticks * 50L + 500L); + // Wait OFF the server thread: a console command runs ON the server thread, so a probe that + // sleeps there blocks ticking entirely. The wait belongs in the test jvm — and it OBSERVES + // the world's clock rather than hoping for it, so a world that is not ticking says so. + ServerTicks.await(harness.client(), dim, ticks + 10); } private boolean isDone(String src) { diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/BeaconEnableCycleTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/BeaconEnableCycleTest.java index 7b6e0e268..85c5cbe2a 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/BeaconEnableCycleTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/BeaconEnableCycleTest.java @@ -70,7 +70,7 @@ public class BeaconEnableCycleTest extends AbstractSharedServerTest { @BeforeClass public static void generateSharedPlanet() throws Exception { Set before = arDims(); - exec("ar planet generate 0 BeaconPhase3 10 10 10"); + exec("ar planet generate 0 BeaconPhase3"); Set diff = arDims(); diff.removeAll(before); assertTrue("planet generate must add exactly one dim — diff=" + diff, diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/FreeFlightAssistsE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/FreeFlightAssistsE2ETest.java index 3a2461cd5..08eb2ec7d 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/FreeFlightAssistsE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/FreeFlightAssistsE2ETest.java @@ -204,6 +204,14 @@ public void yawingTheCraftRotatesTheCruiseVelocity() throws Exception { public void reEnablingFlightAssistCapturesTheCurrentVelocity() throws Exception { // Toggling FA back on mid-flight must NOT jerk the craft: the setpoint // initialises to the current velocity (Elite behaviour). + // + // AMENDED 2026-08-17. This test asserted the capture for a cruise ABOVE the assist's own + // ceiling, and that promise no longer exists: the acceleration law moved the ceiling ONTO the + // setpoint (FA_SETPOINT_MAX_SPEED), so re-engaging the assist above it deliberately decelerates + // the craft to it at the thrust budget rather than rewriting its velocity. The old assertion + // had been failing since that change landed and nobody read it — the cruise built here is 4.0 + // against a ceiling of 3.0. The capture is still the contract; it is now tested where the + // contract holds, and the clamp is tested beside it as its own leg. int id = buildAndAssemble(4350, 64, 500); ok(client().execute("artest rocket set-flight-mode " + id + " FREE_FLIGHT")); ok(client().execute("artest rocket start-free-flight " + id)); @@ -223,12 +231,17 @@ public void reEnablingFlightAssistCapturesTheCurrentVelocity() throws Exception // FA off, build a Newtonian cruise with direct thrust, then coast. ok(client().execute("artest rocket set-flight-assist " + id + " off")); ok(client().execute("artest rocket free-flight-input " + id + " 1 0 0 0 0")); - ok(client().execute("artest rocket free-flight-tick " + id + " 8")); + // Four ticks of thrust, not eight: 4 × 0.5 = 2.0 b/t, comfortably UNDER the assist ceiling, + // which is the regime where "capture the current velocity" is the promise. + ok(client().execute("artest rocket free-flight-tick " + id + " 4")); ok(client().execute("artest rocket free-flight-input " + id + " 0 0 0 0 0")); ok(client().execute("artest rocket free-flight-tick " + id + " 2")); double mzBefore = parseDouble(ok(client().execute("artest rocket info " + id)), Pattern.compile("\"motionZ\":(-?[0-9.E\\-]+)"), "motionZ"); assertTrue("precondition: must be coasting (+Z), got " + mzBefore, mzBefore > 0.2); + assertTrue("precondition: this leg tests the capture, so the cruise must be UNDER the assist " + + "ceiling (" + mzBefore + " vs 3.0) — above it the contract is the clamp below", + mzBefore < 3.0); // FA back on -> setpoint captured -> cruise continues, no jerk. ok(client().execute("artest rocket set-flight-assist " + id + " on")); @@ -239,6 +252,44 @@ public void reEnablingFlightAssistCapturesTheCurrentVelocity() throws Exception + mzAfter + ")", Math.abs(mzAfter - mzBefore) < 0.25); } + /** + * The other side of the same toggle, and the behaviour that replaced the old promise: re-engaging + * the assist on a craft flying FASTER than the assist's ceiling pulls it down to that ceiling — + * by thrusting against its motion, which is why it is a deceleration and not a rewrite. + */ + @Test + public void reEnablingFlightAssistAboveItsCeilingDeceleratesToTheCeiling() throws Exception { + int id = buildAndAssemble(4375, 64, 500); + ok(client().execute("artest rocket set-flight-mode " + id + " FREE_FLIGHT")); + ok(client().execute("artest rocket start-free-flight " + id)); + + // Climb clear of the ground, then hover, exactly as the capture leg does. + ok(client().execute("artest rocket free-flight-input " + id + " 0 1 0 0 0")); + ok(client().execute("artest rocket free-flight-tick " + id + " 60")); + ok(client().execute("artest rocket free-flight-input " + id + " 0 0 0 0 0 1")); + ok(client().execute("artest rocket free-flight-tick " + id + " 30")); + + // FA off, build a cruise well ABOVE the assist ceiling (8 × 0.5 = 4.0 against 3.0). + ok(client().execute("artest rocket set-flight-assist " + id + " off")); + ok(client().execute("artest rocket free-flight-input " + id + " 1 0 0 0 0")); + ok(client().execute("artest rocket free-flight-tick " + id + " 8")); + ok(client().execute("artest rocket free-flight-input " + id + " 0 0 0 0 0")); + ok(client().execute("artest rocket free-flight-tick " + id + " 2")); + double mzBefore = parseDouble(ok(client().execute("artest rocket info " + id)), + Pattern.compile("\"motionZ\":(-?[0-9.E\\-]+)"), "motionZ"); + assertTrue("precondition: the cruise must exceed the assist ceiling, got " + mzBefore, + mzBefore > 3.0); + + ok(client().execute("artest rocket set-flight-assist " + id + " on")); + ok(client().execute("artest rocket free-flight-tick " + id + " 20")); + double mzAfter = parseDouble(ok(client().execute("artest rocket info " + id)), + Pattern.compile("\"motionZ\":(-?[0-9.E\\-]+)"), "motionZ"); + assertTrue("the assist must bring an overfast craft DOWN toward its ceiling (was " + mzBefore + + ", now " + mzAfter + ")", mzAfter < mzBefore); + assertTrue("and must not overshoot below it — it tracks the ceiling, it does not brake to a " + + "halt (now " + mzAfter + ")", mzAfter > 2.0); + } + @Test public void flightAssistOffStillAcceptsExplicitBrake() throws Exception { // Cross-side wiring: FA=off + brake input still attenuates motion. diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/HyperdriveE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/HyperdriveE2ETest.java index b43726114..353d29c59 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/HyperdriveE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/HyperdriveE2ETest.java @@ -286,4 +286,77 @@ public void dampenersAreFoundAndReportPowered() throws Exception { assertEquals("and a dampener with power in its buffer is one that will protect somebody", 3L, field(info, "poweredDampeners")); } + + // ─── The bank is filled by the SHIP, not by the clock ────────────────────── + + /** Its own site: this family drains, feeds and unloads a bank, and must disturb nobody else. */ + private static final String SHIP_E = "2840 82 2840"; + + @Test + public void aFRESHBANKSTAYSEMPTYWHILETIMEPASSES() throws Exception { + // THE property the old model got wrong, asked of a real world with a real clock — which is the + // strongest form of the question, because the defect WAS the clock. The bank used to be a closed + // form of elapsed ticks, so the biggest cost in the family (the window burst, twenty times the + // drive's power) was paid for by waiting. A buffer nobody feeds must stay at nothing. + buildDrive(SHIP_E, 4, 8, 4, 0, 0); + exec("artest drive charge 0 " + SHIP_E + " empty"); + + long before = field(exec("artest drive info 0 " + SHIP_E), "charge"); + assertEquals("a drained bank starts empty", 0L, before); + + zmaster587.advancedRocketry.test.ServerTicks.await(client(), 0, 100); + + String after = exec("artest drive info 0 " + SHIP_E); + assertEquals("100 ticks of a running server must not have put a single unit into a bank that" + + " nothing is feeding: " + after, 0L, field(after, "charge")); + assertTrue("and it must still WANT charge, or this proves nothing", + field(after, "burstCost") > 0L); + } + + @Test + public void whatTheSHIPPUSHESINthroughItsGridIsWhatTheBankHolds() throws Exception { + // The positive half of the same wiring, and it goes through the real Forge Energy capability — + // the same one an adjacent reactor, array or cable pushes into — rather than through the + // fixture seam that sets the level directly. + buildDrive(SHIP_E, 4, 8, 4, 0, 0); + exec("artest drive charge 0 " + SHIP_E + " empty"); + + String pushed = exec("artest drive push 0 " + SHIP_E + " 1000000000"); + assertTrue("the bank must expose an energy port for the ship to push into: " + pushed, + field(pushed, "ports") > 0L); + long accepted = field(pushed, "accepted"); + assertTrue("and it must have taken some of it: " + pushed, accepted > 0L); + assertEquals("what it took is what it holds", accepted, + field(exec("artest drive info 0 " + SHIP_E), "charge")); + + // One push is one tick's worth: the accept rate is a THROUGHPUT ceiling, so a billion offered + // at once does not fill a bank that a hundred pushes would. + long capacity = field(exec("artest drive info 0 " + SHIP_E), "capacity"); + assertTrue("a single tick of inflow must not fill the whole bank (" + accepted + " of " + + capacity + ")", capacity <= 0L || accepted < capacity); + + String again = exec("artest drive push 0 " + SHIP_E + " 1000000000"); + assertTrue("a second push must add more", field(again, "charge") > accepted); + } + + @Test + public void aBanksChargeSurvivesAREALunloadAndReload() throws Exception { + // The write half of the persistence contract, which only a real save can exercise: a + // force-loaded chunk never leaves memory, so a test against one proves the object was not + // collected rather than that its NBT round-trips. `chunk cycle` saves, drops and reads back. + buildDrive(SHIP_E, 4, 8, 4, 0, 0); + exec("artest drive charge 0 " + SHIP_E + " full"); + long before = field(exec("artest drive info 0 " + SHIP_E), "charge"); + assertTrue("the fixture needs a bank with something in it", before > 0L); + + int cx = 2840 >> 4; + int cz = 2840 >> 4; + String cycled = exec("artest chunk cycle 0 " + cx + " " + cz); + assertTrue("the chunk must really have left memory, or nothing was read back from disk: " + + cycled, cycled.contains("\"dropped\":true")); + + String after = exec("artest drive info 0 " + SHIP_E); + assertEquals("a bank that came back from disk holds what it held: " + after, before, + field(after, "charge")); + } } diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/HyperspaceSurvivesARestartE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/HyperspaceSurvivesARestartE2ETest.java index d537cff06..5261ac41b 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/HyperspaceSurvivesARestartE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/HyperspaceSurvivesARestartE2ETest.java @@ -158,7 +158,7 @@ public void aShipParkedInHyperspaceIsStillThereAfterTheServerRestarts() throws E assertTrue("the departure crossing must put the ship into hyperspace: " + begin, readBool(begin, "began")); - String tick = exec("artest space transit-tick"); + String tick = exec("artest space transit-tick 10"); int hyperDimBefore = readInt(tick, "hyperDim"); int inTransit = readInt(tick, "inTransit"); assertTrue("ARRANGEMENT: the jump must still be in flight when the server goes down, or" @@ -213,7 +213,7 @@ public void aShipParkedInHyperspaceIsStillThereAfterTheServerRestarts() throws E String setupAfter = exec("artest space transit-setup-piloted"); assertTrue("the transit probe stack must come up on boot 2: " + setupAfter, readBool(setupAfter, "ok")); - int hyperDimAfter = readInt(exec("artest space transit-tick"), "hyperDim"); + int hyperDimAfter = readInt(exec("artest space transit-tick 10"), "hyperDim"); int parkedAfter = readIntOr(exec("artest vs ship-count-all " + hyperDimAfter), "count", -1); assertEquals("a ship parked in hyperspace must still be parked in hyperspace after a real" diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/InterstellarJumpLegE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/InterstellarJumpLegE2ETest.java index 0a6f7876d..12a0708c0 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/InterstellarJumpLegE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/InterstellarJumpLegE2ETest.java @@ -254,7 +254,7 @@ private static int extractInt(String json, String key) { } private static double extractDouble(String json, String key) { - Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+(?:\\.\\d+)?)").matcher(json); + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+(?:\\.\\d+)?(?:[eE][-+]?\\d+)?)").matcher(json); return m.find() ? Double.parseDouble(m.group(1)) : 0.0; } diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/LowGravFallDamageTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/LowGravFallDamageTest.java index 37bb5423b..cef061210 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/LowGravFallDamageTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/LowGravFallDamageTest.java @@ -6,6 +6,7 @@ import org.junit.Assume; import org.junit.Before; import org.junit.Test; +import zmaster587.advancedRocketry.test.ServerTicks; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -29,9 +30,9 @@ public class LowGravFallDamageTest { private static final int DIM_LOW_GRAV = 9701; private static final Pattern IS_PLANETARY = Pattern.compile("\"isPlanetaryProvider\":(true|false)"); - private static final Pattern INPUT_DIST = Pattern.compile("\"inputDistance\":(-?\\d+(?:\\.\\d+)?)"); - private static final Pattern RESULT_DIST = Pattern.compile("\"resultDistance\":(-?\\d+(?:\\.\\d+)?)"); - private static final Pattern GRAVITY = Pattern.compile("\"gravityMultiplier\":(-?\\d+(?:\\.\\d+)?)"); + private static final Pattern INPUT_DIST = Pattern.compile("\"inputDistance\":(-?\\d+(?:\\.\\d+)?(?:[eE][-+]?\\d+)?)"); + private static final Pattern RESULT_DIST = Pattern.compile("\"resultDistance\":(-?\\d+(?:\\.\\d+)?(?:[eE][-+]?\\d+)?)"); + private static final Pattern GRAVITY = Pattern.compile("\"gravityMultiplier\":(-?\\d+(?:\\.\\d+)?(?:[eE][-+]?\\d+)?)"); private Path workDir; private RealDedicatedServerHarness harness; @@ -83,9 +84,9 @@ private String exec(String cmd) throws Exception { private void stationFake(int dim) throws Exception { String fake = exec("artest player ensure-fake " + dim + " 8.5 120 8.5"); assertTrue("ensure-fake must succeed: " + fake, fake.contains("\"ok\":true")); - // Off-thread settle (see AdvancementsTriggerTest: `artest server wait` - // blocks the server thread and must not be used to advance ticks). - Thread.sleep(1000L); + // Off-thread settle: the wait runs in the test jvm, because a command handler runs on the + // server thread and would block the clock it is waiting for. + ServerTicks.await(harness.client(), dim, 20); } /** Overworld: not an IPlanetaryProvider → distance untouched. */ diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/MixinHookBehaviourPinsTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/MixinHookBehaviourPinsTest.java index 66945234a..589b18c2d 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/MixinHookBehaviourPinsTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/MixinHookBehaviourPinsTest.java @@ -176,7 +176,7 @@ private double doubleField(Pattern p, String src, String fieldName) { * *

    Robust against the dedicated-server harness's idiosyncratic * tick scheduling, which doesn't reliably advance entity onUpdate - * during {@code /artest server wait} on a cold server.

    + * within a bounded wait on a cold server.

    */ private double tickEntityAndReadMotionY(int dim, int id, int count) throws Exception { String resp = ok(client().execute( diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/NebulaSkyFeedE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/NebulaSkyFeedE2ETest.java new file mode 100644 index 000000000..6ca60f184 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/NebulaSkyFeedE2ETest.java @@ -0,0 +1,192 @@ +package zmaster587.advancedRocketry.test.server; + +import com.github.stannismod.forge.testing.junit.AbstractHeadlessServerTest; + +import org.junit.After; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * What the server tells a cell's sky about the clouds around it, driven on a real server. + * + *

    The unit tier pins the geometry — which way a cloud lies, how big it looks, what is filtered out. + * This pins the thing that tier cannot see: that a real generator in a real world actually SEATS + * clouds, and that the reply a client would be sent is derived from that world's own seed rather than + * from anything a test arranged.

    + * + *

    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 NebulaSkyFeedE2ETest extends AbstractHeadlessServerTest { + + /** A dense galaxy so a bounded sweep finds a cluster, at the shipped star spacing. */ + private static final String GEN_INSTALL = "artest space gen-install 0.9 8 987654321"; + + 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) { + } + } + + private static long field(String json, String name) { + String key = "\"" + name + "\":"; + int at = json.indexOf(key); + assertTrue("probe reply has no field " + name + ": " + json, at >= 0); + int from = at + key.length(); + int to = from; + while (to < json.length() && "-0123456789".indexOf(json.charAt(to)) >= 0) { + to++; + } + return Long.parseLong(json.substring(from, to)); + } + + @Test + public void aGalaxyWithClustersInItHasCloudsToLookAt() throws Exception { + String installed = exec(GEN_INSTALL); + assertTrue("the procedural generator must install: " + installed, + installed.contains("\"ok\":true")); + + String found = exec("artest space nebula-find 512 64"); + assertTrue("a dense galaxy must have a cloud somewhere in it: " + found, + found.contains("\"found\":true")); + + long sectorX = field(found, "sectorX"); + String feed = exec("artest space nebulae " + sectorX + " 0 0"); + assertTrue("the cell the finder named must report its sky: " + feed, feed.contains("\"ok\":true")); + assertTrue("and that sky must hold the cloud the finder found: " + feed, + field(feed, "drawn") >= 1); + assertTrue("a cloud that is drawn must cover something of the sky: " + feed, + feed.contains("\"angularRadius\":")); + } + + @Test + public void withoutAProceduralGeneratorTheSkyIsEmptyRatherThanInvented() throws Exception { + // The negative leg, and it is the one that matters: an authored-only pack has no galaxies, so + // it has no clusters and no gas. A feed that produced a cloud here would be producing it from + // nothing — and a landmark nobody generated is worse than no landmark. + String reset = exec("artest space gen-reset"); + assertTrue("the default generator must be restorable: " + reset, reset.contains("\"ok\":true")); + + String feed = exec("artest space nebulae 0 0 0"); + assertTrue("the probe must still answer: " + feed, feed.contains("\"ok\":true")); + assertEquals("a universe with no clusters must seat no clouds: " + feed, 0L, + field(feed, "seated")); + assertEquals("and must draw none: " + feed, 0L, field(feed, "drawn")); + } + + /** The value of a decimal JSON field in a probe reply. */ + private static double decimal(String json, String name) { + String key = "\"" + name + "\":"; + int at = json.indexOf(key); + assertTrue("probe reply has no field " + name + ": " + json, at >= 0); + int from = at + key.length(); + int to = from; + while (to < json.length() && "-+.eE0123456789".indexOf(json.charAt(to)) >= 0) { + to++; + } + return Double.parseDouble(json.substring(from, to)); + } + + @Test + public void aRealCloudDimsWhatIsBehindItAndClearSpaceDoesNot() throws Exception { + // What the unit tier cannot reach: it stubs the column, so it can prove the RULE and never + // that a generated cloud produces a column at all. This walks a real sight line through a + // real cloud in a real world, and a clear line beside it as the control. + String installed = exec(GEN_INSTALL); + assertTrue("the procedural generator must install: " + installed, + installed.contains("\"ok\":true")); + + String found = exec("artest space nebula-find 512 64"); + assertTrue("a dense galaxy must have a cloud somewhere in it: " + found, + found.contains("\"found\":true")); + // A sight line THROUGH the cloud's core: from two radii short of its centre to two radii + // past it, along X. Built from where the generator says the cloud IS — the first version of + // this used the cell the finder was standing in, which was the origin, so the "line" had + // zero length and measured nothing. + long centreX = field(found, "centreX"); + long centreY = field(found, "centreY"); + long centreZ = field(found, "centreZ"); + long radius = field(found, "radiusCells"); + String near = (centreX - 2 * radius) + " " + centreY + " " + centreZ; + String far = (centreX + 2 * radius) + " " + centreY + " " + centreZ; + + String through = exec("artest space extinction " + near + " " + far); + assertTrue("the probe must answer for a real sight line: " + through, + through.contains("\"ok\":true")); + assertTrue("a line that reaches a cloud's neighbourhood must cross SOME matter: " + through, + decimal(through, "column") > 0d); + assertTrue("and the magnitudes must follow the column, not be invented: " + through, + decimal(through, "magnitudes") > 0d); + + // The control: no generator, hence no clusters, hence nothing to cross. + String reset = exec("artest space gen-reset"); + assertTrue("the default generator must be restorable: " + reset, reset.contains("\"ok\":true")); + String clear = exec("artest space extinction " + near + " " + far); + assertEquals("a universe with no clouds must dim nothing: " + clear, 0d, + decimal(clear, "magnitudes"), 1.0E-9d); + } + + @Test + public void theConcealmentThresholdCanBeTurnedOff() throws Exception { + // Driven on the real config: a flag has to REMOVE its mechanic rather than soften it, and + // the reading it is judged against is unchanged either way. + String installed = exec(GEN_INSTALL); + assertTrue("the procedural generator must install: " + installed, + installed.contains("\"ok\":true")); + String found = exec("artest space nebula-find 512 64"); + assertTrue("a dense galaxy must have a cloud somewhere in it: " + found, + found.contains("\"found\":true")); + // A sight line THROUGH the cloud's core: from two radii short of its centre to two radii + // past it, along X. Built from where the generator says the cloud IS — the first version of + // this used the cell the finder was standing in, which was the origin, so the "line" had + // zero length and measured nothing. + long centreX = field(found, "centreX"); + long centreY = field(found, "centreY"); + long centreZ = field(found, "centreZ"); + long radius = field(found, "radiusCells"); + String near = (centreX - 2 * radius) + " " + centreY + " " + centreZ; + String far = (centreX + 2 * radius) + " " + centreY + " " + centreZ; + + try { + exec("artest config set telescopeObscuredAtMagnitudes 0.0001"); + String strict = exec("artest space extinction " + near + " " + far); + assertTrue("at a threshold below the real reading the line must count as obscured: " + + strict, strict.contains("\"obscured\":true")); + + exec("artest config set telescopeObscuredAtMagnitudes 0"); + String off = exec("artest space extinction " + near + " " + far); + assertTrue("with the mechanic off nothing is obscured: " + off, + off.contains("\"obscured\":false")); + assertTrue("and the dust itself is still measured — the flag removes the RULE, not the" + + " physics: " + off, decimal(off, "magnitudes") > 0d); + } finally { + exec("artest config set telescopeObscuredAtMagnitudes 5"); + } + } + + @Test + public void whatIsSeatedAndWhatIsDrawnAreReportedSeparately() throws Exception { + // So a reader can tell a working level-of-detail filter from a missing cloud. Without the two + // numbers side by side, "the sky shows one" and "there is one out there" are the same reading, + // and a filter doing its job would be indistinguishable from a generator that stopped seating. + String installed = exec(GEN_INSTALL); + assertTrue("the procedural generator must install: " + installed, + installed.contains("\"ok\":true")); + + String found = exec("artest space nebula-find 512 64"); + assertTrue("a dense galaxy must have a cloud somewhere in it: " + found, + found.contains("\"found\":true")); + String feed = exec("artest space nebulae " + field(found, "sectorX") + " 0 0"); + + assertTrue("what is drawn may never exceed what is seated: " + feed, + field(feed, "drawn") <= field(feed, "seated")); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/ParkedShipKeepsItsBodiesE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/ParkedShipKeepsItsBodiesE2ETest.java index e3a535995..af303de59 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/ParkedShipKeepsItsBodiesE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/ParkedShipKeepsItsBodiesE2ETest.java @@ -74,7 +74,6 @@ public void aBodyStaysInItsOwnCellAcrossAVeryLongDwell() throws Exception { before.contains("\"dim\":" + WATCHED_DIM + ",\"kind\"")); String frameBefore = exec("artest space frame " + cellArgs); - long originBefore = jsonLong(frameBefore, "originX"); long clockBefore = jsonLong(frameBefore, "clock"); String after; @@ -106,15 +105,19 @@ public void aBodyStaysInItsOwnCellAcrossAVeryLongDwell() throws Exception { exec("artest space set-clock " + clockBefore); } - // THE CONTROL. - long originAfter = jsonLong(frameAfter, "originX"); + // THE CONTROL. The frame's origin is reported as a SECTOR triple plus an in-cell offset, so the + // move has to be reassembled from both: the probe never emitted a flat "originX", and reading + // one asserted nothing while looking like it asserted everything — this leg failed with "no + // numeric originX" rather than with anything about the universe, and had done so silently. + long movedX = frameMoveX(frameBefore, frameAfter); assertNotEquals("the cell's FRAME must have moved over " + AGE_TICKS + " ticks, or the" + " invariance below is a statement about a universe that stands still; before=" - + frameBefore + " after=" + frameAfter, originBefore, originAfter); - assertTrue("...and moved FAR — a cell is 4,000,000 blocks wide, so a smaller move would not" - + " even have left the cell under the old derivation; moved=" - + Math.abs(originAfter - originBefore), - Math.abs(originAfter - originBefore) > 4_000_000L); + + frameBefore + " after=" + frameAfter, 0L, movedX); + assertTrue("...and moved FAR — further than a whole cell (" + + zmaster587.advancedRocketry.space.GalacticCoord.CELL + + " blocks), so the frame cannot be said to have merely drifted inside one;" + + " moved=" + Math.abs(movedX), + Math.abs(movedX) > zmaster587.advancedRocketry.space.GalacticCoord.CELL); // THE CLAUSE. Same cell key, same occupants, same count. assertEquals("a body's own cell may not change because time passed (ledger #143): " + after, @@ -136,6 +139,29 @@ public void aBodyStaysInItsOwnCellAcrossAVeryLongDwell() throws Exception { // --- helpers --------------------------------------------------------------------------------- + /** + * How far the cell frame's origin moved along X between two {@code space frame} replies, in + * blocks. The reply carries {@code originSector} and {@code originOffset}, and the answer needs + * both — a frame that crossed a cell face has a small offset delta and a whole cell of real + * movement hiding in the sector. + */ + private static long frameMoveX(String before, String after) { + long sectorDelta = jsonArrayElement(after, "originSector", 0) + - jsonArrayElement(before, "originSector", 0); + long offsetDelta = jsonArrayElement(after, "originOffset", 0) + - jsonArrayElement(before, "originOffset", 0); + return sectorDelta * zmaster587.advancedRocketry.space.GalacticCoord.CELL + offsetDelta; + } + + /** Element {@code index} of a numeric JSON array field. */ + private static long jsonArrayElement(String json, String field, int index) { + Matcher m = Pattern.compile("\"" + Pattern.quote(field) + "\":\\[([^\\]]*)\\]").matcher(json); + assertTrue("probe response carries no \"" + field + "\" array: " + json, m.find()); + String[] parts = m.group(1).split(","); + assertTrue("\"" + field + "\" has no element " + index + ": " + json, parts.length > index); + return Long.parseLong(parts[index].trim()); + } + private static String dimCell(String json) { Matcher m = Pattern.compile("\"dimCell\":\"([^\"]+)\"").matcher(json); assertTrue("probe response carries no \"dimCell\": " + json, m.find()); diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/PlanetGenerateMoonNullStarTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/PlanetGenerateMoonNullStarTest.java deleted file mode 100644 index 4d24acb81..000000000 --- a/src/test/java/zmaster587/advancedRocketry/test/server/PlanetGenerateMoonNullStarTest.java +++ /dev/null @@ -1,82 +0,0 @@ -package zmaster587.advancedRocketry.test.server; - -import org.junit.Test; - -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * MED batch pack 4 — C072 reproduction + regression guard. - * - *

    Contract under test: {@code /advancedrocketry planet generate moon …} - * must fail with a clean {@code CommandException} — not an unguarded - * {@link NullPointerException} — when the parent planet's star id resolves to no - * star. The non-moon path guards {@code getStar(id) == null} - * ({@code PlanetGenerateCommand} else-if), but the moon path re-derives the star - * id from the parent planet and skips that guard, then feeds it to - * {@code generateRandom} (which dereferences {@code getStar}) — an op-only command - * crash.

    - * - *

    The probe drives the REAL command's {@code execute} against a planet whose - * star has been temporarily orphaned, and reports the thrown type. Pre-fix it is - * a {@code NullPointerException}; post-fix a star-existence guard on the moon - * branch throws {@code CommandException} before any generation, and no dimension - * is registered in either case.

    - */ -public class PlanetGenerateMoonNullStarTest extends AbstractSharedServerTest { - - private static final Pattern AR_DIMS = Pattern.compile("\"arDimensions\":\\[([^\\]]*)]"); - private static final Pattern THROWN = Pattern.compile("\"thrown\":\"([^\"]*)\""); - private static final Pattern DIMS_BEFORE = Pattern.compile("\"dimsBefore\":(-?\\d+)"); - private static final Pattern DIMS_AFTER = Pattern.compile("\"dimsAfter\":(-?\\d+)"); - - private static String ok(java.util.List resp) { - return String.join("\n", resp); - } - - /** Pick a registered AR planet dimension (positive id, non-overworld) to - * serve as the parent planet for the moon-generate. */ - private int anyArPlanetDim() throws Exception { - String list = ok(client().execute("artest dim list")); - Matcher m = AR_DIMS.matcher(list); - assertTrue("dim list missing arDimensions: " + list, m.find()); - String[] ids = m.group(1).split(","); - for (String id : ids) { - String s = id.trim(); - if (s.isEmpty()) continue; - int d = Integer.parseInt(s); - if (d > 0) return d; - } - throw new IllegalStateException("no positive AR planet dim in: " + list); - } - - @Test - public void moonGenerateWithOrphanStarThrowsCleanlyNotNpe() throws Exception { - int planetDim = anyArPlanetDim(); - - String resp = ok(client().execute("artest planet moon-generate-catch " + planetDim)); - assertTrue("moon-generate-catch failed: " + resp, resp.contains("\"ok\":true")); - - Matcher tm = THROWN.matcher(resp); - assertTrue("thrown field missing: " + resp, tm.find()); - String thrown = tm.group(1); - assertFalse("generating a moon for a planet whose star resolves to no star " - + "must not NPE (C072); got " + thrown + ": " + resp, - "NullPointerException".equals(thrown)); - assertTrue("the moon-generate must fail with a clean CommandException, " - + "got " + thrown + ": " + resp, - "CommandException".equals(thrown)); - - // The guard must fire before any generation — no dimension registered. - Matcher bm = DIMS_BEFORE.matcher(resp); - Matcher am = DIMS_AFTER.matcher(resp); - assertTrue("dimsBefore missing: " + resp, bm.find()); - assertTrue("dimsAfter missing: " + resp, am.find()); - assertTrue("no dimension may be registered when the guard rejects the " - + "command: " + resp, - Integer.parseInt(bm.group(1)) == Integer.parseInt(am.group(1))); - } -} 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/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..cee7804a3 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/ProceduralPlanetRealizationE2ETest.java @@ -0,0 +1,178 @@ +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:

    + * + *
      + *
    1. 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.
    2. + *
    3. Realization is idempotent. The trigger is a per-tick proximity check, so a second ask + * must reuse the world rather than mint another.
    4. + *
    5. 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.
    6. + *
    + * + *

    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 compact star spacing, and it has a floor: a system's bodies stand where their own orbits put + * them, so a super-cell has to be wide enough to hold one. Below roughly 170 000 cells a system + * starts losing its outer worlds and below a few cells only the star survives — which is a correct + * outcome of "a system that will not fit loses BODIES, never scale", and a fixture with no landable + * body in it. What is compact here is the distance BETWEEN stars, so a bounded sweep finds several. + * + *

    The spacing is a balance knob and nothing here asserts one.

    + */ + private static final String GEN_INSTALL = "artest space gen-install 0.9 2000000 987654321"; + /** In SUPER-CELLS: the probe sweeps the partition the generator itself walks. */ + private static final int SWEEP_RADIUS = 4; + + 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/server/RocketDescentLandingTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/RocketDescentLandingTest.java index 8ab50e233..1efd08d88 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/RocketDescentLandingTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/RocketDescentLandingTest.java @@ -1,6 +1,7 @@ package zmaster587.advancedRocketry.test.server; import org.junit.Test; +import zmaster587.advancedRocketry.test.ServerTicks; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -25,10 +26,11 @@ * (registered via {@code WorldEvents} mod-side, dispensed by the new * {@code /artest chunk forceload} probe). Holding the chunk hot lets * the headless dedicated server tick the rocket entity through its - * production code paths exactly as a real game session would. The - * {@code /artest server wait } probe blocks the test - * thread until {@code worldserver.getTotalWorldTime()} has advanced by - * the requested number of ticks. + * production code paths exactly as a real game session would. + * {@link zmaster587.advancedRocketry.test.ServerTicks#await} blocks the + * test thread until the world's own clock has advanced by the requested + * number of ticks — the waiting happens in the test jvm, because a + * command handler runs on the very thread that advances that clock. * *

    Test method names suffixed {@code _realTick} to make it explicit * which path is exercised. @@ -123,7 +125,7 @@ public void descentTimerGateFlipsInFlightUnderRealTicks_realTick() throws Except // Setup under REAL server ticking: // - assemble + force-load the rocket's chunk // - state: orbit=true, flight=false, ticksExisted=DESCENT_TIMER+1 - // - server wait 5 ticks -> onUpdate runs at least once -> + // - await 5 real ticks -> onUpdate runs at least once -> // gate fires -> isInFlight flips to true. int baseX = 6100; int baseZ = 500; @@ -134,7 +136,7 @@ public void descentTimerGateFlipsInFlightUnderRealTicks_realTick() throws Except + " orbit=true flight=false ticksExisted=" + (DESCENT_TIMER + 1) + " posY=300 motionY=0")); - ok(client().execute("artest server wait 0 5")); + ServerTicks.await(client(), 0, 5); String info = ok(client().execute("artest rocket info " + id)); assertTrue("descent gate must flip isInFlight under real ticking: " + info, @@ -154,7 +156,7 @@ public void tickBeforeDescentTimerKeepsFlightOff_realTick() throws Exception { ok(client().execute("artest rocket set-state " + id + " orbit=true flight=false ticksExisted=5 posY=300 motionY=0")); - ok(client().execute("artest server wait 0 5")); + ServerTicks.await(client(), 0, 5); String info = ok(client().execute("artest rocket info " + id)); // ticksExisted will have advanced by up to ~5 under real ticking; @@ -182,7 +184,7 @@ public void inFlightDescentApplesGravityUnderRealTicks_realTick() throws Excepti + " orbit=true flight=true ticksExisted=" + (DESCENT_TIMER + 5) + " posY=300 motionY=0")); - ok(client().execute("artest server wait 0 5")); + ServerTicks.await(client(), 0, 5); String info = ok(client().execute("artest rocket info " + id)); Matcher m = POS_Y_FIELD.matcher(info); @@ -216,7 +218,7 @@ public void landedEventFiresOnGroundCollisionUnderRealTicks_realTick() throws Ex + " orbit=true flight=true ticksExisted=" + (DESCENT_TIMER + 5) + " posY=" + (baseY + 2) + " motionY=-10")); - ok(client().execute("artest server wait 0 6")); + ServerTicks.await(client(), 0, 6); String countsAfter = ok(client().execute("artest rocket event-counts-full")); int landedAfter = gi(LANDED_COUNT, countsAfter, "landed after"); diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/RocketEventPayloadContractTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/RocketEventPayloadContractTest.java index 06a2beb17..e89cde5ae 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/RocketEventPayloadContractTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/RocketEventPayloadContractTest.java @@ -2,6 +2,8 @@ import org.junit.Test; +import zmaster587.advancedRocketry.test.ServerTicks; + import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -143,7 +145,7 @@ public void rocketLandedEventCarriesRocketEntityAndWorld() throws Exception { exec("artest rocket set-state " + rocketId + " orbit=true flight=true ticksExisted=" + (DESCENT_TIMER + 5) + " posY=" + (CY + 2) + " motionY=-10"); - exec("artest server wait 0 6"); + ServerTicks.await(client(), 0, 6); String countsAfter = exec("artest rocket event-counts-full"); int landedAfter = extract(countsAfter, LANDED_COUNT); @@ -194,7 +196,7 @@ public void rocketDeOrbitingEventCarriesRocketEntityAndWorld() throws Exception // event. exec("artest rocket set-state " + rocketId + " orbit=true flight=false ticksExisted=18 posY=300 motionY=0"); - exec("artest server wait 0 3"); + ServerTicks.await(client(), 0, 3); String countsAfter = exec("artest rocket event-counts-full"); int deOrbitAfter = extract(countsAfter, DEORBIT_COUNT); diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/ServerWaitProbeReportsRealTicksTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/ServerWaitProbeReportsRealTicksTest.java new file mode 100644 index 000000000..97911c171 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/ServerWaitProbeReportsRealTicksTest.java @@ -0,0 +1,100 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Test; +import zmaster587.advancedRocketry.test.ServerTicks; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertTrue; + +/** + * Does {@code artest server wait} measure the WORLD, or does it measure itself? + * + *

    Measured 2026-08-17 on a space slot world: {@code wait 60} reported + * {@code elapsedTicks=0} after 12 s of wall clock, and a test built on that reading spent two + * revisions hunting a crossing bug that did not exist. Two causes fit equally: the slot world really + * does not tick (headless, no player, no ticking chunks), or the probe polls + * {@code getTotalWorldTime()} from the command — i.e. on the server thread, the one thread that + * advances it — and so blocks its own subject.

    + * + *

    This is that discriminator, asked of a world nobody doubts. Green = the probe reports real + * elapsed ticks on a ticking world, so a zero elsewhere is a fact about that world. Red = the + * probe cannot observe ticks at all and every reading it has ever produced is its own reflection.

    + * + *

    It stays in the suite rather than being deleted with its answer: what it pins is a HARNESS + * contract that other tests read as ground truth, and the day it starts failing is the day those + * tests begin measuring nothing.

    + */ +public class ServerWaitProbeReportsRealTicksTest extends AbstractSharedServerTest { + + /** Small enough to stay fast, large enough that a scheduler hiccup cannot fake it. */ + private static final int TICKS = 20; + + /** + * ANSWERED 2026-08-17: it measures itself. On the OVERWORLD — a world that ticks by definition — + * the probe reported zero elapsed ticks, so the handler runs on the very thread that advances the + * clock and can never see it move. Every "wait N ticks" in the suite has been a sleep. + * + *

    What this test pins now is therefore not "the clock advances" (it cannot, until the probe is + * rebuilt) but the property that keeps the next reader out of the same hole: the probe must SAY + * that it did not advance. A reply claiming success with no such field is what cost this + * session two wrong diagnoses.

    + */ + @Test + public void theWaitProbeNeverClaimsTicksItDidNotObserve() throws Exception { + String reply = exec("artest server wait 0 " + TICKS); + assertTrue("the wait probe failed on the overworld: " + reply, reply.contains("\"requested\"")); + + int elapsed = extractInt(reply, "elapsedTicks"); + boolean claimsAdvanced = reply.contains("\"advanced\":true"); + if (elapsed >= TICKS) { + assertTrue("the clock DID advance, so the probe must say so — a real wait that reports " + + "itself as a non-wait is the same defect mirrored: " + reply, claimsAdvanced); + return; + } + assertTrue("the probe returned fewer ticks than asked and must not report that as a wait: " + + reply, reply.contains("\"advanced\":false")); + assertTrue("and it must name what to do instead, or the next caller repeats the mistake: " + + reply, reply.contains("\"hint\"")); + } + + /** + * The other half of the same contract: a test that asks for N ticks must be able to SEE the + * world's own clock move by N. The probe above cannot deliver that from the server thread, so the + * waiting lives in the test jvm ({@link ServerTicks}) and this is its acceptance — asked, again, + * of a world whose answer is not in doubt. + * + *

    Note what is asserted and what is not: the clock advanced by at least what was asked. Not + * how long it took, not that it stopped there. A wall-clock pin here would be a test of this + * machine's load, which is the very confusion the task exists to end.

    + */ + @Test + public void aTestSideWaitAdvancesTheWorldsOwnClock() throws Exception { + // The premise, measured rather than asserted in a comment: the handler answering this runs on + // the thread that advances the clock. That is WHY the wait cannot live in a probe, and it was + // once written down here the other way round and believed for months. + String clock = exec("artest server tick-count 0"); + assertTrue("a probe handler must report that it runs on the server thread — if this ever " + + "flips, a probe-side wait becomes possible and ServerTicks can be retired: " + clock, + clock.contains("\"onServerThread\":true")); + + long before = ServerTicks.count(client(), 0); + long observed = ServerTicks.await(client(), 0, TICKS); + long after = ServerTicks.count(client(), 0); + + assertTrue("the wait reported " + observed + " ticks but was asked for " + TICKS + + " — a wait may never return short", observed >= TICKS); + assertTrue("the overworld clock must have moved by at least " + TICKS + " ticks across the " + + "wait, but went " + before + " -> " + after, after - before >= TICKS); + } + + private String exec(String cmd) throws Exception { + return String.join("\n", client().execute(cmd)); + } + + private static int extractInt(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+)").matcher(json); + return m.find() ? Integer.parseInt(m.group(1)) : Integer.MIN_VALUE; + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/ShipArrivalKeepsItsPilotSeatInASuperheatedAtmosphereTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/ShipArrivalKeepsItsPilotSeatInASuperheatedAtmosphereTest.java index 4f89b1a89..6f9ffc369 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/ShipArrivalKeepsItsPilotSeatInASuperheatedAtmosphereTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/ShipArrivalKeepsItsPilotSeatInASuperheatedAtmosphereTest.java @@ -215,7 +215,7 @@ private static int extractInt(String json, String key) { } private static double extractDouble(String json, String key) { - Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+(?:\\.\\d+)?)").matcher(json); + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+(?:\\.\\d+)?(?:[eE][-+]?\\d+)?)").matcher(json); return m.find() ? Double.parseDouble(m.group(1)) : 0.0; } } 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..e4e4d47d5 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/SpikeFarCoordinateIntegrityTest.java @@ -0,0 +1,154 @@ +package zmaster587.advancedRocketry.test.server; + +import com.github.stannismod.forge.testing.junit.AbstractHeadlessServerTest; + +import org.junit.Test; +import zmaster587.advancedRocketry.test.ServerTicks; + +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. + ServerTicks.await(client(), 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"); + ServerTicks.await(client(), 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(); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/SystemBodiesFeedFollowsTheCellE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/SystemBodiesFeedFollowsTheCellE2ETest.java index 86a6d09ca..b68b2b453 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/SystemBodiesFeedFollowsTheCellE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/SystemBodiesFeedFollowsTheCellE2ETest.java @@ -3,6 +3,8 @@ import org.junit.After; import org.junit.Test; +import zmaster587.advancedRocketry.universe.GalaxyGenConfig; + import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -39,12 +41,26 @@ public class SystemBodiesFeedFollowsTheCellE2ETest extends AbstractSharedServerTest { /** - * Cells are chosen with a non-zero sector Y, which keeps them clear of the generated fallback stars - * (all at {@code sy=sz=0}) — so the body count of a cell is exactly what this test put in it. The two - * methods use different cells: the shared server runs both, and a cell is global state. + * Cells far enough out that nothing else claims them, so the body count of a cell is exactly what + * this test put in it. The two methods use different cells: the shared server runs both, and a + * cell is global state. + * + *

    The distance that matters is {@code minSpacing/2}, not "away from sector zero". These + * used to sit at {@code sy = 5000} on the reasoning that a non-zero sector Y kept them clear of the + * generated fallback stars at {@code sy=sz=0} — which guarded against the wrong neighbour and left + * both legs failing. A cell is attributed to a stored anchor by + * {@code UniverseRegistry.storedAnchorNear}, whose reach is HALF THE SUPER-CELL — about 2 501 180 + * cells at the shipped spacing — so {@code sy = 5000} is 0.2 % of the way out and both cells were + * squarely inside the shipped solar system's own neighbourhood. The feed was answering correctly: + * it offered the sun, the overworld and a moon at 1.6·10¹¹ blocks (ledger #291).

    + * + *

    Stated as a multiple of the reach rather than as a literal, so the fixture cannot silently + * move back inside the neighbourhood the day the spacing is retuned.

    */ - private static final String CELL_NO_SHIP = "31 5000 2"; - private static final String CELL_MID_JUMP = "32 5000 2"; + private static final long CLEAR_OF_ANY_ANCHOR = + 3L * (GalaxyGenConfig.DEFAULT_MIN_SPACING / 2L); + private static final String CELL_NO_SHIP = "31 " + CLEAR_OF_ANY_ANCHOR + " 2"; + private static final String CELL_MID_JUMP = "32 " + CLEAR_OF_ANY_ANCHOR + " 2"; /** A body a few thousand blocks out, i.e. the geometry a pilot has to fly at to descend. */ private static final String BODY_LOCAL = "2900 0 -1200"; diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/TelescopeRegionScanE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/TelescopeRegionScanE2ETest.java index fe24e4d58..7c281987e 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/TelescopeRegionScanE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/TelescopeRegionScanE2ETest.java @@ -16,7 +16,7 @@ * *

    Every number here is the SERVER's answer; the probes state their own side.

    * - *

    Position-isolated at x=4300-4460 (clear of the observatory-multiblock fixtures at x=4000-4060).

    + *

    Position-isolated at x=4300-4660 (clear of the observatory-multiblock fixtures at x=4000-4060).

    */ public class TelescopeRegionScanE2ETest extends AbstractSharedServerTest { @@ -36,15 +36,28 @@ private static String join(java.util.List response) { * the default game, where what the instrument reaches is resolved outright; on is the research * mode, where the sweep is paced and the time curve is the mechanic. */ - private void surveySetup(boolean research, int cellsPerStep, int ticksPerSector) throws Exception { + private void surveySetup(boolean research, int cellsPerStep, int ticksPerStep) + throws Exception { exec("artest config set planetsMustBeDiscovered " + research); - exec("artest config set telescopeScanBaseTicks 0"); - exec("artest config set telescopeScanTicksPerSector " + ticksPerSector); - exec("artest config set telescopeScanRangeSectors 24"); - exec("artest config set telescopeScanHalfWidthSectors 1"); - exec("artest config set telescopeScanMaxSectors 1000"); + exec("artest config set telescopeScanBaseTicks " + ticksPerStep); + // An aperture that sees essentially anything, so what a fixture finds is decided by where it + // put the fixture and never by how bright the sky happened to draw it. A survey's photometry + // is pinned in the unit tier, where a star's luminosity can be stated. + exec("artest config set telescopeLimitingMagnitude 30"); + exec("artest config set telescopeConeHalfAngleDegrees 20"); + exec("artest config set telescopeScanMaxCells 1000"); exec("artest config set telescopeScanCellsPerStep " + cellsPerStep); - exec("artest config set telescopePassiveRadiusSectors 1"); + exec("artest config set telescopePassiveRadiusSteps 1"); + } + + /** How far apart, in cells, the looks of a directed survey stand in THIS server's universe. */ + private long stride(int x) throws Exception { + String started = exec("artest telescope scan " + where(x) + " 1 0 0 1"); + assertTrue("could not aim the instrument to read its stride: " + started, + started.contains("\"ok\":true")); + long stride = field(started, "stride"); + exec("artest telescope abort " + where(x)); + return stride; } /** The value of a numeric JSON field in a probe reply. */ @@ -60,6 +73,19 @@ private static long field(String json, String name) { return Long.parseLong(json.substring(from, to)); } + /** The value of a decimal JSON field in a probe reply — a length, not a count. */ + private static double decimal(String json, String name) { + String key = "\"" + name + "\":"; + int at = json.indexOf(key); + assertTrue("probe reply has no field " + name + ": " + json, at >= 0); + int from = at + key.length(); + int to = from; + while (to < json.length() && "-+.eE0123456789".indexOf(json.charAt(to)) >= 0) { + to++; + } + return Double.parseDouble(json.substring(from, to)); + } + /** The value of a string JSON field in a probe reply. */ private static String text(String json, String name) { String key = "\"" + name + "\":\""; @@ -92,6 +118,18 @@ private void systemAt(long sx, String sy, String sz) throws Exception { assertTrue("could not place a system to be found: " + system, system.contains("\"ok\":true")); } + /** + * Seat a system {@code steps} territories out along +X, deliberately OFF the cell the survey + * looks at. + * + *

    The offset is the point of the fixture: a star is one cell of a territory millions of cells + * wide, so a survey that could see a system only by landing on its star's own address finds + * nothing. What must be found is the system that OWNS the cell that was looked at.

    + */ + private void systemNearTheLookAt(int x, String[] home, int steps) throws Exception { + systemAt(Long.parseLong(home[0]) + steps * stride(x) + 13L, home[1], home[2]); + } + /** Poll the machine until its survey is finished. Bounded: 40 × 250 ms = 10 s. */ private String awaitSurveyComplete(int x) throws Exception { String info = ""; @@ -108,9 +146,9 @@ private String awaitSurveyComplete(int x) throws Exception { @Test public void withoutResearchWhatTheInstrumentReachesIsResolvedOutright() throws Exception { final int x = 4300; - surveySetup(false, 2, 40); + surveySetup(false, 2, 120); String[] home = observatoryWithCrystal(x); - systemAt(Long.parseLong(home[0]) + 4, home[1], home[2]); + systemNearTheLookAt(x, home, 4); String started = exec("artest telescope scan " + where(x) + " 1 0 0 4"); assertTrue("the survey did not start: " + started, started.contains("\"ok\":true")); @@ -128,7 +166,9 @@ public void withResearchTheSurveySweepsCellByCell() throws Exception { // One cell a step — the claim under test — and a step short enough that 27 of them fit in // the poll budget: at 20 ticks per sector of distance a single cell took 4 s, so the whole // region wanted 108 s against a 10 s budget and the sweep was blamed for the arithmetic. - surveySetup(true, 1, 1); + // Priced flat per STEP now: a pointing's cost in time is carried by how many steps it + // needs, because a deeper one already holds proportionally more looks. + surveySetup(true, 1, 3); String[] home = observatoryWithCrystal(x); String started = exec("artest telescope scan " + where(x) + " 1 0 0 4"); @@ -157,9 +197,9 @@ public void withResearchTheSurveySweepsCellByCell() throws Exception { @Test public void stoppingASurveyIsFreeAndKeepsWhatWasAlreadyLearned() throws Exception { final int x = 4380; - surveySetup(true, 1, 60); + surveySetup(true, 1, 180); String[] home = observatoryWithCrystal(x); - systemAt(Long.parseLong(home[0]) + 3, home[1], home[2]); + systemNearTheLookAt(x, home, 3); exec("artest telescope scan " + where(x) + " 1 0 0 3"); String before = exec("artest telescope info " + where(x)); @@ -178,7 +218,7 @@ public void stoppingASurveyIsFreeAndKeepsWhatWasAlreadyLearned() throws Exceptio @Test public void aimingAgainMovesTheRegionWithoutLosingWhatWasLearned() throws Exception { final int x = 4420; - surveySetup(true, 1, 60); + surveySetup(true, 1, 180); String[] home = observatoryWithCrystal(x); String first = exec("artest telescope scan " + where(x) + " 1 0 0 3"); @@ -187,8 +227,11 @@ public void aimingAgainMovesTheRegionWithoutLosingWhatWasLearned() throws Except String second = exec("artest telescope scan " + where(x) + " 0 0 1 5"); assertTrue("re-aiming mid-survey must be allowed: " + second, second.contains("\"ok\":true")); - assertNotEquals("re-aiming must actually move the region", - text(first, "min"), text(second, "min")); + // The DIRECTION and not the corners. A pointing's bounding box is its apex plus its reach + // on every axis, so re-aiming the same instrument leaves min/max exactly where they were — + // the aim is where it is looking, which is a vector. + assertNotEquals("re-aiming must actually move the pointing", + text(first, "dir"), text(second, "dir")); assertTrue("and must keep every address already written: " + second, field(second, "addresses") >= learned); } @@ -196,7 +239,7 @@ public void aimingAgainMovesTheRegionWithoutLosingWhatWasLearned() throws Except @Test public void theLocalRadarSurveysTheObservatorysOwnNeighbourhood() throws Exception { final int x = 4460; - surveySetup(false, 4, 40); + surveySetup(false, 4, 120); String[] home = observatoryWithCrystal(x); String passive = exec("artest telescope passive " + where(x)); @@ -212,6 +255,16 @@ public void theLocalRadarSurveysTheObservatorysOwnNeighbourhood() throws Excepti long hi = Long.parseLong(maxKey.split("_")[0]); assertTrue("the radar must look around home (" + homeX + "), not at " + minKey + ".." + maxKey, lo <= homeX && homeX <= hi); + assertEquals("and it must walk TERRITORIES: one look already yields every body of the system " + + "that owns it, so a neighbourhood is measured in NEIGHBOURS", + field(passive, "stepCells"), field(passive, "stride")); + + // An observatory stands on a PLANET, never on its own star. Under the gate this test was + // written against, the cell it is standing in reported empty and the machine could not name + // the system it was sitting in. + String done = awaitSurveyComplete(x); + assertTrue("the radar must resolve the system the observatory is standing in: " + done, + field(done, "addresses") >= 1); } @Test @@ -219,7 +272,7 @@ public void aSurveyInFlightSurvivesItsChunkBeingUnloaded() throws Exception { final int x = 4540; // Research on, one cell a step, a step long enough that the sweep is certainly mid-region // when the chunk goes away. - surveySetup(true, 1, 10); + surveySetup(true, 1, 30); observatoryWithCrystal(x); String started = exec("artest telescope scan " + where(x) + " 1 0 0 3"); @@ -247,7 +300,7 @@ public void aSurveyInFlightSurvivesItsChunkBeingUnloaded() throws Exception { @Test public void anUnfedInstrumentStallsInsteadOfSurveyingForFree() throws Exception { final int x = 4580; - surveySetup(true, 1, 1); + surveySetup(true, 1, 3); // A price no bare observatory can pay: it has no data buses, so it has no distance data. exec("artest config set telescopeSurveyDataPerStep 50"); try { @@ -269,9 +322,9 @@ public void anUnfedInstrumentStallsInsteadOfSurveyingForFree() throws Exception @Test public void whatTheTelescopeWroteIsWhatAShipCanBeAimedBy() throws Exception { final int x = 4620; - surveySetup(false, 4, 1); + surveySetup(false, 4, 3); String[] home = observatoryWithCrystal(x); - systemAt(Long.parseLong(home[0]) + 3, home[1], home[2]); + systemNearTheLookAt(x, home, 3); exec("artest telescope scan " + where(x) + " 1 0 0 3"); String surveyed = awaitSurveyComplete(x); @@ -292,6 +345,30 @@ public void whatTheTelescopeWroteIsWhatAShipCanBeAimedBy() throws Exception { field(status, "ship") >= 1); } + @Test + public void theHorizonIsALengthAnInstrumentCouldActuallyHave() throws Exception { + final int x = 4660; + // The half of the defect that no amount of resolving would have fixed: the reach was stated + // in cells, so 24 of them was 0.16 AU — a fifth of the way to Mercury — and every aim inside + // the horizon stayed inside the solar system. A horizon is a LENGTH. + surveySetup(false, 4, 3); + observatoryWithCrystal(x); + + String idle = exec("artest telescope info " + where(x)); + double reachLy = decimal(idle, "reachLy"); + assertTrue("a telescope's horizon must reach other stars, in light years: " + reachLy, + reachLy >= 4d); + assertTrue("and must buy more than one star's territory: " + field(idle, "reachSteps"), + field(idle, "reachSteps") >= 2); + + String aimed = exec("artest telescope scan " + where(x) + " 1 0 0 " + field(idle, "reachSteps")); + assertTrue("the survey did not start: " + aimed, aimed.contains("\"ok\":true")); + assertTrue("an aim at the horizon must land an interstellar distance away: " + + decimal(aimed, "distanceLy") + " ly", + decimal(aimed, "distanceLy") >= 4d); + exec("artest telescope abort " + where(x)); + } + @Test public void aFartherRegionIsALongerSurveyOnTheRealClock() throws Exception { final int x = 4500; diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/TerraformerPoweredCycleOnArPlanetTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/TerraformerPoweredCycleOnArPlanetTest.java index 9456980eb..ebb4e9474 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/TerraformerPoweredCycleOnArPlanetTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/TerraformerPoweredCycleOnArPlanetTest.java @@ -68,9 +68,10 @@ public class TerraformerPoweredCycleOnArPlanetTest extends AbstractSharedServerT @Before public void generatePlanet() throws Exception { Set before = arDims(); - // Args 10 10 10 are positive randomness factors — see - // WorldCommandPlanetLifecycleContractTest for the same idiom. - exec("ar planet generate 0 Phase1aTerraformer 10 10 10"); + // The world is DERIVED, not rolled: /ar planet generate lost its three randomness arguments + // with the legacy generator behind them, so the same command on the same seed now mints the + // same planet. + exec("ar planet generate 0 Phase1aTerraformer"); Set diff = arDims(); diff.removeAll(before); assertEquals("planet generate must add exactly one dim — diff=" + diff, @@ -125,7 +126,17 @@ public void nativePlanetTerraformerWithFuelAndPowerStepsDensity() throws Excepti assertTrue("controller-state probe missing batteries readout — " + preState, preState.contains("\"batteriesPresent\":true")); + // ARRANGE the starting density instead of taking whatever the world hands over. The + // terraformer only steps UP while density is below its ceiling of 1600, and a planet's + // pressure is now DERIVED from its own physics — so a fixture that inherits it can be handed + // a world already AT the ceiling and then measures nothing. (It was: this leg failed with + // before=1600 after=1600 while its two siblings passed, which is what a fixture at the + // ceiling looks like, not a terraformer that does not work.) The subject is whether a + // powered, fuelled terraformer MOVES the density; where it starts is arrangement. + exec("ar planet set " + newDim + " atmosphereDensity 100"); int densityBefore = readDensity(); + assertTrue("arrangement: the planet must start below the terraformer's ceiling, got " + + densityBefore, densityBefore < 1600); // Refill loop: terraformer needs BOTH N2 and O2 each tick. // TileFluidHatch holds one fluid per tank — so split: hatch 0+1 // are N2 sources, hatch 2+3 are O2 sources. The controller's diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/VSCrossingLeavesNoShipBehindE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/VSCrossingLeavesNoShipBehindE2ETest.java index ba5f76a93..f82bd7806 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/VSCrossingLeavesNoShipBehindE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/VSCrossingLeavesNoShipBehindE2ETest.java @@ -234,7 +234,7 @@ private static int extractInt(String json, String key) { } private static double extractDouble(String json, String key) { - Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+(?:\\.\\d+)?)").matcher(json); + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+(?:\\.\\d+)?(?:[eE][-+]?\\d+)?)").matcher(json); return m.find() ? Double.parseDouble(m.group(1)) : 0.0; } } diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/VSCrossingOutOfAnUnloadedSourceE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/VSCrossingOutOfAnUnloadedSourceE2ETest.java index e7571e4fc..7a1eacd7b 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/VSCrossingOutOfAnUnloadedSourceE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/VSCrossingOutOfAnUnloadedSourceE2ETest.java @@ -198,7 +198,7 @@ private static int extractInt(String json, String key) { } private static double extractDouble(String json, String key) { - Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+(?:\\.\\d+)?)").matcher(json); + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+(?:\\.\\d+)?(?:[eE][-+]?\\d+)?)").matcher(json); return m.find() ? Double.parseDouble(m.group(1)) : 0.0; } } diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/VSDoubleQueuedShipLoadDoesNotKillTheServerE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/VSDoubleQueuedShipLoadDoesNotKillTheServerE2ETest.java index 31f3a8a6d..6815945ad 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/VSDoubleQueuedShipLoadDoesNotKillTheServerE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/VSDoubleQueuedShipLoadDoesNotKillTheServerE2ETest.java @@ -188,7 +188,7 @@ private static int extractInt(String json, String key) { } private static double extractDouble(String json, String key) { - Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+(?:\\.\\d+)?)").matcher(json); + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+(?:\\.\\d+)?(?:[eE][-+]?\\d+)?)").matcher(json); return m.find() ? Double.parseDouble(m.group(1)) : 0.0; } } diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/VSJumpCarriesLooseBodiesE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/VSJumpCarriesLooseBodiesE2ETest.java index 9361d5939..ffb1c0193 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/VSJumpCarriesLooseBodiesE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/VSJumpCarriesLooseBodiesE2ETest.java @@ -6,6 +6,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import static zmaster587.advancedRocketry.test.AdvancedRocketryTestConstants.HYPERSPACE_JUMP_SPEED; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -72,13 +73,13 @@ public void aJumpCarriesTheBodiesLyingOnItsDeck() throws Exception { assertTrue("ARRANGEMENT: the dropped body must be ABOARD by the definition the crossing uses," + " not merely near the ship: " + dropped, dropped.contains("\"aboard\":true")); - String begin = exec("artest space transit-begin " + originDim + " 1 64 1"); + String begin = exec("artest space transit-begin " + originDim + " 1 64 1 " + HYPERSPACE_JUMP_SPEED); assertTrue("the transit must begin: " + begin, begin.contains("\"began\":true")); int targetDim = -1; String lastTick = ""; for (int i = 0; i < 80 && targetDim < 0; i++) { - lastTick = exec("artest space transit-tick"); + lastTick = exec("artest space transit-tick 10"); if (extractInt(lastTick, "inTransit") == 0) { targetDim = extractInt(lastTick, "targetDim"); break; @@ -91,7 +92,7 @@ public void aJumpCarriesTheBodiesLyingOnItsDeck() throws Exception { String arrived = ""; boolean carried = false; for (int i = 0; i < 60 && !carried; i++) { - exec("artest space transit-tick"); + exec("artest space transit-tick 10"); arrived = exec("artest vs ship-info " + targetDim + " 0 200 0"); if (arrived.contains("\"posX\"")) { double px = extractDouble(arrived, "posX"); diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/VSShipAutoTakeoffE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/VSShipAutoTakeoffE2ETest.java index 12c96e8ac..51b6495b8 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/VSShipAutoTakeoffE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/VSShipAutoTakeoffE2ETest.java @@ -167,7 +167,7 @@ private static int extractInt(String json, String key) { } private static double extractDouble(String json, String key) { - Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+(?:\\.\\d+)?)").matcher(json); + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+(?:\\.\\d+)?(?:[eE][-+]?\\d+)?)").matcher(json); return m.find() ? Double.parseDouble(m.group(1)) : 0.0; } diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/VSShipCellSeamE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/VSShipCellSeamE2ETest.java new file mode 100644 index 000000000..742b0bbcb --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/VSShipCellSeamE2ETest.java @@ -0,0 +1,334 @@ +package zmaster587.advancedRocketry.test.server; + +import com.github.stannismod.forge.testing.TestTimeouts; + +import zmaster587.advancedRocketry.space.CellSeam; +import zmaster587.advancedRocketry.space.GalacticCoord; + +import org.junit.After; +import org.junit.Assume; +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.assertTrue; + +/** + * E2E: a ship flown out through its cell's face ARRIVES IN THE NEIGHBOUR, and its ledger row names the + * cell it is actually in. + * + *

    Before the seam existed, such a ship was neither stopped nor carried: the pose kept going while + * the ledger report saturated at the boundary, leaving the ship in one place and named in another — + * unable to descend, refused its jumps, and no longer protecting the cell it was really in.

    + * + *

    The arrangement uses the REAL on-ramp to get a ship legitimately settled in a cell (assemble, + * hold a throttle, climb past the ceiling, let the flight computer's own tick call entry), then moves + * it past the face and drives {@code SpaceSubsystem.cellCrossings().requestCarry()} — production code, through + * a probe verb. The crossing itself is the shared one every other crossing uses.

    + * + *

    What this test does NOT cover, stated rather than implied: the trigger wiring inside + * {@code TileAdvancedFlightComputer}. A headless slot world has no player and no ticking chunks, so + * its tiles do not tick and no e2e here can observe that call — the same limit the descent e2e has + * (it drives {@code space descent-begin} and says so). WHEN a carry fires is pinned deterministically + * by {@code CellSeamTest}; the one link neither covers is the two lines in the tile that join them.

    + * + *

    Witnesses, in order: the ledger names the +X neighbour and no other cell; the ship arrives + * {@code REENTRY_DEPTH} INSIDE that neighbour's opposite face rather than on it; and it stays there — + * a ship placed on the face would be one drift away from crossing straight back, which is the whole + * content of the hysteresis. CONTROL: the pre-move ledger read is asserted to name the source cell, so + * the later change is a real observation rather than a first reading.

    + * + *

    Gated on the server's real VS presence (run with {@code -PwithVS}); skips cleanly otherwise.

    + */ +public class VSShipCellSeamE2ETest extends AbstractSharedServerTest { + + private static final Pattern BUILDER_POS = + Pattern.compile("\"builderPos\":\\[(-?\\d+),(-?\\d+),(-?\\d+)]"); + private static final Pattern CELL_KEY = Pattern.compile("^(-?\\d+)_(-?\\d+)_(-?\\d+)$"); + + /** Where this test builds its ship — its own region, clear of the entry/descent legs. */ + private static final int SRC_X = 6800, SRC_Y = 80, SRC_Z = 6800; + /** A world Y comfortably above the default orbit ceiling (ARConfiguration.orbit = 1000). */ + private static final int ABOVE_CEILING_Y = 1200; + + /** Async settle budget, stretched by the build's fork factor (the entry leg's sizing). */ + private static final int SETTLE_POLLS = (int) Math.ceil(120 * TestTimeouts.factor()); + + /** + * How far past the face the ship is placed: comfortably beyond the carry margin, so the test is + * not sitting on the decision boundary — that is {@code CellSeamTest}'s job, on the pure layer, + * where a one-block question can be asked without a physics engine in the way. + */ + private static final long PAST_THE_FACE = + GalacticCoord.HALF_CELL + CellSeam.CARRY_MARGIN + 2_000L; + + /** + * Tolerance on the arrival position. The contract is "inside the face, not on it", and the two + * candidates are {@code REENTRY_DEPTH} apart (16 000 blocks), so a few hundred blocks of settle + * slop cannot confuse them. + */ + private static final double ARRIVAL_TOLERANCE = 2_000d; + + @Test + public void aShipFlownPastItsCellFaceIsCarriedIntoTheNeighbourAndStaysThere() throws Exception { + Assume.assumeTrue("needs Valkyrien Skies on the server classpath (run with -PwithVS)", + serverHasVs()); + + exec("artest vs permaload true"); + String setup = exec("artest space entry-setup 2"); + assertTrue("entry setup failed: " + setup, setup.contains("\"ok\":true")); + + // CONTROL: nothing is ledgered yet, so a later reading is a real observation — and the + // "first ledgered ship" this test reads its durable id from is unambiguously ours. + String before = exec("artest space entry-status"); + assertEquals("no ship must be ledgered before the climb: " + before, 0, + extractInt(before, "ships")); + + // --- Arrangement: get a ship into a cell through the production on-ramp ------------------ + clearArea(SRC_X, SRC_Z); + String coords = placeFixture(SRC_X, SRC_Y, SRC_Z, "with-pilot-seat"); + String asm = exec("artest rocket assemble 0 " + coords); + assertTrue("with VS an AFC-bearing build must route to a ship (no rocket): " + asm, + asm.contains("\"rocketCount\":0")); + assertTrue("the source VS ship never loaded", waitForLoadedShip(0) >= 1); + + // TWO IDENTITIES, deliberately kept apart. `vs ship-info` answers the VS ship uuid + // (`VSBridge.nearestShipId` -> `getShipData().getUuid()`), which every `vs` verb takes and + // which a crossing REPLACES — the arriving ship is a new VS body. The ledger is keyed by AR's + // durable ship id, read from `entry-status` once the ship is in space. Asking either side with + // the other's id answers "not found" and reads exactly like the mechanic being broken. + String srcInfo = exec("artest vs ship-info 0 " + SRC_X + " " + SRC_Y + " " + SRC_Z); + assertTrue("source ship not managed by VS: " + srcInfo, srcInfo.contains("\"managed\":true")); + String srcVsId = extractString(srcInfo, "id"); + assertTrue("the assembled ship reported no VS id: " + srcInfo, srcVsId != null); + double sx = extractDouble(srcInfo, "posX"), sy = extractDouble(srcInfo, "posY"), + sz = extractDouble(srcInfo, "posZ"); + + String heldInput = exec("artest vs ff-input-by-id 0 " + srcVsId + " 0 1 0 0 0 0"); + assertTrue("the held input must reach this ship's flight computer: " + heldInput, + heldInput.contains("\"afcResolved\":true")); + assertTrue("climb teleport failed", + exec("artest vs teleport-ship 0 " + (int) sx + " " + (int) sy + " " + (int) sz + + " " + (int) sx + " " + ABOVE_CEILING_Y + " " + (int) sz) + .contains("\"ok\":true")); + exec("artest vs unpark 0 " + (int) sx + " " + ABOVE_CEILING_Y + " " + (int) sz); + + String status = ""; + String sourceCell = null; + for (int i = 0; i < SETTLE_POLLS; i++) { + status = exec("artest space entry-status"); + if (extractInt(status, "ships") >= 1 && "SETTLED".equals(extractString(status, "state"))) { + sourceCell = extractString(status, "cellKey"); + break; + } + loadAllEntrySlots(setup); + Thread.sleep(250); + } + assertTrue("the ship never reached space through the entry path; last status=" + status, + sourceCell != null); + String arShipId = extractString(status, "shipId"); + assertTrue("the settled ship has no durable id: " + status, arShipId != null); + int sourceSlot = extractInt(status, "slotDim"); + assertTrue("settled ship has no bound slot: " + status, sourceSlot > Integer.MIN_VALUE); + assertTrue("the settled ship's cell world is not live", waitForLoadedShip(sourceSlot) >= 1); + + // --- CONTROL: while it is inside its cell, the ledger names THAT cell -------------------- + String inside = exec("artest space ledger-get " + arShipId); + assertTrue("the ledger does not know the settled ship: " + inside, + inside.contains("\"found\":true")); + assertEquals("the ledger must name the source cell before the ship leaves it: " + inside, + sourceCell, extractString(inside, "cell")); + + // --- Act: put the ship past the +X face of its cell -------------------------------------- + String inCell = shipInThatSlot(sourceSlot); + double cx = extractDouble(inCell, "posX"), cy = extractDouble(inCell, "posY"), + cz = extractDouble(inCell, "posZ"); + assertFalse("the ship's in-cell pose could not be read: " + inCell, + Double.isNaN(cx) || Double.isNaN(cy) || Double.isNaN(cz)); + + String outward = exec("artest vs teleport-ship " + sourceSlot + " " + + (long) cx + " " + (long) cy + " " + (long) cz + " " + + PAST_THE_FACE + " " + (long) cy + " " + (long) cz); + assertTrue("the move past the cell face failed: " + outward, outward.contains("\"ok\":true")); + exec("artest vs unpark " + sourceSlot + " " + PAST_THE_FACE + " " + (long) cy + " " + (long) cz); + + // THE ARRANGEMENT IS ASSERTED, not assumed. "the probe returned ok" is not "the ship is past + // the face": a clamp, a refused transform or a Y-limit would all report ok and leave the ship + // inside its cell, and the carry would then be correctly not firing — a green mechanic + // reported as a red one. + String moved = shipInThatSlot(sourceSlot); + double mx = extractDouble(moved, "posX"); + assertFalse("the moved ship's pose could not be read: " + moved, Double.isNaN(mx)); + assertTrue("the ship is not actually past the cell face after the move — it is at x=" + mx + + ", and the carry threshold is " + (GalacticCoord.HALF_CELL + + CellSeam.CARRY_MARGIN) + "; the test moved nothing: " + moved, + mx > GalacticCoord.HALF_CELL + CellSeam.CARRY_MARGIN); + + // Drive the production carry. NOT the flight computer's own tick: a headless slot world has + // no player and no ticking chunks, so its tiles do not tick — an earlier revision of this test + // waited 30 s for a trigger that cannot fire here and reported the CROSSING as broken. This is + // the same split the descent e2e already uses (`space descent-begin`): WHEN a carry fires is + // pinned deterministically by `CellSeamTest`, and what a carry DOES is pinned here, on a real + // ship. `wouldCarry` is production's own reading of the live pose, so the arrangement is + // witnessed by the code under test rather than only by this test's arithmetic. + String carry = exec("artest space seam-carry " + sourceSlot); + assertTrue("production does not agree the ship has left its cell (its own predicate on the " + + "live pose): " + carry, carry.contains("\"wouldCarry\":true")); + assertTrue("the carry did not start — the reason is in the reply: " + carry, + carry.contains("\"started\":true")); + + // --- Assert: carried into the neighbour --------------------------------------------------- + String carriedCell = null; + String afterMove = ""; + for (int i = 0; i < SETTLE_POLLS; i++) { + afterMove = exec("artest space ledger-get " + arShipId); + String cell = extractString(afterMove, "cell"); + if (cell != null && !sourceCell.equals(cell) + && "SETTLED".equals(extractString(afterMove, "state"))) { + carriedCell = cell; + break; + } + loadAllEntrySlots(setup); + Thread.sleep(250); + } + assertTrue("the carry started but the ship never settled in the neighbour; source=" + + sourceCell + " shipX=" + mx + " carry=" + carry + " last ledger=" + afterMove, + carriedCell != null); + + long[] from = cellSectors(sourceCell); + long[] to = cellSectors(carriedCell); + assertEquals("carried into the +X neighbour and no other", from[0] + 1L, to[0]); + assertEquals("the Y sector must not move — the ship crossed one face", from[1], to[1]); + assertEquals("the Z sector must not move — the ship crossed one face", from[2], to[2]); + + // It arrived INSIDE the neighbour's opposite face, not on it. This is the hysteresis as the + // world sees it: the expected world X is the local offset itself (XZ realize directly). + int carriedSlot = extractInt(afterMove, "slotDim"); + assertTrue("the carried ship has no bound slot: " + afterMove, carriedSlot > Integer.MIN_VALUE); + assertTrue("the neighbour's cell world never came up", waitForLoadedShip(carriedSlot) >= 1); + String arrived = shipInThatSlot(carriedSlot); + double ax = extractDouble(arrived, "posX"); + assertFalse("the arrived ship's pose could not be read: " + arrived, Double.isNaN(ax)); + double expectedX = -(double) GalacticCoord.HALF_CELL + CellSeam.REENTRY_DEPTH; + assertEquals("the ship must arrive the re-entry depth inside the face it came in by, not on it", + expectedX, ax, ARRIVAL_TOLERANCE); + + // And it STAYS there: a ship parked on the face would cross straight back. + for (int i = 0; i < 8; i++) { + Thread.sleep(250); + String held = exec("artest space ledger-get " + arShipId); + assertEquals("the carried ship bounced back across the face (ping-pong): " + held, + carriedCell, extractString(held, "cell")); + } + } + + @After + public void cleanup() throws Exception { + if (serverHasVs()) { + exec("artest space entry-clear"); + exec("artest vs permaload false"); + } + } + + /** The three sector indices of a {@code sx_sy_sz} cell key. */ + private static long[] cellSectors(String cellKey) { + Matcher m = CELL_KEY.matcher(cellKey); + assertTrue("unparsable cell key: " + cellKey, m.matches()); + return new long[]{Long.parseLong(m.group(1)), Long.parseLong(m.group(2)), + Long.parseLong(m.group(3))}; + } + + private String exec(String cmd) throws Exception { + return String.join("\n", client().execute(cmd)); + } + + /** + * The one ship in a cell's slot world, asked for POSITIONALLY and guarded by a count. + * + *

    A ship cannot be followed across a crossing by VS id — the arriving body is a new one — and + * the cell frame is far from any origin a query could guess, so the lookup is "nearest to the + * cell centre pose, within the whole cell". That is only an identity because the slot world holds + * exactly ONE ship, which is asserted here rather than assumed: without the count this returns a + * neighbour the moment a second ship shares the slot, and it reads identically.

    + */ + private String shipInThatSlot(int slotDim) throws Exception { + String count = exec("artest vs ship-count " + slotDim); + assertEquals("this lookup is only an identity while the slot holds exactly one ship: " + count, + 1, extractInt(count, "count")); + long centreY = GalacticCoord.HALF_CELL + 256L; // the cell-centre pose (CellWorldMapper band) + String info = exec("artest vs ship-info " + slotDim + " 0 " + centreY + " 0 " + + (GalacticCoord.CELL * 2L)); + assertTrue("the ship in slot " + slotDim + " could not be located: " + info, + info.contains("\"managed\":true")); + return info; + } + + private boolean serverHasVs() throws Exception { + return exec("artest vs available").contains("\"available\":true"); + } + + private void loadAllEntrySlots(String setup) throws Exception { + Matcher m = Pattern.compile("\"dims\":\\[(-?\\d+),(-?\\d+)]").matcher(setup); + if (m.find()) { + exec("artest vs load-ships " + m.group(1)); + exec("artest vs load-ships " + m.group(2)); + } + } + + private int waitForLoadedShip(int dim) throws Exception { + for (int i = 0; i < 40; i++) { + if (extractInt(exec("artest vs ship-count-all " + dim), "count") >= 1) { + exec("artest vs load-ships " + dim); + int loaded = extractInt(exec("artest vs ship-count " + dim), "count"); + if (loaded >= 1) { + return loaded; + } + } + Thread.sleep(250); + } + return 0; + } + + private void clearArea(int baseX, int baseZ) throws Exception { + int cx1 = (baseX - 4) >> 4, cz1 = (baseZ - 4) >> 4; + int cx2 = (baseX + 20) >> 4, cz2 = (baseZ + 20) >> 4; + assertTrue("chunk warmup failed", exec("artest chunk warmup 0 " + cx1 + " " + cz1 + " " + + cx2 + " " + cz2).contains("\"ok\":true")); + assertTrue("pre-clear failed", exec("artest fill 0 " + (baseX - 4) + " " + (SRC_Y - 2) + " " + + (baseZ - 4) + " " + (baseX + 20) + " " + (SRC_Y + 12) + " " + (baseZ + 20) + + " minecraft:air").contains("\"ok\":true")); + } + + private String placeFixture(int baseX, int baseY, int baseZ, String variant) throws Exception { + String fixture = exec("artest fixture rocket 0 " + baseX + " " + baseY + " " + baseZ + " " + variant); + assertTrue("fixture (" + variant + ") failed: " + fixture, fixture.contains("\"ok\":true")); + Matcher bp = BUILDER_POS.matcher(fixture); + assertTrue("fixture (" + variant + ") missing builderPos: " + fixture, bp.find()); + return bp.group(1) + " " + bp.group(2) + " " + bp.group(3); + } + + private static int extractInt(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+)").matcher(json); + return m.find() ? Integer.parseInt(m.group(1)) : Integer.MIN_VALUE; + } + + /** + * A number out of a probe reply, exponent form included — a coordinate past 10⁷ prints as + * {@code 1.6000256E7}, and an extractor that cannot read that silently compares 1.6 against + * sixteen million. NaN when absent, deliberately: the alternative (0.0) is a legal coordinate and + * would make a missing field read as "the ship is at the origin". + */ + private static double extractDouble(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+(?:\\.\\d+)?(?:[eE][-+]?\\d+)?)") + .matcher(json); + return m.find() ? Double.parseDouble(m.group(1)) : Double.NaN; + } + + private static String extractString(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":\"([^\"]*)\"").matcher(json); + return m.find() ? m.group(1) : null; + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/VSShipCrossingSpikeTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/VSShipCrossingSpikeTest.java index dcd2dcc45..218d45fa5 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/VSShipCrossingSpikeTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/VSShipCrossingSpikeTest.java @@ -177,7 +177,7 @@ private static int extractInt(String json, String key) { } private static double extractDouble(String json, String key) { - Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+(?:\\.\\d+)?)").matcher(json); + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+(?:\\.\\d+)?(?:[eE][-+]?\\d+)?)").matcher(json); return m.find() ? Double.parseDouble(m.group(1)) : 0.0; } } diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/VSShipDescentE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/VSShipDescentE2ETest.java index 4dff01731..df3538c1f 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/VSShipDescentE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/VSShipDescentE2ETest.java @@ -196,7 +196,7 @@ private static int extractInt(String json, String key) { } private static double extractDouble(String json, String key) { - Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+(?:\\.\\d+)?)").matcher(json); + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+(?:\\.\\d+)?(?:[eE][-+]?\\d+)?)").matcher(json); return m.find() ? Double.parseDouble(m.group(1)) : 0.0; } diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/VSShipEntryE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/VSShipEntryE2ETest.java index 3c2fd2fbb..82a10c47d 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/VSShipEntryE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/VSShipEntryE2ETest.java @@ -333,7 +333,7 @@ private static int extractInt(String json, String key) { } private static double extractDouble(String json, String key) { - Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+(?:\\.\\d+)?)").matcher(json); + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+(?:\\.\\d+)?(?:[eE][-+]?\\d+)?)").matcher(json); return m.find() ? Double.parseDouble(m.group(1)) : 0.0; } diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/VSShipTransitE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/VSShipTransitE2ETest.java index d6384742f..d665cdd94 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/VSShipTransitE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/VSShipTransitE2ETest.java @@ -6,6 +6,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import static zmaster587.advancedRocketry.test.AdvancedRocketryTestConstants.HYPERSPACE_JUMP_SPEED; import static org.junit.Assert.assertTrue; /** @@ -38,15 +39,19 @@ public void aVsShipTransitsFromOneCellToAnotherThroughHyperspace() throws Except assertTrue("origin ship never assembled/loaded in the pool-slot cell (dim " + originDim + ")", waitForLoadedShip(originDim) >= 1); - // Depart: begin the jump. The ship leaves the origin cell for hyperspace. - String begin = exec("artest space transit-begin " + originDim + " " + ax + " " + ay + " " + az); + // Depart: begin the jump. The ship leaves the origin cell for hyperspace — at a speed that + // makes it a real flight, because a fast enough jump is performed as a single crossing instead + // and this test is about the hyperspace path. (This fixture could not take the other path + // anyway: its bare cube has no flight computer, so it has no durable id to be crossed under.) + String begin = exec("artest space transit-begin " + originDim + " " + ax + " " + ay + " " + az + + " " + HYPERSPACE_JUMP_SPEED); assertTrue("transit did not begin (departure crossing failed): " + begin, begin.contains("\"began\":true")); // Advance the transit until it arrives (arrival retries while the async hyperspace ship assembles). int targetDim = -1; String lastTick = ""; for (int i = 0; i < 80; i++) { - lastTick = exec("artest space transit-tick"); + lastTick = exec("artest space transit-tick 10"); if (extractInt(lastTick, "inTransit") == 0) { targetDim = extractInt(lastTick, "targetDim"); break; diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/VSShipTransitPersistE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/VSShipTransitPersistE2ETest.java index 19402bd2c..3c8d2384c 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/VSShipTransitPersistE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/VSShipTransitPersistE2ETest.java @@ -6,6 +6,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import static zmaster587.advancedRocketry.test.AdvancedRocketryTestConstants.HYPERSPACE_JUMP_SPEED; import static org.junit.Assert.assertTrue; /** @@ -50,7 +51,8 @@ public void aRestoredInFlightJumpRebuildsItsShipByPastingItsSnapshotIntoTheTarge // Depart into hyperspace. We deliberately do NOT tick the transit yet: it stays parked in hyperspace // while we re-cut its snapshot (the save-point cut is of a PARKED ship). - String begin = exec("artest space transit-begin " + originDim + " " + ax + " " + ay + " " + az); + String begin = exec("artest space transit-begin " + originDim + " " + ax + " " + ay + " " + az + + " " + HYPERSPACE_JUMP_SPEED); assertTrue("transit did not begin (departure crossing failed): " + begin, begin.contains("\"began\":true")); // Re-cut the parked ship's block snapshot; retry while the async hyperspace assembly completes @@ -91,7 +93,7 @@ public void aRestoredInFlightJumpRebuildsItsShipByPastingItsSnapshotIntoTheTarge int targetDim = -1; String lastTick = ""; for (int i = 0; i < 80; i++) { - lastTick = exec("artest space transit-tick"); + lastTick = exec("artest space transit-tick 10"); if (extractInt(lastTick, "inTransit") == 0) { targetDim = extractInt(lastTick, "targetDim"); break; diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/VSShortJumpCrossesDirectlyE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/VSShortJumpCrossesDirectlyE2ETest.java new file mode 100644 index 000000000..a586c0c86 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/VSShortJumpCrossesDirectlyE2ETest.java @@ -0,0 +1,145 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Assume; +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.assertTrue; +import static zmaster587.advancedRocketry.test.AdvancedRocketryTestConstants.DIRECT_JUMP_SPEED; +import static zmaster587.advancedRocketry.test.AdvancedRocketryTestConstants.HYPERSPACE_JUMP_SPEED; + +/** + * E2E: a jump short enough to have no cruise moves a real VS ship between two cells in ONE crossing. + * + *

    The arrival acceptance here is deliberately the SAME body for both mechanisms + * ({@link #arrivesInTheTargetCell}), run once at a speed that selects the direct crossing and once at a + * speed that selects a hyperspace flight. Two mechanisms with two copies of "did it arrive" drift apart + * within weeks, and the copy that stops being maintained is the one whose mechanism nobody is changing + * — which is the one that will break silently.

    + * + *

    What is asserted about the direct path beyond arriving: it never reports a flight in progress. + * That is the whole claim — no lane, no park, no mid-flight for a restart to resume — and it is read + * off the probe's own {@code inTransit}/{@code crossing} pair rather than off how long anything + * took.

    + */ +public class VSShortJumpCrossesDirectlyE2ETest extends AbstractSharedServerTest { + + /** Probe-driven ticks a crossing or a flight gets to complete before the test calls it stuck. */ + private static final int TICK_POLLS = 80; + + @Test + public void aShortJumpArrivesWithoutEverBeingInFlight() throws Exception { + Assume.assumeTrue("needs Valkyrien Skies on the server", serverHasVs()); + exec("artest vs permaload true"); + + String setup = setUpPilotedShip(); + int originDim = extractInt(setup, "originDim"); + + String begin = exec("artest space transit-begin " + originDim + " 1 64 1 " + + DIRECT_JUMP_SPEED); + assertTrue("the short jump must begin: " + begin, begin.contains("\"began\":true")); + assertEquals("a direct crossing is not a flight — nothing may be in transit the moment it " + + "starts, because there is no flight to be in the middle of: " + begin, + 0, extractInt(begin, "inTransit")); + + String lastTick = arrivesInTheTargetCell(); + assertEquals("and nothing was ever in transit while it settled: " + lastTick, + 0, extractInt(lastTick, "inTransit")); + } + + /** + * The control leg, and it is not decoration: it is what makes the assertion above mean "the SPEED + * chose this" rather than "this fixture always does this". Same ship, same cells, same acceptance — + * only the drive is slower, and the jump becomes a flight with a lane under it. + */ + @Test + public void theSameJumpFlownSlowlyStillGoesThroughHyperspace() throws Exception { + Assume.assumeTrue("needs Valkyrien Skies on the server", serverHasVs()); + exec("artest vs permaload true"); + + String setup = setUpPilotedShip(); + int originDim = extractInt(setup, "originDim"); + + String begin = exec("artest space transit-begin " + originDim + " 1 64 1 " + + HYPERSPACE_JUMP_SPEED); + assertTrue("the jump must begin: " + begin, begin.contains("\"began\":true")); + assertEquals("a slow jump IS a flight, and reports one: " + begin, + 1, extractInt(begin, "inTransit")); + + arrivesInTheTargetCell(); + } + + /** + * The shared acceptance: tick until the jump is over, then require the ship to be VS-managed at the + * target cell's pose. Returns the last tick reply so a caller can assert on the mechanism too. + */ + private String arrivesInTheTargetCell() throws Exception { + int targetDim = -1; + String lastTick = ""; + for (int i = 0; i < TICK_POLLS && targetDim < 0; i++) { + lastTick = exec("artest space transit-tick 10"); + if (extractInt(lastTick, "inTransit") == 0 && extractInt(lastTick, "crossing") == 0 + && extractInt(lastTick, "targetDim") >= 0) { + targetDim = extractInt(lastTick, "targetDim"); + break; + } + Thread.sleep(250); + } + assertTrue("the ship never reached the target cell; last tick=" + lastTick, targetDim >= 0); + assertTrue("the ship never (re)loaded in the target cell (dim " + targetDim + "); countAll=" + + exec("artest vs ship-count-all " + targetDim), waitForLoadedShip(targetDim) >= 1); + String dstInfo = exec("artest vs ship-info " + targetDim + " 0 200 0"); + assertTrue("the arrived ship is not VS-managed in the target cell: " + dstInfo, + dstInfo.contains("\"managed\":true")); + return lastTick; + } + + private String setUpPilotedShip() throws Exception { + String setup = exec("artest space transit-setup-piloted"); + assertTrue("piloted transit setup failed: " + setup, setup.contains("\"ok\":true")); + int originDim = extractInt(setup, "originDim"); + assertTrue("the fixture must mint a durable id — a crossing resolves its ship by identity, " + + "never by the anchor every transit fixture shares: " + setup, + setup.contains("\"durableId\":\"") && !setup.contains("\"durableId\":\"\"")); + assertTrue("origin ship never assembled/loaded in the pool-slot cell (dim " + originDim + ")", + waitForLoadedShip(originDim) >= 1); + return setup; + } + + @org.junit.After + public void resetPermaload() throws Exception { + if (serverHasVs()) { + exec("artest vs permaload false"); + } + } + + private String exec(String cmd) throws Exception { + return String.join("\n", client().execute(cmd)); + } + + private boolean serverHasVs() throws Exception { + return exec("artest vs available").contains("\"available\":true"); + } + + private int waitForLoadedShip(int dim) throws Exception { + for (int i = 0; i < 40; i++) { + if (extractInt(exec("artest vs ship-count-all " + dim), "count") >= 1) { + exec("artest vs load-ships " + dim); + int loaded = extractInt(exec("artest vs ship-count " + dim), "count"); + if (loaded >= 1) { + return loaded; + } + } + Thread.sleep(250); + } + return 0; + } + + private static int extractInt(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+)").matcher(json); + return m.find() ? Integer.parseInt(m.group(1)) : Integer.MIN_VALUE; + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/VSUnmannedTransitSettlesOnItsPoseE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/VSUnmannedTransitSettlesOnItsPoseE2ETest.java index 0d51ca6d6..0cf451fef 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/VSUnmannedTransitSettlesOnItsPoseE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/VSUnmannedTransitSettlesOnItsPoseE2ETest.java @@ -6,6 +6,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import static zmaster587.advancedRocketry.test.AdvancedRocketryTestConstants.HYPERSPACE_JUMP_SPEED; import static org.junit.Assert.assertTrue; /** @@ -50,13 +51,14 @@ public void anUnmannedJumpEndsOnItsPoseNotInThePasteBand() throws Exception { assertTrue("origin ship never registered in the pool-slot cell (dim " + originDim + ")", waitForRegisteredShip(originDim)); - String begin = exec("artest space transit-begin " + originDim + " " + ax + " " + ay + " " + az); + String begin = exec("artest space transit-begin " + originDim + " " + ax + " " + ay + " " + az + + " " + HYPERSPACE_JUMP_SPEED); assertTrue("transit did not begin (departure crossing failed): " + begin, begin.contains("\"began\":true")); String lastTick = ""; for (int i = 0; i < TICK_POLLS; i++) { - lastTick = exec("artest space transit-tick"); + lastTick = exec("artest space transit-tick 10"); if (extractInt(lastTick, "inTransit") == 0 && extractInt(lastTick, "targetDim") >= 0) { break; } diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/VSUnpilotedEntryE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/VSUnpilotedEntryE2ETest.java index df4997038..536032e1a 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/VSUnpilotedEntryE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/VSUnpilotedEntryE2ETest.java @@ -185,7 +185,7 @@ private static int extractInt(String json, String key) { } private static double extractDouble(String json, String key) { - Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+(?:\\.\\d+)?)").matcher(json); + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+(?:\\.\\d+)?(?:[eE][-+]?\\d+)?)").matcher(json); return m.find() ? Double.parseDouble(m.group(1)) : 0.0; } diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/WearAccrualDisableTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/WearAccrualDisableTest.java index c883387b2..75d221ee3 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/WearAccrualDisableTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/WearAccrualDisableTest.java @@ -25,7 +25,7 @@ public class WearAccrualDisableTest extends AbstractSharedServerTest { Pattern.compile("\"builderPos\":\\[(-?\\d+),(-?\\d+),(-?\\d+)]"); private static final Pattern ROCKET_LIST_ID = Pattern.compile("\"id\":(-?\\d+)"); private static final Pattern BREAKING_PROB = - Pattern.compile("\"breakingProb\":(-?\\d+(?:\\.\\d+)?)"); + Pattern.compile("\"breakingProb\":(-?\\d+(?:\\.\\d+)?(?:[eE][-+]?\\d+)?)"); private String cmd(String c) throws Exception { return String.join("\n", client().execute(c)); diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/WearSystemTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/WearSystemTest.java index 9f785d652..47d5c762b 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/WearSystemTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/WearSystemTest.java @@ -100,7 +100,7 @@ public void wearStageRoundTripsThroughCapability() throws Exception { private double breakingProbOf(int entityId) throws Exception { String info = String.join("\n", client().execute("artest rocket info " + entityId)); - Matcher m = Pattern.compile("\"breakingProb\":(-?\\d+(?:\\.\\d+)?)").matcher(info); + Matcher m = Pattern.compile("\"breakingProb\":(-?\\d+(?:\\.\\d+)?(?:[eE][-+]?\\d+)?)").matcher(info); assertTrue("no breakingProb in info: " + info, m.find()); return Double.parseDouble(m.group(1)); } diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/WeightSystemTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/WeightSystemTest.java index 2ce611279..899abe4f6 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/WeightSystemTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/WeightSystemTest.java @@ -29,7 +29,7 @@ */ public class WeightSystemTest extends AbstractSharedServerTest { - private static final Pattern WEIGHT = Pattern.compile("\"weight\":(-?\\d+(?:\\.\\d+)?)"); + private static final Pattern WEIGHT = Pattern.compile("\"weight\":(-?\\d+(?:\\.\\d+)?(?:[eE][-+]?\\d+)?)"); private void reset() throws Exception { String r = String.join("\n", client().execute("artest weight reset")); diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/WorldCommandPlanetLifecycleContractTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/WorldCommandPlanetLifecycleContractTest.java index 9ae577a1f..d917a498c 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/WorldCommandPlanetLifecycleContractTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/WorldCommandPlanetLifecycleContractTest.java @@ -43,7 +43,7 @@ private static Set dimIds() throws Exception { @Test public void planetGenerateAddsExactlyOneEntryToRegistry() throws Exception { Set before = dimIds(); - exec("ar planet generate 0 GenTestA 10 10 10"); + exec("ar planet generate 0 GenTestA"); Set after = dimIds(); try { after.removeAll(before); @@ -57,7 +57,7 @@ public void planetGenerateAddsExactlyOneEntryToRegistry() throws Exception { @Test public void planetGenerateNamesNewDimensionFromArg() throws Exception { Set before = dimIds(); - exec("ar planet generate 0 GenTestNamed 10 10 10"); + exec("ar planet generate 0 GenTestNamed"); Set diff = dimIds(); diff.removeAll(before); try { @@ -73,7 +73,7 @@ public void planetGenerateNamesNewDimensionFromArg() throws Exception { @Test public void planetDeleteRemovesEntryFromRegistry() throws Exception { Set before = dimIds(); - exec("ar planet generate 0 GenTestDel 10 10 10"); + exec("ar planet generate 0 GenTestDel"); Set diff = dimIds(); diff.removeAll(before); assertEquals(1, diff.size()); diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/ApparentSizeTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/ApparentSizeTest.java index a07d6d15a..abad1056b 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/unit/ApparentSizeTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/ApparentSizeTest.java @@ -8,53 +8,103 @@ import static org.junit.Assert.assertTrue; /** - * A fed body is drawn at an apparent size that FALLS with distance and is CLAMPED at both ends. + * A fed body is drawn at an apparent size that RISES with the angle it subtends — its own radius over + * the distance to it — and is CLAMPED at both ends. * - *

    Neither half is polish. The fed range runs from a few thousand blocks to ~109, so an - * unclamped inverse law draws the star at a fraction of a pixel; and the renderer drops a body whose - * direction vector is shorter than 10-6, i.e. a body vanishes exactly when it is closest, - * which the maximum is what stops being the only cue. The particular curve and the four numbers are - * {@code tunable} and are deliberately not pinned here.

    + *

    Neither half is polish. Radii run from a small moon to a star and distances from a few thousand + * blocks to ~109, so an unclamped inverse law draws the star at a fraction of a pixel; and + * the renderer drops a body whose direction vector is shorter than 10-6, i.e. a body + * vanishes exactly when it is closest, which the maximum is what stops being the only cue. The + * particular curve and the four numbers are {@code tunable} and are deliberately not pinned here.

    + * + *

    What IS pinned is the shape the sky was missing until 2026-08-16: size used to be a function of + * distance alone, so a moon and a gas giant beside each other drew the same disc.

    */ public class ApparentSizeTest { + /** Earth-ish and Jupiter-ish, in the chart blocks the feed sends. */ + private static final double MOON_R = 6_800d; + private static final double EARTH_R = 25_512d; + private static final double GIANT_R = 280_000d; + @Test - public void sizeFallsAsDistanceGrows() { - double[] distances = {2_000d, 10_000d, 100_000d, 1_000_000d, 10_000_000d, 100_000_000d}; - float previous = ApparentSize.halfSizeFor(distances[0]); + public void sizeFallsAsTheSameBodyRecedes() { + double[] distances = {200_000d, 1_000_000d, 10_000_000d, 100_000_000d, 1_000_000_000d}; + float previous = ApparentSize.halfSizeFor(EARTH_R, distances[0]); for (int i = 1; i < distances.length; i++) { - float now = ApparentSize.halfSizeFor(distances[i]); + float now = ApparentSize.halfSizeFor(EARTH_R, distances[i]); assertTrue("size must fall from " + distances[i - 1] + " to " + distances[i] + " (" + previous + " -> " + now + ")", now < previous); previous = now; } } + @Test + public void aBiggerBodyOutdrawsASmallerOneAtTheSameRange() { + // THE defect this file exists for: with size keyed on distance alone these three were equal. + double range = 5_000_000d; + float moon = ApparentSize.halfSizeFor(MOON_R, range); + float earth = ApparentSize.halfSizeFor(EARTH_R, range); + float giant = ApparentSize.halfSizeFor(GIANT_R, range); + assertTrue("an Earth must outdraw a moon at the same range (" + moon + " vs " + earth + ")", + earth > moon); + assertTrue("a giant must outdraw an Earth at the same range (" + earth + " vs " + giant + ")", + giant > earth); + } + + @Test + public void twoBodiesOfEqualRadiusAtEqualRangeAreDrawnEqual() { + // The contract stated positively: nothing but the pair (radius, distance) may enter, so two + // bodies that agree on both are the same size whatever else differs about them. + assertEquals(ApparentSize.halfSizeFor(EARTH_R, 3_000_000d), + ApparentSize.halfSizeFor(EARTH_R, 3_000_000d), 0f); + } + + @Test + public void onlyTheRATIOMatters() { + // A body twice as big, twice as far, subtends the same angle — so it draws the same. This is + // what makes the argument an angular size rather than two loosely-related numbers. + assertEquals(ApparentSize.halfSizeFor(EARTH_R, 4_000_000d), + ApparentSize.halfSizeFor(2d * EARTH_R, 8_000_000d), 1e-4); + assertEquals(ApparentSize.halfSizeFor(MOON_R, 900_000d), + ApparentSize.halfSizeFor(MOON_R / 10d, 90_000d), 1e-4); + } + @Test public void sizeIsClampedAtBothEnds() { assertEquals("a body on top of you does not fill the sky", ApparentSize.MAX_HALF_SIZE, - ApparentSize.halfSizeFor(0d), 1e-6); - assertEquals(ApparentSize.MAX_HALF_SIZE, ApparentSize.halfSizeFor(1d), 1e-6); + ApparentSize.halfSizeFor(EARTH_R, 1d), 1e-6); assertEquals("a body at the neighbourhood bound is still drawn", - ApparentSize.MIN_HALF_SIZE, ApparentSize.halfSizeFor(1.0e12), 1e-6); + ApparentSize.MIN_HALF_SIZE, ApparentSize.halfSizeFor(EARTH_R, 1.0e15), 1e-6); assertTrue("nothing is ever drawn at zero size", ApparentSize.MIN_HALF_SIZE > 0f); } @Test - public void everyDistanceInTheFedRangeStaysInsideTheClamps() { - // The fed range: a moon in the observer's own cell out to the far side of a neighbourhood. - for (double d = 1d; d < 1.0e10; d *= 3d) { - float half = ApparentSize.halfSizeFor(d); - assertTrue("size left the clamps at " + d + ": " + half, - half >= ApparentSize.MIN_HALF_SIZE && half <= ApparentSize.MAX_HALF_SIZE); + public void everyFedPairStaysInsideTheClamps() { + for (double r : new double[] {1d, MOON_R, EARTH_R, GIANT_R, 2.8e6}) { + for (double d = 1d; d < 1.0e10; d *= 3d) { + float half = ApparentSize.halfSizeFor(r, d); + assertTrue("size left the clamps at r=" + r + " d=" + d + ": " + half, + half >= ApparentSize.MIN_HALF_SIZE && half <= ApparentSize.MAX_HALF_SIZE); + } } } + @Test + public void aBodyWithNoRadiusIsAMarkerNotAGuess() { + // A belt or a station slot is not a sphere. It gets the marker size rather than a size + // invented for it — the guessing that made every body the same disc in the first place. + assertEquals(ApparentSize.MIN_HALF_SIZE, ApparentSize.halfSizeFor(0d, 100_000d), 1e-6); + assertEquals(ApparentSize.MIN_HALF_SIZE, ApparentSize.halfSizeFor(-3d, 100_000d), 1e-6); + assertEquals(ApparentSize.MIN_HALF_SIZE, ApparentSize.halfSizeFor(Double.NaN, 100_000d), 1e-6); + } + @Test public void aNonsenseDistanceIsTreatedAsNearRatherThanInvisible() { // A body whose vector could not be measured must not silently disappear from the sky. - assertEquals(ApparentSize.MAX_HALF_SIZE, ApparentSize.halfSizeFor(Double.NaN), 1e-6); - assertEquals(ApparentSize.MAX_HALF_SIZE, ApparentSize.halfSizeFor(-5d), 1e-6); + assertEquals(ApparentSize.MAX_HALF_SIZE, ApparentSize.halfSizeFor(EARTH_R, Double.NaN), 1e-6); + assertEquals(ApparentSize.MAX_HALF_SIZE, ApparentSize.halfSizeFor(EARTH_R, -5d), 1e-6); + assertEquals(ApparentSize.MAX_HALF_SIZE, ApparentSize.halfSizeFor(EARTH_R, 0d), 1e-6); } @Test diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/AstronomicalBodyHelperTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/AstronomicalBodyHelperTest.java index 86f6c9c27..ec543fd60 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/unit/AstronomicalBodyHelperTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/AstronomicalBodyHelperTest.java @@ -86,10 +86,97 @@ 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 contactPair = sunLikeStar(); + StellarBody touching = sunLikeStar(); + touching.setOrbitalDistance(0); // the degenerate case: both stars at the same place + contactPair.addSubStar(touching); + + 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); + } + + /** + * 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); + 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", + 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); @@ -147,4 +234,126 @@ 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. + // ───────────────────────────────────────────────────────────────────────────── + + /** + * 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 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 + 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); + } + } diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/AtmosphericDragTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/AtmosphericDragTest.java new file mode 100644 index 000000000..1053ac97a --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/AtmosphericDragTest.java @@ -0,0 +1,97 @@ +package zmaster587.advancedRocketry.test.unit; + +import org.junit.Test; + +import zmaster587.advancedRocketry.api.FreeFlightPhysics; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Contract tests for {@link FreeFlightPhysics#atmosphericDrag} — the bound that replaced the speed cap. + * + *

    Free flight is bounded by acceleration and not by speed, which leaves one hole: a craft may + * arrive at a planet arbitrarily fast and nothing charges it. An atmosphere charges it. What is pinned + * here is that the charge behaves like air — it opposes motion, scales with density, never turns a + * craft and never pushes it backwards — and that the drag constant means what its derivation says.

    + */ +public class AtmosphericDragTest { + + private static final double EPS = 1e-9; + + private static double speed(double[] v) { + return Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); + } + + @Test + public void vacuumChangesNothing() { + double[] v = FreeFlightPhysics.atmosphericDrag(30.0, -12.0, 4.0, 0.0); + assertEquals(30.0, v[0], EPS); + assertEquals(-12.0, v[1], EPS); + assertEquals(4.0, v[2], EPS); + + double[] negative = FreeFlightPhysics.atmosphericDrag(30.0, -12.0, 4.0, -1.0); + assertEquals("a negative density is vacuum, not thrust", 30.0, negative[0], EPS); + } + + /** + * The derivation itself: at the stated terminal speed in one atmosphere, drag must exactly cancel + * full thrust — that is what makes it a TERMINAL speed rather than a number someone liked. Read + * from the class, so re-deriving either input keeps this honest. + */ + @Test + public void atTheTerminalSpeedDragCancelsFullThrust() { + double vTerm = FreeFlightPhysics.ATMOSPHERIC_TERMINAL_SPEED; + double[] after = FreeFlightPhysics.atmosphericDrag(vTerm, 0.0, 0.0, 1.0); + double lost = vTerm - after[0]; + assertEquals("drag at terminal speed must equal the thrust budget, or the constant is not " + + "the one its derivation claims", + FreeFlightPhysics.MAX_THRUST_ACCEL, lost, 1e-9); + } + + @Test + public void dragOpposesMotionAndDoesNotTurnIt() { + double[] before = {12.0, -5.0, 3.0}; + double[] after = FreeFlightPhysics.atmosphericDrag(before[0], before[1], before[2], 1.0); + + assertTrue("air must slow a craft", speed(after) < speed(before)); + // Same direction: the cross product of the two velocity vectors is zero. + double cx = before[1] * after[2] - before[2] * after[1]; + double cy = before[2] * after[0] - before[0] * after[2]; + double cz = before[0] * after[1] - before[1] * after[0]; + assertEquals("drag may not steer", 0.0, Math.sqrt(cx * cx + cy * cy + cz * cz), 1e-9); + assertTrue("and may not reverse the craft", after[0] > 0.0 && after[1] < 0.0 && after[2] > 0.0); + } + + /** + * The clamp. An unclamped quadratic at high speed removes more velocity than the craft has, which + * would fly it backwards out of the atmosphere it just entered — a hull bouncing off the sky. + */ + @Test + public void airBringsACraftToRestButNeverThroughIt() { + double absurd = 100.0 * FreeFlightPhysics.ATMOSPHERIC_TERMINAL_SPEED; + double[] after = FreeFlightPhysics.atmosphericDrag(absurd, 0.0, 0.0, 1.0); + assertTrue("never reversed: " + after[0], after[0] >= 0.0); + assertTrue("and never faster than it arrived", after[0] <= absurd); + } + + @Test + public void denserAirBrakesHarder() { + double[] thin = FreeFlightPhysics.atmosphericDrag(50.0, 0.0, 0.0, 0.2); + double[] thick = FreeFlightPhysics.atmosphericDrag(50.0, 0.0, 0.0, 1.0); + assertTrue("a thicker atmosphere must take more speed: thin=" + thin[0] + " thick=" + thick[0], + thick[0] < thin[0]); + } + + /** + * Quadratic, not linear: doubling the speed must more than double the loss. Pinned because a + * linear drag would let a craft enter arbitrarily fast and lose a fixed fraction — which is the + * hole this closes, reopened. + */ + @Test + public void theLossGrowsWithTheSquareOfSpeed() { + double slowLoss = 20.0 - FreeFlightPhysics.atmosphericDrag(20.0, 0.0, 0.0, 1.0)[0]; + double fastLoss = 40.0 - FreeFlightPhysics.atmosphericDrag(40.0, 0.0, 0.0, 1.0)[0]; + assertEquals("twice the speed, four times the loss", 4.0, fastLoss / slowLoss, 1e-6); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/CapacitorChargeTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/CapacitorChargeTest.java index 3b9daf6a8..30a63c891 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/unit/CapacitorChargeTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/CapacitorChargeTest.java @@ -3,88 +3,199 @@ import org.junit.Test; import zmaster587.advancedRocketry.hyperdrive.CapacitorCharge; +import zmaster587.advancedRocketry.hyperdrive.DriveTuning; +import zmaster587.advancedRocketry.tile.hyperdrive.TileJumpCapacitor; + +import net.minecraft.nbt.NBTTagCompound; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; /** - * What the capacitor promises: a charge that is computed, never accumulated. + * What the jump bank promises now that it holds real energy. + * + *

    The contract this file used to assert has been RETRACTED, and that is worth stating rather + * than quietly rewriting. It pinned "time away is time charging" and "an unloaded month charges + * exactly like a loaded one" — properties of a capacitor whose level was a closed form of the world + * clock and which therefore stored no energy at all. They were only true because the charge was FREE: + * the biggest single cost in the hyperdrive family, the window burst at twenty times the drive's + * power, was paid for in wall-clock time. An unloaded ship's reactors are not running either, so + * charging through an absence was manufacturing energy a second time, more quietly.

    * - *

    The contracts under test are the ones the rest of the game leans on. Time away is time - * charging, whether or not anything was loaded to notice — that is what lets a ship park in an empty - * cell for a month and come back ready. A bank never overfills, never goes negative, and never gains - * anything from a clock that ran backwards. And the cooldown between jumps is not a timer at all: it - * is however long the same arithmetic takes to reach the next burst, so a bank too small to ever - * hold one says so instead of counting forever.

    + *

    What is pinned instead is that the energy comes from the SHIP: a bank with nothing feeding it + * never fills however long anybody waits, it accepts no faster than its throughput allows, and it + * refuses to be used as a battery by the rest of the vessel. Plus the one thing that was never wrong — + * turning a deficit and a rate into a number of ticks — now labelled as the best case it is.

    */ public class CapacitorChargeTest { - @Test - public void timeAwayIsTimeCharging() { - long atStart = CapacitorCharge.at(0L, 100L, 5L, 1_000_000L, 100L); - long after200Ticks = CapacitorCharge.at(0L, 100L, 5L, 1_000_000L, 300L); + /** + * A capacitor with no world, so its build is just the controller block: capacity + * {@code CAPACITOR_BASE_CAPACITY}, throughput {@code CAPACITOR_BASE_ACCEPT_RATE}. Enough to pin + * every property here, none of which is about the scan. + */ + private static TileJumpCapacitor bareCapacitor() { + return new TileJumpCapacitor(); + } - assertEquals("nothing has elapsed yet", 0L, atStart); - assertTrue("200 ticks of absence must have charged the bank: " + after200Ticks, - after200Ticks > atStart); + /** + * What the ship pushes in. The Forge Energy port itself cannot be exercised here — its + * {@code Capability} handle is injected by Forge and is null outside a loaded game — so the tests + * drive the RULE the port delegates to, which is where the rule belongs. + */ + private static long push(TileJumpCapacitor capacitor, long amount) { + return capacitor.acceptCharge(amount, false); } + // ── the energy is the ship's ────────────────────────────────────────────── + @Test - public void anUnloadedMonthChargesExactlyLikeALoadedOne() { - // The whole point of computing rather than ticking: two capacitors, same build, same elapsed - // time, one of them in a cell nobody visited. They must agree. - long month = 20L * 60L * 60L * 24L * 30L; - long ticked = CapacitorCharge.at(0L, 0L, 1L, Long.MAX_VALUE, month); - long parked = CapacitorCharge.at(0L, 0L, 1L, Long.MAX_VALUE, month); - - assertEquals(ticked, parked); - assertEquals("and the closed form is exactly rate x elapsed", month, ticked); + public void aBankWithNothingFeedingItNeverFills() { + // THE property the old model got wrong. This capacitor is asked about repeatedly and nothing + // ever pushes into it; it must stay empty, because a buffer is not a generator. + TileJumpCapacitor capacitor = bareCapacitor(); + + assertEquals("a fresh bank is empty", 0L, capacitor.charge()); + for (int i = 0; i < 1_000; i++) { + assertEquals("a bank nobody feeds must not gain charge by being asked about it", + 0L, capacitor.charge()); + } + assertEquals("and no elapsed anything fills it either", 0L, capacitor.charge()); } @Test - public void chargeNeverExceedsCapacity() { - long charge = CapacitorCharge.at(0L, 0L, 1_000L, 5_000L, 1_000_000L); + public void whatTheShipPushesInIsWhatTheBankHolds() { + TileJumpCapacitor capacitor = bareCapacitor(); - assertEquals("a full bank is full, however long it waits", 5_000L, charge); + long accepted = push(capacitor, 5L); + assertEquals("the bank takes what it is given, up to its throughput", 5L, accepted); + assertEquals(5L, capacitor.charge()); } @Test - public void aClockThatRanBackwardsGainsNothing() { - // A restored world can hand back a smaller tick count than a tile remembers. That must read - // as "no time has passed", never as a negative charge or a wrapped one. - long charge = CapacitorCharge.at(4_000L, 9_000L, 10L, 10_000L, 500L); + public void aBankAcceptsNoFasterThanItsThroughputAllows() { + // Heat sinks are what raise this. They do not make energy — a bank with every sink in the world + // fills at nothing if nothing is feeding it, which is the previous test. + TileJumpCapacitor capacitor = bareCapacitor(); + + long accepted = push(capacitor, Long.MAX_VALUE); + assertEquals("one tick may not swallow more than the accept rate", + DriveTuning.CAPACITOR_BASE_ACCEPT_RATE, accepted); + assertEquals(DriveTuning.CAPACITOR_BASE_ACCEPT_RATE, capacitor.charge()); + } - assertEquals(4_000L, charge); + @Test + public void aBankNeverOverfills() { + TileJumpCapacitor capacitor = bareCapacitor(); + long capacity = capacitor.capacity(); + + long pushed = 0L; + for (int i = 0; i < 100_000 && capacitor.charge() < capacity; i++) { + pushed += push(capacitor, Long.MAX_VALUE); + } + assertEquals("a full bank is full", capacity, capacitor.charge()); + assertEquals("and it never took more than it can hold", capacity, pushed); + assertEquals("a full bank accepts nothing further", 0L, push(capacitor, 1_000L)); } @Test - public void anAbsenceLongEnoughToOverflowStillJustFills() { - long charge = CapacitorCharge.at(0L, 0L, Long.MAX_VALUE / 2L, 10_000L, Long.MAX_VALUE / 2L); + public void aSimulatedPushChangesNothing() { + TileJumpCapacitor capacitor = bareCapacitor(); - assertEquals("a colossal elapsed time must saturate at capacity, not wrap negative", - 10_000L, charge); + long would = capacitor.acceptCharge(3L, true); + assertEquals("a simulation must report what a real push would take", 3L, would); + assertEquals("...and must not have taken it", 0L, capacitor.charge()); } @Test - public void theCooldownIsHowLongTheNextBurstTakesToArrive() { - long ticks = CapacitorCharge.ticksUntil(0L, 0L, 10L, 10_000L, 0L, 1_000L); + public void theJumpBankIsNOTtheShipsBattery() { + // Only the drive's own burst may take from it. If the rest of the vessel could pull, a jump + // bank would become the ship's general storage and the burst would be paid for out of whatever + // happened to be lying around at the moment — which is the free energy coming back sideways. + // The port cannot be exercised without Forge's capability registry, so what is pinned here is + // the machine's own rule: nothing but the drive's burst removes charge, and the burst goes + // through discharge(). The port's refusal is one line of delegation over this. + TileJumpCapacitor capacitor = bareCapacitor(); + push(capacitor, 10L); + + assertEquals("a partial take must remove nothing", 0L, capacitor.discharge(11L)); + assertEquals("the charge is untouched", 10L, capacitor.charge()); + } + + // ── the burst really leaves the buffer ──────────────────────────────────── - assertEquals("1000 needed at 10 per tick", 100L, ticks); + @Test + public void aBurstTakesAllOfItOrNoneOfIt() { + // Half a burst does not open half a window, so a bank that cannot cover one must not be + // partially drained by the attempt. + TileJumpCapacitor capacitor = bareCapacitor(); + capacitor.fill(); + long full = capacitor.charge(); + assertTrue("the fixture needs a bank with something in it", full > 0L); + + assertEquals("a burst larger than the bank takes nothing", 0L, + capacitor.discharge(full + 1L)); + assertEquals("...and leaves it untouched", full, capacitor.charge()); + + assertEquals("a burst it can cover takes exactly that", full - 1L, + capacitor.discharge(full - 1L)); + assertEquals("and the energy is really gone", 1L, capacitor.charge()); + } + + @Test + public void aStoredChargeIsREADbackOffTheSave() { + // It is real stored energy now, so it has to persist — under the old model only c0 and a tick + // stamp were written and the level was recomputed, which is exactly how an absence created it. + // + // ONLY THE READ HALF is pinned here, and the limit is worth naming rather than hiding: writing + // goes through TileEntity's registry mapping, which does not exist outside a loaded game, so + // this test builds the compound by hand. The write half is exercised by the real save path in + // the server tier but is not ASSERTED anywhere yet, and saying so is better than a green that + // reads as though it were. + NBTTagCompound nbt = new NBTTagCompound(); + nbt.setLong("capCharge", 7L); + + TileJumpCapacitor restored = bareCapacitor(); + restored.readFromNBT(nbt); + assertEquals("a reloaded bank holds what it held", 7L, restored.charge()); + } + + // ── the cooldown forecast, now a best case ─────────────────────────────── + + @Test + public void theForecastIsADeficitOverARate() { + assertEquals("already there", 0L, CapacitorCharge.ticksToReach(500L, 1_000L, 10L, 500L)); + assertEquals("400 short at 10 a tick", 40L, + CapacitorCharge.ticksToReach(100L, 1_000L, 10L, 500L)); + assertEquals("a partial tick still costs a whole one", 41L, + CapacitorCharge.ticksToReach(99L, 1_000L, 10L, 500L)); } @Test - public void aBankThatAlreadyHoldsTheBurstHasNoCooldown() { - assertEquals(0L, CapacitorCharge.ticksUntil(5_000L, 0L, 10L, 10_000L, 0L, 1_000L)); + public void aBankTooSmallToEverHoldABurstSaysSoInsteadOfCountingForever() { + assertEquals(-1L, CapacitorCharge.ticksToReach(0L, 1_000L, 10L, 5_000L)); } @Test - public void aBankTooSmallForTheBurstSaysSoInsteadOfCountingForever() { - assertEquals("a build that can never open the window must be reported, not waited on", - -1L, CapacitorCharge.ticksUntil(0L, 0L, 10L, 500L, 0L, 1_000L)); + public void aBankWithNoInflowNeverGetsThere() { + // The forecast's own statement of the property the first test pins on the tile: a rate of zero + // is not "a very long time", it is never. + assertEquals(-1L, CapacitorCharge.ticksToReach(0L, 10_000L, 0L, 5_000L)); } @Test - public void aBankThatNeverRechargesSaysSoToo() { - assertEquals(-1L, CapacitorCharge.ticksUntil(0L, 0L, 0L, 10_000L, 0L, 1_000L)); + public void theForecastIsTheBANKSbestCaseAndTheTileSaysSo() { + // Named for what it is. The rate is the bank's own accept ceiling, so a ship whose reactors + // deliver less waits longer — and nothing here may present that number as a promise. + TileJumpCapacitor capacitor = bareCapacitor(); + long needed = capacitor.capacity(); + long forecast = capacitor.ticksUntilAtFullInflow(needed); + + assertEquals("an empty bank at its full accept rate", + (needed + DriveTuning.CAPACITOR_BASE_ACCEPT_RATE - 1L) + / DriveTuning.CAPACITOR_BASE_ACCEPT_RATE, + forecast); + assertEquals("a burst bigger than the bank is never reachable", -1L, + capacitor.ticksUntilAtFullInflow(needed + 1L)); } } diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/CellFramesTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/CellFramesTest.java index 1eb68937b..98319a320 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/unit/CellFramesTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/CellFramesTest.java @@ -8,6 +8,7 @@ import zmaster587.advancedRocketry.space.GalacticCoord; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; /** @@ -104,4 +105,60 @@ public void twoCellsInOneMovingSystemKeepTheirDistanceIfBothRide() { ship.staticFrameDistanceTo(bodyInSameCell), drifting.distanceBetween(ship, bodyInSameCell, 999L), 1e-6); } + + // ── separations wider than a block long ─────────────────────────────────── + + /** + * The furthest apart two sectors can be while a block delta between them still holds. Past this + * the components are clamped, which is the whole subject of the two tests below. + */ + private static final long BLOCK_REACH_SECTORS = Long.MAX_VALUE / GalacticCoord.CELL; + + @Test + public void anOrdinarySeparationIsExactAndSaysSo() { + // The control. Everything inside a galaxy is here, and a delta that reported itself clamped + // when it was not would make the flag below useless by crying wolf. + BlockDelta delta = CellFrames.STATIC.deltaBetween(cell(0L, 0L), cell(1_000_000L, 0L), 0L); + assertFalse("a separation a million cells wide fits a long of blocks and must not be flagged", + delta.isSaturated()); + assertEquals(1_000_000L * GalacticCoord.CELL, delta.dx()); + } + + @Test + public void aSeparationTooWideForABlockLongCOMESBACKSAYINGSO() { + // Deliberately asked for. Two things in different galaxies are further apart than three block + // longs can hold — the galaxy lattice is millions of light years across — and the clamped + // vector that comes back is a DIRECTION, not a distance. What must never happen is that it is + // indistinguishable from a real one: a consumer measuring it would report a separation of + // exactly Long.MAX_VALUE blocks as though it had measured something. + GalacticCoord here = cell(0L, 0L); + GalacticCoord farAway = cell(2L * BLOCK_REACH_SECTORS, 0L); + + BlockDelta delta = CellFrames.STATIC.deltaBetween(here, farAway, 0L); + assertTrue("a separation past the block range must report itself saturated", + delta.isSaturated()); + assertEquals("and must be held at the bound, never wrapped to a small number pointing back", + Long.MAX_VALUE, delta.dx()); + + // The direction survives, which is what the render and nav channels actually read. + assertTrue("the clamped component must keep the sign of the real separation", delta.dx() > 0L); + + // And the distance is still answerable at that magnitude — through the positions, which are + // sectorised, rather than through the delta, which is not. + double honest = CellFrames.STATIC.distanceBetween(here, farAway, 0L); + assertTrue("the true distance must exceed what the clamped vector can express: " + honest + + " vs " + delta.length(), + honest > delta.length()); + } + + @Test + public void addingToASaturatedDeltaDoesNotLaunderItBackIntoAnExactOne() { + // A sum involving a lower bound is a lower bound. Dropping the flag here would let a clamped + // vector re-enter the system as an exact answer one addition later. + BlockDelta clamped = BlockDelta.saturated(Long.MAX_VALUE, 0L, 0L); + assertTrue(clamped.plus(BlockDelta.of(1L, 2L, 3L)).isSaturated()); + assertTrue(BlockDelta.of(1L, 2L, 3L).plus(clamped).isSaturated()); + assertFalse("two exact deltas still add to an exact one", + BlockDelta.of(1L, 0L, 0L).plus(BlockDelta.of(2L, 0L, 0L)).isSaturated()); + } } diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/CellSeamTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/CellSeamTest.java new file mode 100644 index 000000000..023d00795 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/CellSeamTest.java @@ -0,0 +1,131 @@ +package zmaster587.advancedRocketry.test.unit; + +import org.junit.Test; + +import zmaster587.advancedRocketry.space.CellSeam; +import zmaster587.advancedRocketry.space.CellWorldMapper; +import zmaster587.advancedRocketry.space.GalacticCoord; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Contract tests for {@link CellSeam} — the arithmetic of flying THROUGH a cell face. + * + *

    What these pin is deliberately narrow: that a ship inside its cell is left alone, that one far + * enough past a face is carried into the neighbour it left through, that it arrives inside that + * neighbour rather than on its face, and that the return trip costs more than the outbound overshoot + * — which is the whole content of "no ping-pong". The margins themselves are read from the class, not + * restated, so a re-tuning changes the behaviour these tests describe without making them lie.

    + */ +public class CellSeamTest { + + private static final GalacticCoord CELL = GalacticCoord.ofSectorLocal(3L, -1L, 7L, 0, 0, 0); + + /** The world-frame pose whose local offset is {@code (lx,ly,lz)} — the inverse of the mapping. */ + private static double[] poseOfLocal(long lx, long ly, long lz) { + return new double[]{lx, ly + GalacticCoord.HALF_CELL + CellWorldMapper.POSE_BAND_Y, lz}; + } + + @Test + public void aShipInsideItsCellIsNotCarried() { + double[] deepInside = poseOfLocal(0L, 0L, 0L); + assertFalse(CellSeam.shouldCarry(deepInside[0], deepInside[1], deepInside[2])); + + // Right up against the face, and even a little past it: still not a crossing. This is the case + // the margin exists for — a report may saturate here, a ship may not change worlds here. + double[] onTheFace = poseOfLocal(GalacticCoord.HALF_CELL, 0L, 0L); + assertFalse(CellSeam.shouldCarry(onTheFace[0], onTheFace[1], onTheFace[2])); + double[] justPast = poseOfLocal(GalacticCoord.HALF_CELL + CellSeam.CARRY_MARGIN, 0L, 0L); + assertFalse(CellSeam.shouldCarry(justPast[0], justPast[1], justPast[2])); + } + + @Test + public void aShipPastTheMarginIsCarriedIntoTheNeighbourItLeftThrough() { + double[] out = poseOfLocal(GalacticCoord.HALF_CELL + CellSeam.CARRY_MARGIN + 1L, 0L, 0L); + assertTrue(CellSeam.shouldCarry(out[0], out[1], out[2])); + + GalacticCoord dest = CellSeam.carriedCoord(CELL, out[0], out[1], out[2]); + assertEquals("the +X neighbour, and only that one", CELL.sectorX() + 1L, dest.sectorX()); + assertEquals(CELL.sectorY(), dest.sectorY()); + assertEquals(CELL.sectorZ(), dest.sectorZ()); + assertEquals("placed inside the face it came in by", + -GalacticCoord.HALF_CELL + CellSeam.REENTRY_DEPTH, dest.localX()); + } + + @Test + public void theAxesThatDidNotCrossKeepWhereThePilotFlewThem() { + long ly = 4_242L; + long lz = -1_000_000L; + double[] out = poseOfLocal(-GalacticCoord.HALF_CELL - CellSeam.CARRY_MARGIN - 1L, ly, lz); + + GalacticCoord dest = CellSeam.carriedCoord(CELL, out[0], out[1], out[2]); + assertEquals(CELL.sectorX() - 1L, dest.sectorX()); + assertEquals("left through -X, so it arrives just inside the +X face", + GalacticCoord.HALF_CELL - CellSeam.REENTRY_DEPTH, dest.localX()); + assertEquals(ly, dest.localY()); + assertEquals(lz, dest.localZ()); + } + + @Test + public void aCornerExitCarriesEveryAxisThatCrossed() { + double[] out = poseOfLocal( + GalacticCoord.HALF_CELL + CellSeam.CARRY_MARGIN + 1L, + -GalacticCoord.HALF_CELL - CellSeam.CARRY_MARGIN - 1L, + GalacticCoord.HALF_CELL + CellSeam.CARRY_MARGIN + 1L); + + GalacticCoord dest = CellSeam.carriedCoord(CELL, out[0], out[1], out[2]); + assertEquals(CELL.sectorX() + 1L, dest.sectorX()); + assertEquals(CELL.sectorY() - 1L, dest.sectorY()); + assertEquals(CELL.sectorZ() + 1L, dest.sectorZ()); + } + + /** + * The hysteresis, measured on the ship rather than on the constants: from where a carry actually + * PUT it, flying straight back must cost at least {@code REENTRY_DEPTH + CARRY_MARGIN}. + * + *

    Every distance below is derived from the arrival coordinate. An earlier version of this test + * compared the two constants to each other and asserted about poses computed from them, and it + * stayed green against a build that landed the ship ON the face — which is the whole defect this + * test exists to catch, with the return trip cut by a factor of ten.

    + */ + @Test + public void aCarriedShipCannotPingPongBackAcrossTheFace() { + double[] out = poseOfLocal(GalacticCoord.HALF_CELL + CellSeam.CARRY_MARGIN + 1L, 0L, 0L); + GalacticCoord dest = CellSeam.carriedCoord(CELL, out[0], out[1], out[2]); + + double[] arrival = CellWorldMapper.poseWorldOf(dest); + assertFalse("the arrival pose must not itself be a crossing", + CellSeam.shouldCarry(arrival[0], arrival[1], arrival[2])); + + // Where it landed, and how far back the return threshold is FROM THERE. + long arrivedAt = CellSeam.localOf(arrival[0], false); + long returnThreshold = -GalacticCoord.HALF_CELL - CellSeam.CARRY_MARGIN; + long returnTrip = arrivedAt - returnThreshold; + assertTrue("returning must cost the re-entry depth plus the margin, not merely the margin: " + + "arrived at " + arrivedAt + ", threshold " + returnThreshold, + returnTrip >= CellSeam.REENTRY_DEPTH + CellSeam.CARRY_MARGIN); + + // And the threshold is where it says it is: one block short does not cross, one past does. + double[] almostBack = poseOfLocal(arrivedAt - returnTrip + 1L, 0L, 0L); + assertFalse("one block short of the return threshold is still not a crossing", + CellSeam.shouldCarry(almostBack[0], almostBack[1], almostBack[2])); + double[] allTheWayBack = poseOfLocal(arrivedAt - returnTrip - 1L, 0L, 0L); + assertTrue("one block past it must carry the ship back", + CellSeam.shouldCarry(allTheWayBack[0], allTheWayBack[1], allTheWayBack[2])); + } + + /** + * Both margins are fractions of the cell. Pinned because the failure they guard against is silent: + * an absolute margin keeps its number when the cell is resized and quietly becomes a different + * duration — which is exactly what happened to the moon band before it was expressed this way. + */ + @Test + public void theMarginsScaleWithTheCell() { + assertEquals(GalacticCoord.HALF_CELL / 10_000L, CellSeam.CARRY_MARGIN); + assertEquals(GalacticCoord.HALF_CELL / 1_000L, CellSeam.REENTRY_DEPTH); + assertTrue("the re-entry depth must exceed the carry margin, or the hysteresis is inverted", + CellSeam.REENTRY_DEPTH > CellSeam.CARRY_MARGIN); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/ClusteredGalaxyGeneratorTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/ClusteredGalaxyGeneratorTest.java index 7fe11b02d..0e93fd07e 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/unit/ClusteredGalaxyGeneratorTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/ClusteredGalaxyGeneratorTest.java @@ -2,7 +2,14 @@ import org.junit.Test; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -11,23 +18,47 @@ import java.util.Set; import zmaster587.advancedRocketry.space.GalacticCoord; +import zmaster587.advancedRocketry.universe.BodyDerivationV0; +import zmaster587.advancedRocketry.universe.BodyProfile; import zmaster587.advancedRocketry.universe.ClusteredGalaxyGenerator; +import zmaster587.advancedRocketry.universe.Cosmology; +import zmaster587.advancedRocketry.universe.Galaxy; import zmaster587.advancedRocketry.universe.GalaxyGenConfig; -import zmaster587.advancedRocketry.universe.StarSystem; +import zmaster587.advancedRocketry.universe.IBodyDerivation; +import zmaster587.advancedRocketry.universe.PlanetDerivation; +import zmaster587.advancedRocketry.universe.PlanetarySystem; import zmaster587.advancedRocketry.universe.SystemBody; import zmaster587.advancedRocketry.universe.SystemBodyKind; +import zmaster587.advancedRocketry.universe.UniverseScale; +import zmaster587.advancedRocketry.universe.UniverseSchemas; +import zmaster587.advancedRocketry.util.AstronomicalBodyHelper; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; /** * 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 star field is its GALAXY's density + * profile (it thins outwards and stops at the declared radius), 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.

    + * + *

    Every sweep here sits near the ORIGIN, which is the home galaxy's centre — the one place + * guaranteed to be inside a galaxy under every seed. A sweep elsewhere would be sampling whatever the + * seed happened to put there, which is a different claim. The galaxy lattice itself is + * {@code GalaxyFieldTest}'s subject.

    + * + *

    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 +68,33 @@ private static GalacticCoord cell(long sx, long sy, long sz) { return GalacticCoord.ofSectorLocal(sx, sy, sz, 0L, 0L, 0L); } + /** The shipped spacing: what the sampled galaxy is is what the game ships. */ + private static final int SPACING = GalaxyGenConfig.DEFAULT_MIN_SPACING; + /** - * 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. + * A config at the shipped galaxy lattice, varying only how full a galaxy's densest point is. Every + * sweep in this class sits near the ORIGIN, which is the home galaxy's centre, so {@code density} + * is the whole of what decides whether the sampled sky has stars in it. */ - private static GalaxyGenConfig smallCfg() { - return new GalaxyGenConfig(0.35d, 4, 16, 0.6d, null); + private static GalaxyGenConfig cfg(double density, int spacing) { + return new GalaxyGenConfig(spacing, density, GalaxyGenConfig.DEFAULT_GALAXY_SPACING, + GalaxyGenConfig.DEFAULT_GALAXY_DENSITY, null, null); + } + + private static GalaxyGenConfig defaultsCfg() { + return cfg(0.35d, SPACING); } - /** 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,42 +102,63 @@ 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().systemId(), b.get().systemId()); + assertEquals("primary kind stable", a.get().primaryKind(), b.get().primaryKind()); + assertEquals("star presence stable", a.get().star().isPresent(), b.get().star().isPresent()); + if (!a.get().star().isPresent()) { + return; // a starless system: it has no temperature or size to be stable + } + assertEquals("temperature stable", a.get().star().get().getTemperature(), + b.get().star().get().getTemperature()); + assertEquals("size stable", a.get().star().get().getSize(), b.get().star().get().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); // 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); @@ -105,86 +166,136 @@ public void minimumSpacingIsRespected() { } @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); - - int emptyBlocks = 0; - int nonEmptyBlocks = 0; - // Scan 16x16 coarse blocks (each 6x6x1 cells) across a wide plane; classify each as void or populated. - 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()) { - any = true; - } - } - } - if (any) { - nonEmptyBlocks++; - } else { - emptyBlocks++; - } + 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); // 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); } } - assertTrue("clustering must leave genuinely empty void regions", emptyBlocks > 0); - assertTrue("clustering must leave genuinely populated regions", nonEmptyBlocks > 0); + } + + @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); + 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 starFormationStopsAtTheGalaxysDeclaredEdge() { + // The star field is the GALAXY's density profile, so where a galaxy ends, star FORMATION ends. + // This is what an independent per-cell mask could not do: drawn above the percolation threshold + // it produced one unbounded sponge, with no edge to reach and no answer to "which galaxy is + // this". + // + // It is star FORMATION and not "anything at all", and the distinction is the whole of the void + // content: what is out past the edge got there by being thrown, and the material that carries + // it is the ejecta halo rather than the profile. So the reading is the BOUND term — what a + // star needs to condense out of — and it is zero past the radius on the nose. + // + // Sampled against the home galaxy's OWN radius rather than a hard-coded distance: the radius + // is drawn per seed, so a fixed number would be testing one draw. + ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(cfg(1.0d, SPACING)); + Galaxy home = gen.galaxies().home(SEED); + + assertTrue("the galaxy's core must hold stars (found " + seatsInBlockAround(gen, 0L, 3) + ")", + seatsInBlockAround(gen, 0L, 3) > 0); + assertTrue("inside the galaxy there must be material a star can form out of", + gen.galaxies().materialAtSector(SEED, 0L, 0L, 0L).bound > 0d); + + long beyondEdge = UniverseScale.cellsForLightYears(home.radiusLy() * 1.5d); + for (long d = 0; d <= 3; d++) { + long sector = beyondEdge + d * SPACING; + assertEquals("past the declared radius of " + (long) home.radiusLy() + + " ly nothing may FORM, at " + sector, + 0d, gen.galaxies().materialAtSector(SEED, sector, 0L, 0L).bound, 0d); + } } @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; + // 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 byPointQuery = new HashSet<>(); - forEachCell(r, c -> { - if (gen.systemAt(SEED, c).isPresent()) { - byPointQuery.add(c.cellKey()); + 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)); + Map region = gen.systemsInRegion(SEED, cell(-r, -r, -r), cell(r, r, r)); Set byRegion = new HashSet<>(); - for (Map.Entry e : region.entrySet()) { + for (Map.Entry e : region.entrySet()) { byRegion.add(e.getKey().cellKey()); // The enumerated cell must itself point-resolve to the same system. - Optional point = gen.systemAt(SEED, e.getKey()); + Optional point = gen.systemAt(SEED, e.getKey()); assertTrue("region cell " + e.getKey() + " must point-resolve", point.isPresent()); - assertEquals(point.get().starId(), e.getValue().starId()); + assertEquals(point.get().systemId(), e.getValue().systemId()); } - 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(); - assertEquals("voidFraction=1 must yield an empty galaxy", 0, allVoid); - assertTrue("voidFraction=0 must populate the galaxy", noVoid > 0); + public void aGalaxysProfileThinsTheStarFieldOutwards() { + // The profile is not a mask with two states. A galaxy is densest at its nucleus and thins with + // radius, so the same density knob has to place more stars near the centre than out at the rim + // — that gradient is the whole difference between a galaxy and a uniform fog. + ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(cfg(1.0d, SPACING)); + Galaxy home = gen.galaxies().home(SEED); + + int core = seatsInBlockAround(gen, 0L, 4); + int rim = seatsInBlockAround(gen, UniverseScale.cellsForLightYears(home.radiusLy() * 0.8d), 4); + assertTrue("the core must be denser than the rim (" + core + " vs " + rim + ")", core > rim); } @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)), + SEED, SPACING, 7).size(); + int dense = occupiedSeats(new ClusteredGalaxyGenerator(cfg(0.9d, SPACING)), + SEED, SPACING, 7).size(); assertTrue("higher density must place more systems (" + sparse + " vs " + dense + ")", dense > sparse); } @@ -195,22 +306,27 @@ 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(SPACING, 0.9d, + GalaxyGenConfig.DEFAULT_GALAXY_SPACING, GalaxyGenConfig.DEFAULT_GALAXY_DENSITY, + types, null); + 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(); + PlanetarySystem sys = gen.systemAt(SEED, anchor.get()).get(); + if (!sys.star().isPresent()) { + continue; // a starless system draws no star archetype, which is this test's subject + } + int temp = sys.star().get().getTemperature(); seenTemps.add(Integer.toString(temp)); total++; if (temp == 50) { @@ -221,7 +337,7 @@ public void starTypesAreDrawnFromTheConfiguredSetAndWeighted() { other++; } // size must lie in the archetype's range - float size = sys.get().star().getSize(); + float size = sys.star().get().getSize(); if (temp == 50) { assertTrue(size >= 0.5f && size <= 1.0f); } else if (temp == 250) { @@ -237,36 +353,33 @@ 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)); 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().systemId() < 0); } assertTrue(sawAny); } @Test public void configClampsAndDefaults() { - GalaxyGenConfig c = new GalaxyGenConfig(5.0d, -3, 0, -1.0d, null); + GalaxyGenConfig c = new GalaxyGenConfig(-3, 5.0d, -7L, -1.0d, null, null); assertEquals("density clamps to [0,1]", 1.0d, c.density, 0d); - assertEquals("voidFraction clamps to [0,1]", 0.0d, c.voidFraction, 0d); + assertEquals("galaxyDensity clamps to [0,1]", 0.0d, c.galaxyDensity, 0d); assertTrue("minSpacing floors at 1", c.minSpacing >= 1); - assertTrue("clusterScale floors at 1", c.clusterScale >= 1); + assertTrue("galaxySpacing floors at 1", c.galaxySpacing >= 1L); assertFalse("empty star types fall back to defaults", c.starTypes.isEmpty()); + assertFalse("empty galaxy types fall back to defaults", c.galaxyTypes.isEmpty()); } @Test public void configClampsNaNToZero() { - // A NaN attribute (Double.parseDouble accepts "NaN") must not poison the density/void gates. - GalaxyGenConfig c = new GalaxyGenConfig(Double.NaN, 1, 1, Double.NaN, null); + // A NaN attribute (Double.parseDouble accepts "NaN") must not poison either occupancy gate. + GalaxyGenConfig c = new GalaxyGenConfig(1, Double.NaN, 1L, Double.NaN, null, null); assertEquals("NaN density clamps to 0", 0.0d, c.density, 0d); - assertEquals("NaN voidFraction clamps to 0", 0.0d, c.voidFraction, 0d); + assertEquals("NaN galaxyDensity clamps to 0", 0.0d, c.galaxyDensity, 0d); } @Test @@ -276,14 +389,19 @@ 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(SPACING, 0.9d, GalaxyGenConfig.DEFAULT_GALAXY_SPACING, + GalaxyGenConfig.DEFAULT_GALAXY_DENSITY, types, null)); 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()) { + continue; + } + PlanetarySystem sys = gen.systemAt(SEED, anchor.get()).get(); + if (sys.star().isPresent()) { + seenTemps.add(Integer.toString(sys.star().get().getTemperature())); } } } @@ -293,33 +411,40 @@ 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); + 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)) { + if (!gen.systemAt(SEED, anchor).get().star().isPresent()) { + continue; // a starless system has no star at its anchor, which is what this pins } 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()); assertTrue(a.get(0).name().sameCell(anchor)); assertEquals(0, a.get(0).name().localX()); + // Every body names a star OF THIS SYSTEM — the primary, or one of its companions, which + // are stars in their own right with ids of their own. + Set systemStars = new HashSet<>(); + systemStars.add(a.get(0).starId()); + for (zmaster587.advancedRocketry.api.dimension.solar.StellarBody companion + : gen.systemAt(SEED, anchor).get().star().get().getSubStars()) { + systemStars.add(companion.getId()); + } + boolean sawOwnCell = false; for (SystemBody body : a) { - assertEquals("every body belongs to the system's star", a.get(0).starId(), body.starId()); + assertTrue("body names star " + body.starId() + ", which is not one of this system's", + systemStars.contains(body.starId())); assertFalse("procedural bodies are not descend targets yet", body.isDescendTarget()); // Snapped to its own cell's centre. assertEquals(0, body.name().localX()); @@ -339,11 +464,62 @@ 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). - ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator( - new GalaxyGenConfig(0.9d, 1, 8, 0.0d, null)); + 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)); + 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)); + 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. + ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(cfg(0.9d, 1)); boolean checkedAny = false; for (long x = -6; x <= 6; x++) { GalacticCoord c = cell(x, 0, 0); @@ -352,48 +528,728 @@ 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); + int real = 0; + for (SystemBody body : bodies) { + assertTrue("nothing may escape the one cell this system has", + body.name().sameCell(c)); + if (body.definesFrame()) { + real++; + } } + assertEquals("a one-cell neighbourhood can host exactly one real body", 1, real); + assertTrue("and that body is the system's primary", + bodies.get(0).definesFrame() && bodies.get(0).name().sameCell(c)); } assertTrue(checkedAny); } @Test - public void anchorAtAttributesEveryCellOfAnOccupiedSuperCell() { - GalaxyGenConfig cfg = new GalaxyGenConfig(0.9d, 8, 8, 0.0d, null); - ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(cfg); - long s = cfg.minSpacing; + public void everyBodyOfASystemAttributesBackToThatSystemsAnchor() { + // MEMBER-CELL ATTRIBUTION, which is what every address in the game rests on: a body is + // reached, described and landed on through the system that owns its cell, so + // "which system owns this cell" must have exactly one answer and it must be the right one. + // + // It used to be stated as "every cell of a SUPER-CELL attributes to that super-cell's + // anchor", and that sentence stopped being true when the lattice began to be divided + // uniformly: a territory holds up to k-cubed seats, so two cells of one super-cell honestly + // belong to two different systems. What did NOT change — and what the old wording was + // standing in for — is that a system's own bodies all attribute back to it. That is the + // property the console, the descent trigger and the sky all read, and unlike the old one it + // is stated against the unit that actually owns a neighbourhood. + GalaxyGenConfig config = cfg(0.9d, SPACING); + 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)); - if (!anchor.isPresent()) { - continue; - } - checkedAny = true; - // Every cell of the super-cell attributes to the SAME anchor (corners included). - for (long dx : new long[] {0, s - 1}) { - 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)); + for (GalacticCoord anchor : gen.anchorsInTerritory(SEED, cell(sup * s, 0, 0), 64)) { + checkedAny = true; + // The anchor itself point-resolves to the system, and to itself. + assertTrue(gen.systemAt(SEED, anchor).isPresent()); + assertEquals("an anchor must attribute to itself", + Optional.of(anchor), gen.anchorAt(SEED, anchor)); + + for (SystemBody body : gen.bodiesFor(SEED, anchor)) { + assertEquals("body " + body.name().cellKey() + " of the system at " + + anchor.cellKey() + " must attribute back to it", + Optional.of(anchor), gen.anchorAt(SEED, body.name())); } } - // The anchor itself point-resolves to the system. - assertTrue(gen.systemAt(SEED, anchor.get()).isPresent()); } assertTrue(checkedAny); } // ─── 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; + } + + /** + * How many seats a {@code (2r+1)³} block of super-cells holds, centred {@code offsetCells} out + * along +X from the origin — the origin being the home galaxy's centre. Sampling a BLOCK rather + * than a single super-cell is what makes the count a reading of the density there instead of one + * coin toss. + */ + /** + * How many STAR seats a block of super-cells holds — never how many seats of any kind. + * + *

    The difference is load-bearing at the shipped tuning. Free-floating worlds are drawn on the + * same lattice at a MEASURED twenty-one per star, which saturates it: past {@code 1/density} every + * cube the star draw passed over holds something, so a count of occupied seats is the constant + * "all of them" and discriminates neither the density nor the galaxy profile. Both of the tests + * below exist to show that those two DO drive the star field, so both must count stars.

    + */ + private static int seatsInBlockAround(ClusteredGalaxyGenerator gen, long offsetCells, long r) { + Set seen = new HashSet<>(); + for (long x = -r; x <= r; x++) { + for (long y = -r; y <= r; y++) { + for (long z = -r; z <= r; z++) { + Optional a = gen.anchorAt(SEED, + cell(offsetCells + x * SPACING, y * SPACING, z * SPACING)); + if (a.isPresent() && gen.systemAt(SEED, a.get()).get().star().isPresent()) { + seen.add(a.get().cellKey()); + } + } + } + } + return seen.size(); + } + + /** + * The STAR seats of a sweep, by cell key — see {@link #seatsInBlockAround} for why it is stars and + * not seats of any kind: the unbound draw saturates the lattice at the shipped tuning, so a count + * of everything is the constant "every cube" and measures nothing. + */ + private static Set occupiedSeats(ClusteredGalaxyGenerator gen, long seed, long spacing, + long r) { + Set keys = new HashSet<>(); + for (GalacticCoord a : anchors(gen, seed, spacing, r)) { + if (gen.systemAt(seed, a).get().star().isPresent()) { + keys.add(a.cellKey()); + } + } return keys; } + + // ─── the retinue an AUTHORED system gets: one generator, never two ───────── + + private static zmaster587.advancedRocketry.api.dimension.solar.StellarBody authoredStar() { + zmaster587.advancedRocketry.api.dimension.solar.StellarBody star = + new zmaster587.advancedRocketry.api.dimension.solar.StellarBody(); + star.setName("Authored"); + star.setId(0); + star.setSize(1f); + star.setTemperature(100); + return star; + } + + @Test + public void anAuthoredSystemsDerivedRetinueIsTheSameEverySave() { + // The whole reason the legacy generator had to go: it drew from + // new Random(System.currentTimeMillis()), so two saves of one seed held different worlds and + // nothing about a system could be predicted, reproduced or reported. + ClusteredGalaxyGenerator g = new ClusteredGalaxyGenerator(defaultsCfg()); + GalacticCoord anchor = cell(0, 0, 0); + + List first = g.authoredRetinueFor(SEED, anchor, authoredStar(), 0, 6, + java.util.Collections.emptySet()); + List again = g.authoredRetinueFor(SEED, anchor, authoredStar(), 0, 6, + java.util.Collections.emptySet()); + + assertEquals("the same seed must produce the same system, body for body", first, again); + assertTrue("...and it must actually produce one", first.size() > 1); + + List otherSeed = g.authoredRetinueFor(SEED + 1L, anchor, authoredStar(), 0, 6, + java.util.Collections.emptySet()); + assertNotEquals("a different seed must produce a different system, or the derivation ignores" + + " its seed and determinism is vacuous", first, otherSeed); + } + + @Test + public void aPacksBodyCountBoundsWhatItsStarGets() { + // The pack-facing knob the legacy generator consumed: a pack that asks for more worlds gets + // more of them. Stated as a bound rather than an equality, because the drawn orbits still + // decide how many FIT — a system squeezed by its neighbours holds fewer worlds rather than + // the same worlds at the wrong distances. + ClusteredGalaxyGenerator g = new ClusteredGalaxyGenerator(defaultsCfg()); + GalacticCoord anchor = cell(0, 0, 0); + + int few = majorBodies(g.authoredRetinueFor(SEED, anchor, authoredStar(), 0, 2, + java.util.Collections.emptySet())); + int many = majorBodies(g.authoredRetinueFor(SEED, anchor, authoredStar(), 0, 10, + java.util.Collections.emptySet())); + + assertTrue("asking for two must not hand out more than two worlds, got " + few, few <= 2); + assertTrue("asking for ten must hand out more than asking for two (" + few + " -> " + many + + ")", many > few); + } + + @Test + public void anAuthoredWorldsCellIsNeverTakenByADerivedOne() { + // The authored system wins: a pack's own world may not be displaced, or shadowed, by a body + // the generator drew. + ClusteredGalaxyGenerator g = new ClusteredGalaxyGenerator(defaultsCfg()); + GalacticCoord anchor = cell(0, 0, 0); + List free = g.authoredRetinueFor(SEED, anchor, authoredStar(), 0, 6, + java.util.Collections.emptySet()); + assertTrue("arrangement: the free draw must place something to reserve", free.size() > 1); + + java.util.Set reserved = new java.util.HashSet<>(); + for (SystemBody b : free) { + reserved.add(b.name().cellKey()); + } + for (SystemBody b : g.authoredRetinueFor(SEED, anchor, authoredStar(), 0, 6, reserved)) { + assertFalse("a derived body landed on a cell the authored system holds: " + + b.name().cellKey(), reserved.contains(b.name().cellKey())); + } + } + + private static int majorBodies(List bodies) { + int n = 0; + for (SystemBody b : bodies) { + if (b.kind() == SystemBodyKind.PLANET || b.kind() == SystemBodyKind.GAS_GIANT) { + n++; + } + } + return n; + } + + // ── a body's size is what its neighbours are measured against ───────────── + + @Test + public void aMoonStandsOutsideItsParent() { + // The defect this closes: a moon's orbit was an absolute length (4 000–26 000 blocks) chosen + // when a planet had no radius. Bodies then got one — an Earth is 25 513 blocks across and a + // Jupiter 280 643 — so essentially every moon was seated INSIDE its parent, and a giant's by an + // order of magnitude. + // + // The assertion is geometric and takes no number from production: at a fixed tick, the + // separation between a moon and its parent must exceed the parent's own radius. A test that + // pinned "2.5 radii" would pin the tuning; this pins that a moon is a thing you can see from + // the world it goes round. + ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(defaultsCfg()); + long tick = 12_345L; + int checkedMoons = 0; + int checkedParents = 0; + + for (long seed = 1L; seed <= 6L; seed++) { + for (GalacticCoord anchor : anchors(gen, seed, SPACING, 2)) { + List bodies = gen.bodiesFor(seed, anchor); + for (SystemBody moon : bodies) { + if (moon.kind() != SystemBodyKind.MOON) { + continue; + } + SystemBody parent = null; + for (SystemBody candidate : bodies) { + if (candidate != moon && candidate.definesFrame() + && candidate.name().cellKey().equals(moon.name().cellKey())) { + parent = candidate; + break; + } + } + if (parent == null || parent.radiusEarths() <= 0d) { + continue; + } + checkedParents++; + zmaster587.advancedRocketry.space.BlockDelta m = moon.inCellOffsetAt(tick); + zmaster587.advancedRocketry.space.BlockDelta p = parent.inCellOffsetAt(tick); + double ddx = (double) (m.dx() - p.dx()); + double ddy = (double) (m.dy() - p.dy()); + double ddz = (double) (m.dz() - p.dz()); + double separation = Math.sqrt(ddx * ddx + ddy * ddy + ddz * ddz); + double parentRadiusBlocks = + parent.radiusEarths() * AstronomicalBodyHelper.EARTH_RADIUS_BLOCKS; + assertTrue("a moon must stand outside the world it orbits: separation " + + Math.round(separation) + " blocks against a parent radius of " + + Math.round(parentRadiusBlocks) + " (" + parent.kind() + " at " + + parent.name().cellKey() + ")", + separation > parentRadiusBlocks); + checkedMoons++; + } + } + } + System.out.println("checked " + checkedMoons + " moons against " + checkedParents + " parents"); + assertTrue("arrangement: the sweep must find moons to check, or this proves nothing", + checkedMoons >= 10); + } + + // ── the constants say what they mean ────────────────────────────────────── + + @Test + public void theFieldStandsAsFarApartAsTheConstantSaysItDoes() { + // MEAN_STAR_SEPARATION_LY is a MEASURED astronomical quantity, so the lattice owes it as an + // OUTPUT, not as an input it happens to be spelled with. It used to be consumed as the cube + // edge, which is a different quantity: a cube of edge e filled with probability p puts its + // neighbours e/p^(1/3) apart, so the field stood 42 % further apart than the constant claimed + // and nothing said so. This test is the thing that would have said so. + GalaxyGenConfig config = GalaxyGenConfig.defaults(); + ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(config); + + int span = 8; // a 17-cube of territories: enough seats for the ratio to settle + int territories = 0; + int seated = 0; + for (int i = -span; i <= span; i++) { + for (int j = -span; j <= span; j++) { + for (int k = -span; k <= span; k++) { + territories++; + // STARS, not systems. The unbound population seats a rogue world in essentially + // every territory a star left empty, so counting systems measures occupancy 1.0 + // and says nothing about how far apart the STARS stand — which is the quantity + // MEAN_STAR_SEPARATION_LY is about. (Measured here first: 4913 of 4913.) + // What the whole TERRITORY holds. Two things are wrong with resolving its + // corner point instead: systemAt answers on the seat cell alone and a corner is + // not a seat, AND the lattice is divided uniformly, so one point is one seat in + // k-cubed — a sweep built on it measured a full field as 1.3 % occupied. + for (GalacticCoord anchor : gen.anchorsInTerritory(SEED, + cell((long) i * config.minSpacing, (long) j * config.minSpacing, + (long) k * config.minSpacing), 64)) { + Optional here = gen.systemAt(SEED, anchor); + if (here.isPresent() && here.get().star().isPresent()) { + seated++; + } + } + } + } + } + assertTrue("arrangement: the sweep must find a populated star field", seated > territories / 10); + + // Stars PER TERRITORY, which is what the separation formula wants and is no longer the same + // thing as "the fraction of territories that hold one": a territory now holds up to k-cubed + // seats, so the two numbers come apart the moment more than one of them is taken. + double occupancy = seated / (double) territories; + double separation = UniverseScale.meanSeparationLy(config.minSpacing, occupancy); + double claimed = UniverseScale.MEAN_STAR_SEPARATION_LY; + System.out.println("swept " + territories + " territories, seated " + seated + + " (occupancy " + occupancy + ") -> mean separation " + separation + " ly against " + + claimed); + + // A band, not a number: the galaxy's own profile scales the occupancy even at the centre, so + // the produced separation sits a little above the bare lattice's. What is pinned is that the + // constant DESCRIBES the field — a return to consuming it as an edge lands ~42 % out and red. + assertTrue("the field must stand about as far apart as MEAN_STAR_SEPARATION_LY claims: " + + separation + " ly against " + claimed, + separation > claimed * 0.85d && separation < claimed * 1.2d); + } + + @Test + public void aBlueStarIsAFindAndARedDwarfIsTheSky() { + // The weights are an observed census by NUMBER, so what they owe is the ORDER OF MAGNITUDE + // between classes, not any particular value. They read 40/25/20/10/5 before — a blue star in + // one system out of twenty, against an observed one in seven hundred and sixty, while the + // table's own comment called them rare. + List table = GalaxyGenConfig.defaults().starTypes; + assertEquals("arrangement: the stock table is the five-class one", 5, table.size()); + + for (int i = 1; i < table.size(); i++) { + assertTrue("a hotter class must never be commoner than a cooler one: " + + table.get(i - 1).temperature + " weighted " + table.get(i - 1).weight + + " against " + table.get(i).temperature + " weighted " + table.get(i).weight, + table.get(i).weight < table.get(i - 1).weight); + } + + GalaxyGenConfig.StarType coolest = table.get(0); + GalaxyGenConfig.StarType hottest = table.get(table.size() - 1); + assertTrue("a red dwarf must outnumber a blue star by at least two orders, as observed: " + + coolest.weight + " against " + hottest.weight, + coolest.weight >= hottest.weight * 100); + } + + // ── the derivation is part of the world model ───────────────────────────── + + /** A derivation that differs from version 1 in one law, and delegates the rest. */ + private static final class ShiftedDerivation implements IBodyDerivation { + private final IBodyDerivation base = BodyDerivationV0.INSTANCE; + + @Override + public double metallicityOf(long seed, GalacticCoord anchor) { + return base.metallicityOf(seed, anchor); + } + + @Override + public int referenceDistance(zmaster587.advancedRocketry.api.dimension.solar.StellarBody star) { + return base.referenceDistance(star); + } + + @Override + public int orbitalDistanceOf(long seed, GalacticCoord anchor, int index, int count, + zmaster587.advancedRocketry.api.dimension.solar.StellarBody star) { + return base.orbitalDistanceOf(seed, anchor, index, count, star) + 7; + } + + @Override + public double innerOrbit(zmaster587.advancedRocketry.api.dimension.solar.StellarBody star) { + return base.innerOrbit(star); + } + + @Override + public double outerOrbit(zmaster587.advancedRocketry.api.dimension.solar.StellarBody star) { + return base.outerOrbit(star); + } + + @Override + public int bareTemperature(zmaster587.advancedRocketry.api.dimension.solar.StellarBody star, + int orbitalDistance) { + return base.bareTemperature(star, orbitalDistance); + } + + @Override + public boolean tidallyLockedAt(zmaster587.advancedRocketry.api.dimension.solar.StellarBody star, + int orbitalDistance) { + return base.tidallyLockedAt(star, orbitalDistance); + } + + @Override + public boolean isGiantAt(long seed, GalacticCoord anchor, int index, int bareTemperatureK) { + return base.isGiantAt(seed, anchor, index, bareTemperatureK); + } + + @Override + public BodyProfile derive(long seed, GalacticCoord anchor, GalacticCoord bodyCell, int variant, + zmaster587.advancedRocketry.api.dimension.solar.StellarBody star, + boolean moon, int orbitalDistance) { + return base.derive(seed, anchor, bodyCell, variant, star, moon, orbitalDistance); + } + + @Override + public BodyProfile deriveRogue(long seed, GalacticCoord bodyCell, int variant, + double giantFraction) { + return base.deriveRogue(seed, bodyCell, variant, giantFraction); + } + + @Override + public int residualTemperature(double massEarths, double radiusEarths) { + return base.residualTemperature(massEarths, radiusEarths); + } + } + + @Test + public void aGeneratorDerivesItsBodiesThroughTheDerivationItWasGiven() { + // The point of the seam: a later schema can change what a body IS while the placement stands. + // If this passes with an unused parameter somewhere, the seam is decoration. + GalaxyGenConfig config = defaultsCfg(); + ClusteredGalaxyGenerator stock = new ClusteredGalaxyGenerator(config); + ClusteredGalaxyGenerator shifted = new ClusteredGalaxyGenerator(config, new ShiftedDerivation()); + + GalacticCoord anchor = null; + for (int i = 0; i < 64 && anchor == null; i++) { + GalacticCoord probe = GalacticCoord.ofSectorLocal((long) i * config.minSpacing, 0, 0, 0, 0, 0); + for (GalacticCoord found : stock.anchorsInTerritory(SEED, probe, 64)) { + // A system with a RETINUE. A territory's seats include unbound worlds, which hold one + // body and no orbit law to move — so a probe that took the first seat it found would + // compare two derivations on a system neither of them can express differently. + if (stock.systemAt(SEED, found).flatMap(sys -> sys.star()).isPresent() + && stock.bodiesFor(SEED, found).size() > 1) { + anchor = found; + break; + } + } + } + assertTrue("arrangement: a system with bodies must be found near the origin", anchor != null); + + List stockBodies = stock.bodiesFor(SEED, anchor); + List shiftedBodies = shifted.bodiesFor(SEED, anchor); + + // The retinue does not merely change VALUES, it changes SHAPE — a body's cell follows its + // orbital distance, so moving the orbit law moves which seats are claimed and how many fit. + // That is the strongest form of the claim being made here: the derivation is not a decoration + // on top of a fixed layout, it is part of what the world model IS, and it therefore has to + // travel with the schema version rather than with the jar. + assertNotEquals("a generator handed a different derivation must produce a different system — " + + "otherwise the derivation is not reachable from the schema at all", + describe(stockBodies), describe(shiftedBodies)); + } + + /** A system as a comparable string: every body's cell, kind and orbit, in a stable order. */ + private static String describe(List bodies) { + List lines = new ArrayList<>(); + for (SystemBody b : bodies) { + lines.add(b.name().cellKey() + ':' + b.kind() + ':' + b.orbitalDistance()); + } + Collections.sort(lines); + return lines.toString(); + } + + @Test + public void aGeneratorHandsOutTheDerivationItUses() { + // How everything outside this package reaches the world's derivation. Asking the class directly + // would pin version 1 forever, whatever schema the save is owed. + IBodyDerivation mine = new ShiftedDerivation(); + + assertSame("a generator must hand out the derivation it was built with", + mine, new ClusteredGalaxyGenerator(defaultsCfg(), mine).derivation()); + assertSame("and the stock one hands out version 1's", BodyDerivationV0.INSTANCE, + new ClusteredGalaxyGenerator(defaultsCfg()).derivation()); + } + + // ── the golden corpus ───────────────────────────────────────────────────── + + /** + * The released world model, rendered and compared byte for byte against a checked-in fixture. + * + *

    This is not a regression test, it is a VERSION DECISION. A save keeps what has been + * touched and re-derives everything else, so any change to what this renders moves systems in worlds + * that already exist. The fixture is what makes that visible before it ships:

    + * + *
      + *
    • No diff — the world model is unchanged; the release is a minor one and existing saves + * carry on under the same schema version.
    • + *
    • A diff, on a version that has REACHED A RELEASE — the world model has moved under + * worlds that exist, so the change needs a NEW schema version registered in + * {@code UniverseSchemas}, and this fixture is regenerated alongside it. Not a discussion: + * a diff here IS the definition of a different universe.
    • + *
    • A diff, on a version that has not shipped yet — the version is edited IN PLACE and + * the fixture regenerated with it. A model nobody outside the branch has ever generated a + * world under owes nobody compatibility, and minting a version for it would fill the registry + * with universes that never existed. "Shipped" means merged to the release branch, not + * landed on a feature branch.
    • + *
    + * + *

    Regenerate deliberately, never to make a red test green: + * {@code ./gradlew testUnit -Dadvancedrocketry.universe.corpus.write=true}

    + */ + @Test + public void theGoldenCorpusIsByteIdentical() throws Exception { + byte[] rendered = UniverseCorpus.render().getBytes(StandardCharsets.UTF_8); + + if (Boolean.getBoolean("advancedrocketry.universe.corpus.write")) { + File out = new File(UniverseCorpus.FIXTURE_PATH); + //noinspection ResultOfMethodCallIgnored + out.getParentFile().mkdirs(); + byte[] tmp = rendered; + try (FileOutputStream fos = new FileOutputStream(out)) { + fos.write(tmp); + } + fail("corpus rewritten to " + out.getPath() + " (" + tmp.length + " bytes). This is a " + + "DELIBERATE act: if the content changed, the world model changed, and the release " + + "needs a new universe schema version. Re-run without the write flag."); + } + + byte[] expected; + try (InputStream in = getClass().getResourceAsStream(UniverseCorpus.FIXTURE_RESOURCE)) { + assertNotNull("the golden corpus fixture is missing from the test resources: " + + UniverseCorpus.FIXTURE_RESOURCE, in); + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + byte[] chunk = new byte[8192]; + int read; + while ((read = in.read(chunk)) > 0) { + buf.write(chunk, 0, read); + } + expected = buf.toByteArray(); + } + + if (!Arrays.equals(expected, rendered)) { + fail("THE WORLD MODEL HAS MOVED. " + firstDifference( + new String(expected, StandardCharsets.UTF_8), + new String(rendered, StandardCharsets.UTF_8)) + + "\nEvery system nobody has visited moves with it, in every save generated under " + + "this version. If the change is NOT intended, this is the bug. If it is: a version " + + "that has already reached a release needs a NEW schema version in UniverseSchemas " + + "beside it, while a version that has not shipped yet is edited in place — it owes " + + "nobody compatibility. Either way the fixture is regenerated deliberately, with " + + "-Dadvancedrocketry.universe.corpus.write=true."); + } + } + + /** The first line that differs, quoted — a byte offset alone says nothing about what moved. */ + private static String firstDifference(String expected, String actual) { + String[] e = expected.split("\n", -1); + String[] a = actual.split("\n", -1); + for (int i = 0; i < Math.max(e.length, a.length); i++) { + String le = i < e.length ? e[i] : ""; + String la = i < a.length ? a[i] : ""; + if (!le.equals(la)) { + return "line " + (i + 1) + ":\n fixture: " + le + "\n now: " + la; + } + } + return "the two differ in length only (" + expected.length() + " vs " + actual.length() + ")"; + } + + /** + * Renders the observable universe of a fixed set of seeds over a fixed region — the whole schema, + * not the generator alone. + * + *

    Four members, and each is sampled where a change to it would show:

    + *
      + *
    • {@code IGalaxyGenerator} — which territories hold a system, its identity, and the cells its + * bodies stand in;
    • + *
    • {@code PlanetDerivation} — a profile derived at each body's real inputs. Deliberately a + * SAMPLE at a fixed variant rather than a claim about what the generator built internally: + * its purpose is to be a canary on the derivation, and a canary that reproduced the + * generator's private choices would be pinning implementation instead;
    • + *
    • {@code UniverseScale} — the metric constants and both conversions, because a light year + * that becomes a different number of cells relocates everything at once;
    • + *
    • {@code Cosmology} — the expansion factor at fixed ticks.
    • + *
    + * + *

    Rendering rules: LF only, every list sorted, doubles through {@link Double#toString} (exact and + * locale-free — a formatted number would hide a change in its last digits and change with a locale).

    + */ + static final class UniverseCorpus { + + static final String FIXTURE_RESOURCE = "/universe/golden-corpus-v1.txt"; + static final String FIXTURE_PATH = "src/test/resources/universe/golden-corpus-v1.txt"; + + /** Fixed seeds. Arbitrary, and that is the point — they are frozen, not chosen for an outcome. */ + private static final long[] SEEDS = { + 1L, 42L, 1337L, 8675309L, -1L, 6_942_069L, 2_147_483_647L, + }; + + /** Territories swept per axis, centred on the origin — the home galaxy's centre. */ + private static final int SPAN = 1; + + private UniverseCorpus() { + } + + static String render() { + GalaxyGenConfig config = GalaxyGenConfig.defaults(); + StringBuilder sb = new StringBuilder(64 * 1024); + sb.append("# universe golden corpus - schema ").append(UniverseSchemas.CURRENT).append('\n'); + sb.append("config ").append(config.fingerprint()).append('\n'); + renderScale(sb); + renderCosmology(sb); + for (long seed : SEEDS) { + renderSeed(sb, config, seed); + } + return sb.toString(); + } + + private static void renderScale(StringBuilder sb) { + sb.append("scale spacingCells=").append(UniverseScale.DEFAULT_SPACING_CELLS) + .append(" galaxySpacingCells=").append(UniverseScale.DEFAULT_GALAXY_SPACING_CELLS) + .append(" seatMarginCells=").append(UniverseScale.SEAT_MARGIN_CELLS).append('\n'); + double[] lightYears = {0.1d, 1d, 4.23d, 100d, 50_000d}; + for (double ly : lightYears) { + long cells = UniverseScale.cellsForLightYears(ly); + sb.append("scale ly=").append(Double.toString(ly)) + .append(" cells=").append(cells) + .append(" backLy=").append(Double.toString(UniverseScale.lightYearsForCells(cells))) + .append('\n'); + } + } + + private static void renderCosmology(StringBuilder sb) { + long[] ticks = {0L, 24_000L, 24_000_000L}; + for (long tick : ticks) { + sb.append("cosmology tick=").append(tick) + .append(" scaleFactor=").append(Double.toString(Cosmology.scaleFactorAt(tick))) + .append('\n'); + } + } + + private static void renderSeed(StringBuilder sb, GalaxyGenConfig config, long seed) { + ClusteredGalaxyGenerator g = new ClusteredGalaxyGenerator(config); + long step = config.minSpacing; + Set seen = new HashSet<>(); + List lines = new ArrayList<>(); + for (int i = -SPAN; i <= SPAN; i++) { + for (int j = -SPAN; j <= SPAN; j++) { + for (int k = -SPAN; k <= SPAN; k++) { + GalacticCoord probe = GalacticCoord.ofSectorLocal(i * step, j * step, k * step, + 0L, 0L, 0L); + Optional anchor = g.anchorAt(seed, probe); + if (!anchor.isPresent() || !seen.add(anchor.get().cellKey())) { + continue; + } + renderSystem(lines, g, seed, anchor.get()); + } + } + } + Collections.sort(lines); + sb.append("seed ").append(seed).append(" systems=").append(seen.size()).append('\n'); + for (String line : lines) { + sb.append(line).append('\n'); + } + } + + private static void renderSystem(List out, ClusteredGalaxyGenerator g, long seed, + GalacticCoord anchor) { + Optional systemOpt = g.systemAt(seed, anchor); + if (!systemOpt.isPresent()) { + return; + } + PlanetarySystem system = systemOpt.get(); + StringBuilder head = new StringBuilder(); + head.append(" system ").append(anchor.cellKey()) + .append(" id=").append(system.systemId()) + .append(" kind=").append(system.primaryKind()) + .append(" name=").append(system.name()); + if (system.star().isPresent()) { + head.append(" starTemp=").append(system.star().get().getTemperature()) + .append(" starSize=").append(Double.toString(system.star().get().getSize())); + } else { + head.append(" starless"); + } + out.add(head.toString()); + + List bodies = new ArrayList<>(g.bodiesFor(seed, anchor)); + List bodyLines = new ArrayList<>(); + // One derivation sample per distinct CELL, not per body: a moon stands in its parent's + // cell, so a per-body sample would render every profile twice and cover nothing extra. + Map byCell = new java.util.TreeMap<>(); + for (SystemBody body : bodies) { + bodyLines.add(renderBody(anchor, body)); + String key = body.name().cellKey(); + if (!byCell.containsKey(key)) { + byCell.put(key, body); + } + } + Collections.sort(bodyLines); + out.addAll(bodyLines); + for (Map.Entry e : byCell.entrySet()) { + out.add(renderDerivation(g, seed, anchor, system, e.getValue())); + } + } + + /** One fixed instant, so an orbiting body has a POSITION the corpus can compare. */ + private static final long OBSERVED_TICK = 12_345L; + + private static String renderBody(GalacticCoord anchor, SystemBody body) { + // The in-cell OFFSET is rendered, and it has to be: a moon carries its PARENT's orbital + // distance in orbitalDistance() and stands in its parent's cell, so identity and radius + // alone leave a moon's position entirely unobserved — the corpus stayed byte-identical + // across a change that moved every moon in the universe. + zmaster587.advancedRocketry.space.BlockDelta at = body.inCellOffsetAt(OBSERVED_TICK); + return " body " + anchor.cellKey() + ' ' + body.name().cellKey() + + " kind=" + body.kind() + + " orbit=" + body.orbitalDistance() + + " radius=" + Double.toString(body.radiusEarths()) + + " starId=" + body.starId() + + " frame=" + body.definesFrame() + + " at=" + at.dx() + ',' + at.dy() + ',' + at.dz(); + } + + private static String renderDerivation(ClusteredGalaxyGenerator g, long seed, + GalacticCoord anchor, PlanetarySystem system, + SystemBody body) { + BodyProfile profile = system.star().isPresent() + ? PlanetDerivation.derive(seed, anchor, body.name(), 0, system.star().get(), false, + body.orbitalDistance()) + : PlanetDerivation.deriveRogue(seed, body.name(), 0, + g.config().rogue.giantFraction); + return " derived " + anchor.cellKey() + ' ' + body.name().cellKey() + + " type=" + profile.typeName() + + " mass=" + Double.toString(profile.massEarths()) + + " radius=" + Double.toString(profile.radiusEarths()) + + " gravity=" + profile.gravityPercent() + + " pressure=" + profile.pressure() + + " tempK=" + profile.temperatureKelvin() + + " oxygen=" + profile.hasOxygen() + + " locked=" + profile.tidallyLocked() + + " rings=" + profile.hasRings() + + " rotation=" + profile.rotationalPeriodTicks() + + " metallicity=" + Double.toString(profile.metallicity()) + + " terrain=" + profile.terrain(); + } + } } diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/DescentShellTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/DescentShellTest.java index d105b30e2..1bac54a88 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/unit/DescentShellTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/DescentShellTest.java @@ -98,4 +98,47 @@ public void theRangeIsShorterThanTheDistanceByTheWholeShell() { assertEquals("the readout must differ from the centre distance by exactly the shell", R, d - DescentShell.distanceToShell(d, R), 0d); } + + // ── the shell is a property of the BODY ─────────────────────────────────── + + /** A body of {@code radiusEarths}, standing at the origin, with nothing else stated. */ + private static zmaster587.advancedRocketry.universe.SystemBody sized(double radiusEarths) { + return zmaster587.advancedRocketry.universe.SystemBody.fixedAt( + zmaster587.advancedRocketry.space.GalacticCoord.ORIGIN, + zmaster587.advancedRocketry.universe.SystemBodyKind.PLANET, + zmaster587.advancedRocketry.api.Constants.INVALID_PLANET, 0) + .withRadius(radiusEarths); + } + + @Test + public void aShellStandsOutsideTheWorldItBounds() { + // The defect this closes: radiusAround ignored its argument and returned a flat 512 blocks, + // chosen when a body had no size. Against the radii that now exist that is 1/50 of an Earth + // and 1/548 of a Jupiter — the surface a descent fires at lay deep inside the world it belongs + // to. Every size the generator can produce is checked, not one convenient case. + double[] radii = {0.1d, 0.5d, 1d, 2.5d, 11d, 30d}; + for (double r : radii) { + zmaster587.advancedRocketry.universe.SystemBody body = sized(r); + long shell = zmaster587.advancedRocketry.space.DescentShell.radiusAround(body); + double surface = r * zmaster587.advancedRocketry.util.AstronomicalBodyHelper.EARTH_RADIUS_BLOCKS; + + assertTrue("a descent shell must stand OUTSIDE the body it bounds: " + shell + + " against a surface at " + Math.round(surface) + " (r=" + r + ")", + shell > surface); + assertTrue("and an entering ship must spawn outside that shell, or it arrives already " + + "inside the trigger it is flying towards (r=" + r + ")", + zmaster587.advancedRocketry.space.ShipEntryController.entryRingAround(body) > shell); + } + } + + @Test + public void aBodyWithNoSizeKeepsTheFlatProximityRadius() { + // A belt or a station slot is not a sphere and has no surface to stand above, so the constant + // is the right answer there rather than a fallback — and a body whose atmosphere would be + // thinner than a ship's manoeuvring scale keeps it too. + assertEquals(zmaster587.advancedRocketry.space.ShipEntryController.DESCENT_RADIUS_BLOCKS, + zmaster587.advancedRocketry.space.DescentShell.radiusAround(sized(0d))); + assertEquals(zmaster587.advancedRocketry.space.ShipEntryController.DESCENT_RADIUS_BLOCKS, + zmaster587.advancedRocketry.space.DescentShell.radiusAround(null)); + } } diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/DriveLadderTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/DriveLadderTest.java new file mode 100644 index 000000000..b4dde7580 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/DriveLadderTest.java @@ -0,0 +1,308 @@ +package zmaster587.advancedRocketry.test.unit; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import zmaster587.advancedRocketry.hyperdrive.DriveTier; +import zmaster587.advancedRocketry.hyperdrive.DriveTuning; +import zmaster587.advancedRocketry.hyperdrive.JumpSpeed; +import zmaster587.advancedRocketry.space.CellFrames; +import zmaster587.advancedRocketry.space.GalacticCoord; +import zmaster587.advancedRocketry.universe.ClusteredGalaxyGenerator; +import zmaster587.advancedRocketry.universe.GalaxyGenConfig; +import zmaster587.advancedRocketry.universe.PlanetarySystem; +import zmaster587.advancedRocketry.universe.UniverseScale; +import zmaster587.advancedRocketry.util.AstronomicalBodyHelper; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * The progression the hyperdrive family is built around: size buys POWER, the generation buys + * EFFICIENCY, and each generation owns one band of distance. + * + *

    What is pinned here is the SHAPE of the ladder and nothing about its tuning. A test that asserted + * "a full drive crosses a galaxy in 28 minutes" would fail the day anybody rebalanced, without anything + * having broken. What may be asserted is the relations that make the ladder a ladder:

    + *
      + *
    • a full build of each generation crosses ITS OWN band in the same time — that is what "one tier + * per band" means, and it holds at any exponent and any baseline speed;
    • + *
    • a route's total energy does not depend on drive POWER — the property that makes "the tier buys + * efficiency" arithmetic rather than a slogan;
    • + *
    • nothing is ever refused for being far;
    • + *
    • a fully built drive can open its own window — the invariant that decides how far the + * power law may be bent.
    • + *
    + */ +public class DriveLadderTest { + + /** Distances are quoted in light years and flown in blocks; this is the one conversion. */ + private static double blocksForLightYears(double lightYears) { + return lightYears * (double) AstronomicalBodyHelper.BLOCKS_PER_LIGHT_YEAR; + } + + /** A fully built generator of {@code tier}, hauling the baseline hull. */ + private static long fullBuildSpeed(DriveTier tier) { + return JumpSpeed.blocksPerTick(DriveTuning.MAX_DRIVE_POWER, + DriveTuning.PLACEHOLDER_SHIP_MASS, tier); + } + + // ── the ladder ──────────────────────────────────────────────────────────── + + @Test + public void aFullBuildOfEachGenerationCrossesITSOWNBandInTheSameTime() { + // THE defining property, and the reason a generation's efficiency is derived rather than + // chosen: a tier's efficiency IS the gap between its band and the previous one, so a player who + // has finished building one generation and then installs the next stands in the same relation to + // the new band as he did to the old. Independent of the exponent, the baseline speed and the + // hull mass — which is exactly why it is the thing worth pinning. + long interstellar = JumpSpeed.transitTicks( + blocksForLightYears(DriveTier.INTERSTELLAR.bandLightYears()), + fullBuildSpeed(DriveTier.INTERSTELLAR)); + long galactic = JumpSpeed.transitTicks( + blocksForLightYears(DriveTier.GALACTIC.bandLightYears()), + fullBuildSpeed(DriveTier.GALACTIC)); + + System.out.println(String.format( + "full build: interstellar band %.2f ly -> %d ticks (%.1f min); galactic band %.0f ly" + + " -> %d ticks (%.1f min)", + DriveTier.INTERSTELLAR.bandLightYears(), interstellar, interstellar / 1200d, + DriveTier.GALACTIC.bandLightYears(), galactic, galactic / 1200d)); + + assertTrue("a band that takes no time at all is not a flight", interstellar > 0L); + double ratio = galactic / (double) interstellar; + assertEquals("each generation must stand in the same relation to its own band as the previous" + + " one does to its own; the two crossings came out " + interstellar + " vs " + + galactic + " ticks", 1d, ratio, 0.01d); + } + + @Test + public void theGalacticGenerationIsWorthMoreThanEveryCoilOnTheShip() { + // A generation is only felt as an upgrade if it beats a MAXED build of the previous one, because + // installing it puts the player back at a handful of coils. This is the condition that decides + // how many tiers exist at all: a band gap smaller than what iron already buys is a tier that + // would make its owner slower. + double boughtBySize = DriveTuning.MAX_DRIVE_POWER / (double) DriveTuning.BASELINE_DRIVE_POWER; + double boughtByTier = DriveTier.GALACTIC.efficiency(); + System.out.println(String.format( + "size buys x%.0f (%d coils -> %d power); the galactic generation buys x%.0f", + boughtBySize, DriveTuning.MAX_COILS, DriveTuning.MAX_DRIVE_POWER, boughtByTier)); + assertTrue("a fresh galactic drive (" + (long) boughtByTier + "x) must beat a maxed" + + " interstellar one (" + (long) boughtBySize + "x), or installing it is a" + + " downgrade wearing an upgrade's name", + boughtByTier > boughtBySize); + } + + @Test + public void aRoutesENERGYdoesNotDependOnHowBigTheDriveIs() { + // The property that makes "size buys power, the tier buys efficiency" literal: ticks go as + // d.m/(eta.P) and the in-flight draw goes as P, so power cancels exactly. A bigger drive does + // not change the bill for a trip, only how fast it is paid. If this ever stops holding, size + // has started buying part of the efficiency and the two knobs have blurred into one. + double distance = blocksForLightYears(10d); + double small = JumpSpeed.routeEnergy(distance, DriveTuning.BASELINE_SHIP_MASS, + DriveTier.INTERSTELLAR); + double large = JumpSpeed.routeEnergy(distance, DriveTuning.BASELINE_SHIP_MASS, + DriveTier.INTERSTELLAR); + assertEquals("route energy must not read drive power at all", small, large, 0d); + + // And the same claim measured THROUGH the speed law rather than off the closed form, because the + // closed form is where the cancellation could be true while the flight disagreed. + assertEquals("the closed form and the flight must agree on the bill", + flownEnergy(distance, DriveTuning.BASELINE_DRIVE_POWER, DriveTier.INTERSTELLAR), + flownEnergy(distance, DriveTuning.MAX_DRIVE_POWER, DriveTier.INTERSTELLAR), + flownEnergy(distance, DriveTuning.BASELINE_DRIVE_POWER, DriveTier.INTERSTELLAR) + * 0.02d); + + // A later generation is CHEAPER per unit distance — that is what efficiency means. + assertTrue("a galactic drive must cost less energy for the same leg", + JumpSpeed.routeEnergy(distance, DriveTuning.BASELINE_SHIP_MASS, DriveTier.GALACTIC) + < small); + } + + /** The bill as actually flown: the per-tick draw times the ticks the speed law produces. */ + private static double flownEnergy(double distance, long drivePower, DriveTier tier) { + long speed = JumpSpeed.blocksPerTick(drivePower, DriveTuning.BASELINE_SHIP_MASS, tier); + long ticks = JumpSpeed.transitTicks(distance, speed); + return ticks * drivePower * DriveTuning.IN_FLIGHT_DRAW_PER_POWER; + } + + @Test + public void aBaselineDriveAimedAcrossInterstellarSpaceIsNOTrefused() { + // A generation is a coefficient, never a licence. The first drive a player builds, aimed at + // something absurdly far, departs and takes what it takes — the barrier is then life support + // and generation over that duration, which are real systems, rather than a red message. + long speed = JumpSpeed.blocksPerTick(DriveTuning.BASELINE_DRIVE_POWER, + DriveTuning.PLACEHOLDER_SHIP_MASS, DriveTier.INTERSTELLAR); + long ticks = JumpSpeed.transitTicks( + blocksForLightYears(DriveTier.GALACTIC.bandLightYears()), speed); + + assertTrue("a baseline drive must still have a speed", speed > 0L); + assertTrue("and a finite, statable duration for a trip it has no business making: " + ticks, + ticks > 0L && ticks < Long.MAX_VALUE); + System.out.println("a baseline drive crosses a galaxy in " + ticks + " ticks (" + + String.format("%.1f", ticks / 1728000d) + " in-game months) - unreasonable, not" + + " impossible"); + } + + // ── the invariant that bounds the power law ──────────────────────────────── + + @Test + public void aFULLYBUILTdriveMustBeAbleToOpenItsOwnWindow() { + // The invariant nobody had written down, and it is what decides how far the power law may be + // bent. Every energy cost of a drive is proportional to its power — the window burst above all — + // while the capacitor that pays that burst grows only with its COMPONENT count, which is capped. + // So the two ranges have to be checked against each other: a drive whose burst outruns any bank + // a player can build is REFUSED at the gate, which means growing it past some coil count makes + // it useless. That is a lock, and a lock is the one thing a cost may not become. + long fullBank = DriveTuning.CAPACITOR_BASE_CAPACITY + + (long) DriveTuning.MAX_CAPACITOR_COMPONENTS * DriveTuning.CAPACITY_PER_CELL; + long fullBurst = (long) Math.ceil(DriveTuning.MAX_DRIVE_POWER + * DriveTuning.BURST_COST_PER_POWER); + + System.out.println(String.format( + "at exponent %.2f a full drive is %d power, burst %d, against a full bank of %d" + + " (margin x%.2f)", + DriveTuning.COIL_POWER_EXPONENT, DriveTuning.MAX_DRIVE_POWER, fullBurst, fullBank, + fullBank / (double) fullBurst)); + + assertTrue("a fully built drive cannot open its window: burst " + fullBurst + + " against a full capacitor bank of " + fullBank + + ". Raising COIL_POWER_EXPONENT needs the capacitor economy re-derived with" + + " it — see that constant's javadoc for the measured collision.", + fullBurst <= fullBank); + } + + @Test + public void aBaselineDriveStillNEEDSacapacitorBank() { + // The other end of the same bound, and the reason it cannot be fixed by simply making the burst + // cheaper: a novice's window must cost more than the controller block holds on its own, or the + // capacitor stops being something he has to build. + long baselineBurst = (long) Math.ceil(DriveTuning.BASELINE_DRIVE_POWER + * DriveTuning.BURST_COST_PER_POWER); + assertTrue("a baseline window costs " + baselineBurst + ", which a bare controller (" + + DriveTuning.CAPACITOR_BASE_CAPACITY + ") already covers — the capacitor has" + + " stopped being a requirement", + baselineBurst > DriveTuning.CAPACITOR_BASE_CAPACITY); + } + + // ── the two knobs, and the derived numbers that must not detach ──────────── + + @Test + public void theBaselineIsWHATASEVENCOILGENERATORISWORTH_notALiteral() { + // The entry-level speed is a datum from play and must not move unless somebody moves it. It did + // move once, silently: the baseline power was a literal that stopped being the seven-coil figure + // the moment the power law gained an exponent. + assertEquals("the baseline power must BE the baseline build's power", + DriveTuning.powerForCoils(DriveTuning.BASELINE_COILS), + DriveTuning.BASELINE_DRIVE_POWER); + assertEquals("so a baseline ship flies at exactly the baseline speed", + DriveTuning.BASELINE_SPEED_BLOCKS_PER_TICK, + JumpSpeed.blocksPerTick(DriveTuning.BASELINE_DRIVE_POWER, + DriveTuning.BASELINE_SHIP_MASS, DriveTier.INTERSTELLAR)); + } + + @Test + public void aDampenerAbsorbsAFRACTIONofABaselineArrival() { + // Expressed as a ratio, so what it promises — "a couple of these cover the ship a novice + // flies" — survives the speed law moving. As an absolute it became a rounding error on the next + // generation of drive the first time a tier multiplied every arrival. + long baseline = JumpSpeed.blocksPerTick(DriveTuning.BASELINE_DRIVE_POWER, + DriveTuning.BASELINE_SHIP_MASS, DriveTier.INTERSTELLAR); + long absorbed = DriveTuning.DAMPENER_ABSORBED_SPEED; + int needed = (int) Math.ceil(baseline / (double) absorbed); + + System.out.println("a baseline arrival of " + baseline + " needs " + needed + " dampener(s) at " + + absorbed + " each"); + assertEquals("one dampener must absorb the configured fraction of a baseline arrival", + DriveTuning.DAMPENER_ABSORBED_BASELINE_FRACTION, + absorbed / (double) baseline, 1e-9d); + assertTrue("and a baseline arrival must need more than one, or the dampener is free", + needed > 1); + } + + @Test + public void theGalacticEfficiencyISTheBandGap_notANumberSomebodyPicked() { + // Written as a literal it would be a number nobody could check, and one that silently stopped + // meaning "one band" the first time the star separation or the galaxy size was retuned. It rests + // on exactly two constants, and this is what says so. + double expected = 2d * UniverseScale.REFERENCE_GALAXY_RADIUS_LY + / UniverseScale.MEAN_STAR_SEPARATION_LY; + assertEquals("the galactic generation's efficiency must BE the star -> galaxy gap", expected, + DriveTier.GALACTIC.efficiency(), 1e-9d); + assertEquals("the baseline generation is the unit every other is quoted against", 1d, + DriveTier.INTERSTELLAR.efficiency(), 0d); + } + + // ── measured through the real generator, over the same 20 seeds ──────────── + + @Test + public void theInterstellarBandIsWhatTheGeneratorActuallyProduces() { + // The band figures above are arithmetic on two constants; this is the same span measured through + // the real generator, over the same 20 seeds the leg reading uses. If the generator's actual + // nearest-neighbour distance drifts away from the separation the ladder is derived against, the + // tiers are no longer aimed at the bands they are named for. + final double TOLERANCE_FACTOR = 2d; + + ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(GalaxyGenConfig.defaults()); + long stride = 4L * GalaxyGenConfig.DEFAULT_MIN_SPACING; + List legs = new ArrayList<>(); + for (long seed = 1L; seed <= 20L; seed++) { + Map all = gen.systemsInRegion(seed, + cell(-stride, -stride, -stride), cell(stride, stride, stride)); + // STAR systems only. Since the void was populated, an unbound world sits in essentially every + // territory the stars left empty, so "the nearest system" stopped meaning "the nearest star" — + // and a leg measured over all seats is the lattice EDGE rather than the separation this band is + // declared against. A jump is aimed at something a telescope found, which is a star. + java.util.Set found = new java.util.LinkedHashSet<>(); + for (Map.Entry e : all.entrySet()) { + if (e.getValue().star().isPresent()) { + found.add(e.getKey()); + } + } + GalacticCoord home = nearestTo(found, cell(0L, 0L, 0L)); + GalacticCoord neighbour = home == null ? null : nearestTo(found, home); + if (neighbour == null) { + continue; + } + legs.add(CellFrames.STATIC.distanceBetween(home, neighbour, 0L) + / (double) AstronomicalBodyHelper.BLOCKS_PER_LIGHT_YEAR); + } + Collections.sort(legs); + assertTrue("no seed produced a pair of systems to measure a leg from", !legs.isEmpty()); + + double median = legs.get(legs.size() / 2); + double declared = DriveTier.INTERSTELLAR.bandLightYears(); + System.out.println(String.format( + "interstellar band: declared %.2f ly, measured median %.2f ly over %d seeds" + + " (min %.2f, max %.2f)", + declared, median, legs.size(), legs.get(0), legs.get(legs.size() - 1))); + + assertTrue("the measured leg " + String.format("%.2f", median) + " ly is not the band the" + + " interstellar generation is named for (" + String.format("%.2f", declared) + + " ly) within a factor of " + TOLERANCE_FACTOR, + median >= declared / TOLERANCE_FACTOR && median <= declared * TOLERANCE_FACTOR); + } + + private static GalacticCoord cell(long sx, long sy, long sz) { + return GalacticCoord.ofSectorLocal(sx, sy, sz, 0L, 0L, 0L); + } + + private static GalacticCoord nearestTo(java.util.Collection cells, + GalacticCoord from) { + GalacticCoord best = null; + double bestDist = Double.MAX_VALUE; + for (GalacticCoord c : cells) { + double d = CellFrames.STATIC.distanceBetween(from, c, 0L); + if (d > 0d && d < bestDist) { + bestDist = d; + best = c; + } + } + return best; + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/FreeFlightAssistsTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/FreeFlightAssistsTest.java index 61b1b3800..a49e493a4 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/unit/FreeFlightAssistsTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/FreeFlightAssistsTest.java @@ -64,16 +64,16 @@ public void cutZeroesTheWholeSetpointInstantly() { public void rampReachesFullScaleInSixtyTicks() { double[] sp = {0, 0, 0}; for (int i = 0; i < 60; i++) sp = FreeFlightPhysics.rampSetpoint(sp[0], sp[1], sp[2], fwd(1f)); - assertEquals(FreeFlightPhysics.MAX_SPEED, sp[0], 1e-9); + assertEquals(FreeFlightPhysics.FA_SETPOINT_MAX_SPEED, sp[0], 1e-9); } @Test - public void setpointMagnitudeIsClampedToMaxSpeed() { + public void setpointMagnitudeIsClampedToTheAssistCeiling() { double[] sp = {0, 0, 0}; FreeFlightInput diag = new FreeFlightInput(1f, 1f, 1f, 0f, 0f, 0f, false); for (int i = 0; i < 300; i++) sp = FreeFlightPhysics.rampSetpoint(sp[0], sp[1], sp[2], diag); double mag = Math.sqrt(sp[0]*sp[0] + sp[1]*sp[1] + sp[2]*sp[2]); - assertEquals(FreeFlightPhysics.MAX_SPEED, mag, 1e-9); + assertEquals(FreeFlightPhysics.FA_SETPOINT_MAX_SPEED, mag, 1e-9); } @Test @@ -187,10 +187,22 @@ public void faStepEchoesOrientationUntouched() { assertEquals(-42f, s.pitch, DELTA); } - @Test - public void faSpeedIsHardCapped() { - Step s = FreeFlightPhysics.faStep(2.9, 0, 0.9, 0f, 0f, 3.0, 3.0, 0, 0.5, 0.0, true); + /** + * Switching the assist ON while flying faster than it can be asked for must not rewrite the + * craft's velocity: FA slows it down with the thrust it has, one budget per tick, like anything + * else. The assist's ceiling binds the SETPOINT (pinned above), never the motion. + * + *

    This is the leg that separates "the ceiling moved onto the setpoint" from "the ceiling is + * still on the velocity, one call later": a clamping build brings 100 blocks/tick back to 3 in a + * single step, which is a stop no engine paid for.

    + */ + @Test + public void faDeceleratesAnOverfastCraftAtItsThrustBudget() { + double entrySpeed = 100.0; + double budget = 0.5; + Step s = FreeFlightPhysics.faStep(0, 0, entrySpeed, 0f, 0f, 0, 0, 0, budget, 0.0, true); double speed = Math.sqrt(s.motionX*s.motionX + s.motionY*s.motionY + s.motionZ*s.motionZ); - assertTrue(speed <= FreeFlightPhysics.MAX_SPEED + DELTA); + assertEquals("FA must shed exactly the thrust budget, not the whole overspeed", + entrySpeed - budget, speed, DELTA); } } diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/FreeFlightPhysicsTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/FreeFlightPhysicsTest.java index ec1674251..79f8d845b 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/unit/FreeFlightPhysicsTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/FreeFlightPhysicsTest.java @@ -28,7 +28,9 @@ * - Climb gate: full vertical climbs iff thrustMag > gravity. * - Yaw/pitch rotate at MAX_*_RATE; pitch clamps to PITCH_MAX. * - canThrust=false → no thrust applied; gravity + rotation still act. - * - Brake attenuates motion; hard speed cap clamps to MAX_SPEED. + * - Brake attenuates motion; NOTHING caps speed — the bound is on acceleration, so + * burning for n ticks buys exactly n x MAX_THRUST_ACCEL and first cosmic velocity + * is reachable. * - Translation is body-relative: forward along the nose, strafe along the * horizontal right axis, vertical along the nose's up axis (tilts with pitch). * - Null input is tolerated (treated as zero). @@ -159,14 +161,69 @@ public void brakeAttenuatesHorizontalMotion() { assertTrue("brake must shrink motionX magnitude", Math.abs(s.motionX) < startX); } + /** + * With the assist off the law bounds ACCELERATION and nothing else: keep burning and you keep + * gaining speed, without limit. + * + *

    The per-tick gain is asserted alongside the total, and that pairing is the test: a build that + * removed the acceleration ceiling too would pass a "goes very fast" assertion, and a build that + * kept a speed cap anywhere would fail the total however small the cap was. The craft coasts + * unaccelerated for the last stretch as a control — a cap would bite there too.

    + */ @Test - public void hardSpeedCapClampsMagnitudeToMaxSpeed() { - Step s = FreeFlightPhysics.step(10, 0, 0, 0f, 0f, FreeFlightInput.zero(), - THRUST, 0.0, true); + public void newtonianFlightBoundsAccelerationAndNotSpeed() { + int burnTicks = 1000; + double previousSpeed = 0.0; + Step s = new Step(0, 0, 0, 0f, 0f, false); + for (int tick = 0; tick < burnTicks; tick++) { + s = FreeFlightPhysics.step(s.motionX, s.motionY, s.motionZ, 0f, 0f, + new FreeFlightInput(1f, 0f, 0f, 0f, 0f), + FreeFlightPhysics.MAX_THRUST_ACCEL, 0.0, true); + double speed = Math.sqrt(s.motionX * s.motionX + + s.motionY * s.motionY + s.motionZ * s.motionZ); + assertTrue("no tick may add more speed than the thrust ceiling; tick " + tick + + " added " + (speed - previousSpeed), + speed - previousSpeed <= FreeFlightPhysics.MAX_THRUST_ACCEL + DELTA); + previousSpeed = speed; + } + double expected = burnTicks * FreeFlightPhysics.MAX_THRUST_ACCEL; + assertEquals("burning for " + burnTicks + " ticks must buy every bit of the speed it paid for", + expected, previousSpeed, DELTA); + + // Control: release the throttle and the craft neither gains nor loses. A surviving cap + // anywhere in the law would show up here as a silent haircut. + Step coast = FreeFlightPhysics.step(s.motionX, s.motionY, s.motionZ, 0f, 0f, + FreeFlightInput.zero(), THRUST, 0.0, true); + double coastSpeed = Math.sqrt(coast.motionX * coast.motionX + + coast.motionY * coast.motionY + coast.motionZ * coast.motionZ); + assertEquals("coasting must preserve the speed exactly", expected, coastSpeed, DELTA); + } + + /** + * The number this law exists for: first cosmic velocity is 7.9 km/s, which in a metre-per-block + * world is 395 blocks/tick. Under the cap this file used to pin (3 blocks/tick) a rocket + * was short of orbital speed by a factor of ~130 — by its own numbers it could not reach orbit. + * + *

    Flown at 0.1 blocks/tick², an ordinary rocket at thrust-to-weight 2, in vacuum.

    + */ + @Test + public void aRocketAtOrdinaryThrustReachesFirstCosmicVelocity() { + double firstCosmicBlocksPerTick = 395.0; + double ordinaryAccel = 0.1; + int ticks = (int) Math.ceil(firstCosmicBlocksPerTick / ordinaryAccel); + + Step s = new Step(0, 0, 0, 0f, 0f, false); + for (int tick = 0; tick < ticks; tick++) { + s = FreeFlightPhysics.step(s.motionX, s.motionY, s.motionZ, 0f, 0f, + new FreeFlightInput(1f, 0f, 0f, 0f, 0f), + ordinaryAccel, 0.0, true); + } double speed = Math.sqrt(s.motionX * s.motionX + s.motionY * s.motionY + s.motionZ * s.motionZ); - assertTrue("hard cap: speed must not exceed MAX_SPEED, got " + speed, - speed <= FreeFlightPhysics.MAX_SPEED + DELTA); + assertTrue("a rocket accelerating at " + ordinaryAccel + " b/t2 must reach first cosmic" + + " velocity (" + firstCosmicBlocksPerTick + " b/t) after " + ticks + + " ticks of burn, got " + speed, + speed >= firstCosmicBlocksPerTick); } @Test diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/GalacticCoordTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/GalacticCoordTest.java index 8166e028e..5edf27744 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/unit/GalacticCoordTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/GalacticCoordTest.java @@ -2,6 +2,7 @@ import net.minecraft.nbt.NBTTagCompound; import org.junit.Test; +import zmaster587.advancedRocketry.space.AbsolutePos; import zmaster587.advancedRocketry.space.GalacticCoord; import static org.junit.Assert.assertEquals; @@ -30,15 +31,52 @@ private static void assertLocalCanonical(GalacticCoord c) { assertTrue("localZ in [-HALF, HALF)", c.localZ() >= -HALF && c.localZ() < HALF); } + + // The sector+local identity, read through the type that can hold it. GalacticCoord no longer + // materialises a whole-block absolute of its own: the product overflows a long seven orders + // before the sector index does, so the coordinate could name positions it could not express. + + private static AbsolutePos wholeOf(GalacticCoord c) { + return AbsolutePos.ofSectorLocal(c.sectorX(), c.sectorY(), c.sectorZ(), + c.localX(), c.localY(), c.localZ()); + } + + private static long blocksX(GalacticCoord c) { + return wholeOf(c).minus(AbsolutePos.ORIGIN).dx(); + } + + private static long blocksY(GalacticCoord c) { + return wholeOf(c).minus(AbsolutePos.ORIGIN).dy(); + } + + private static long blocksZ(GalacticCoord c) { + return wholeOf(c).minus(AbsolutePos.ORIGIN).dz(); + } + + @Test + public void aCoordinateBeyondTheOldBlockCeilingStillMeasuresCorrectly() { + // The defect R4 removes: sector * CELL overflows a long at 2.9e11 while a sector index runs + // to 9.2e18. Two coordinates out where the product cannot fit must still be a cell apart — + // under the old arithmetic the difference wrapped and came back small, pointing anywhere. + long farOut = 1_000_000_000_000L; // 3.4 orders past where the product stops fitting + GalacticCoord a = GalacticCoord.ofSectorLocal(farOut, 0L, 0L, 0L, 0L, 0L); + GalacticCoord b = GalacticCoord.ofSectorLocal(farOut + 1L, 0L, 0L, 0L, 0L, 0L); + + assertEquals("one cell apart, however far out they are", + (double) CELL, a.staticFrameDistanceTo(b), 1.0); + assertEquals("and the same measured through an absolute position", (double) CELL, + AbsolutePos.ofCellName(a).distanceTo(AbsolutePos.ofCellName(b)), 1.0); + } + @Test public void absoluteRoundTripWithinCell() { GalacticCoord c = GalacticCoord.ofAbsolute(123L, -456L, 789L); assertEquals(0L, c.sectorX()); assertEquals(0L, c.sectorY()); assertEquals(0L, c.sectorZ()); - assertEquals(123L, c.absoluteX()); - assertEquals(-456L, c.absoluteY()); - assertEquals(789L, c.absoluteZ()); + assertEquals(123L, blocksX(c)); + assertEquals(-456L, blocksY(c)); + assertEquals(789L, blocksZ(c)); assertLocalCanonical(c); } @@ -48,18 +86,18 @@ public void absoluteRoundTripAcrossManyCells() { long ay = -12L * CELL - 5L; long az = 4L * CELL - HALF; // lands exactly on a cell's lower edge GalacticCoord c = GalacticCoord.ofAbsolute(ax, ay, az); - assertEquals(ax, c.absoluteX()); - assertEquals(ay, c.absoluteY()); - assertEquals(az, c.absoluteZ()); + assertEquals(ax, blocksX(c)); + assertEquals(ay, blocksY(c)); + assertEquals(az, blocksZ(c)); assertLocalCanonical(c); } @Test public void sectorLocalIdentityHolds() { GalacticCoord c = GalacticCoord.ofSectorLocal(5L, -3L, 8L, 100L, -200L, 300L); - assertEquals(5L * CELL + 100L, c.absoluteX()); - assertEquals(-3L * CELL - 200L, c.absoluteY()); - assertEquals(8L * CELL + 300L, c.absoluteZ()); + assertEquals(5L * CELL + 100L, blocksX(c)); + assertEquals(-3L * CELL - 200L, blocksY(c)); + assertEquals(8L * CELL + 300L, blocksZ(c)); } @Test @@ -67,9 +105,9 @@ public void localOffsetIsRenormalisedWithSectorCarry() { // Local offsets far outside a cell must fold back in and carry into the sector. GalacticCoord c = GalacticCoord.ofSectorLocal(0L, 0L, 0L, CELL + 10L, -CELL - 10L, 3L * CELL); assertLocalCanonical(c); - assertEquals(CELL + 10L, c.absoluteX()); - assertEquals(-CELL - 10L, c.absoluteY()); - assertEquals(3L * CELL, c.absoluteZ()); + assertEquals(CELL + 10L, blocksX(c)); + assertEquals(-CELL - 10L, blocksY(c)); + assertEquals(3L * CELL, blocksZ(c)); } @Test @@ -119,7 +157,7 @@ public void cellCentreZeroesLocalAndStaysInCell() { assertEquals(0, centre.localY()); assertEquals(0, centre.localZ()); // The centre of sector s sits at absolute s*CELL. - assertEquals(7L * CELL, centre.absoluteX()); + assertEquals(7L * CELL, blocksX(centre)); } @Test @@ -152,7 +190,7 @@ public void integrationDoesNotDriftOverManySteps() { GalacticCoord oneShot = GalacticCoord.ORIGIN.plusLocal(7_000_000L, 0L, 0L); assertEquals(oneShot, stepwise); - assertEquals(7_000_000L, stepwise.absoluteX()); + assertEquals(7_000_000L, blocksX(stepwise)); } @Test @@ -161,7 +199,7 @@ public void plusLocalCarriesAcrossCellBoundary() { GalacticCoord crossed = near.plusLocal(20L, 0L, 0L); assertEquals(1L, crossed.sectorX()); assertLocalCanonical(crossed); - assertEquals(HALF + 10L, crossed.absoluteX()); + assertEquals(HALF + 10L, blocksX(crossed)); } @Test diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/GalaxyFieldTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/GalaxyFieldTest.java new file mode 100644 index 000000000..7895dc268 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/GalaxyFieldTest.java @@ -0,0 +1,891 @@ +package zmaster587.advancedRocketry.test.unit; + +import org.junit.Test; + +import java.util.Collections; +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.Cosmology; +import zmaster587.advancedRocketry.universe.GalacticAnchor; +import zmaster587.advancedRocketry.universe.GalacticFrame; +import zmaster587.advancedRocketry.universe.GalaxyKey; +import zmaster587.advancedRocketry.universe.Galaxy; +import zmaster587.advancedRocketry.universe.GalaxyField; +import zmaster587.advancedRocketry.universe.GalaxyGenConfig; +import zmaster587.advancedRocketry.universe.LightYearVector; +import zmaster587.advancedRocketry.universe.PlanetarySystem; +import zmaster587.advancedRocketry.universe.UniverseLawsV0; +import zmaster587.advancedRocketry.universe.UniverseScale; + +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 galaxy lattice — the tier above the star lattice. + * + *

    What is pinned: a galaxy is a pure function of {@code (seed, galaxy cell)}; the galaxy index is + * DERIVED from the sector and nothing is stored; a galaxy never straddles its own cell face, which is + * what makes "which galaxy is this point in" an O(1) question with one answer; radius is drawn + * CONDITIONAL ON TYPE; the home galaxy exists under every seed while still differing between them; + * and the cosmic-web hook is neutral today, so galaxy density comes out uniform.

    + */ +public class GalaxyFieldTest { + + private static GalaxyGenConfig cfg(double galaxyDensity) { + return new GalaxyGenConfig(GalaxyGenConfig.DEFAULT_MIN_SPACING, 0.9d, + GalaxyGenConfig.DEFAULT_GALAXY_SPACING, galaxyDensity, null, null); + } + + private static GalaxyField field(double galaxyDensity) { + return new GalaxyField(cfg(galaxyDensity), UniverseLawsV0.INSTANCE); + } + + @Test + public void theHomeGalaxyExistsUnderEverySeed() { + // Authored content is placed at absolute coordinates near the origin, and a galaxy is otherwise + // a hash draw that may simply not be there. Without the reserved cell the shipped solar system + // would land in intergalactic space on almost every seed. + GalaxyField f = field(GalaxyGenConfig.DEFAULT_GALAXY_DENSITY); + for (long seed = 1L; seed <= 200L; seed++) { + Galaxy home = f.home(seed); + assertNotNull("seed " + seed + " has no home galaxy", home); + assertTrue("seed " + seed + "'s home galaxy is only " + home.radiusLy() + + " ly across, under the guaranteed minimum", + home.radiusLy() >= UniverseScale.MIN_AUTHORED_GALAXY_RADIUS_LY); + assertTrue("the ORIGIN must be inside the home galaxy under seed " + seed, + home.containsSector(0L, 0L, 0L)); + // And out in the disc, not at the centre: the centre of a galaxy is its nucleus, which is + // the last address a shipped solar system should have. + double originRadius = home.localRadius( + -UniverseScale.lightYearsForCells(home.centre().sectorX()), + -UniverseScale.lightYearsForCells(home.centre().sectorY()), + -UniverseScale.lightYearsForCells(home.centre().sectorZ())); + assertEquals("the origin must sit at a sun-like galactic radius", + UniverseScale.HOME_GALAXY_ORIGIN_FRACTION * home.radiusLy(), originRadius, + home.radiusLy() * 1e-3d); + } + } + + @Test + public void theHomeGalaxyIsSeatedEvenWhenNoOtherGalaxyIs() { + // Its EXISTENCE is reserved, not its probability: a config that places no galaxies at all + // still has to have the one the player lives in. + GalaxyField f = field(0d); + assertNotNull(f.home(7L)); + int others = 0; + for (long gx = -3L; gx <= 3L; gx++) { + for (long gy = -3L; gy <= 3L; gy++) { + if (f.galaxyAtIndex(7L, gx, gy, 0L).isPresent() && !GalaxyField.isHomeCell(gx, gy, 0L)) { + others++; + } + } + } + assertEquals("galaxyDensity=0 must leave everything but the home cell void", 0, others); + } + + @Test + public void theHomeGalaxyStillDiffersBetweenSeeds() { + // Only its existence and its centre are fixed. If its type and size were fixed too, every + // world would open on the same sky. + GalaxyField f = field(GalaxyGenConfig.DEFAULT_GALAXY_DENSITY); + Set shapes = new HashSet<>(); + for (long seed = 1L; seed <= 50L; seed++) { + Galaxy home = f.home(seed); + shapes.add(home.type().name + "@" + (long) home.radiusLy()); + } + assertTrue("every seed produced the same home galaxy", shapes.size() > 1); + } + + @Test + public void aGalaxyIsAPureFunctionOfSeedAndCell() { + GalaxyField f = field(GalaxyGenConfig.DEFAULT_GALAXY_DENSITY); + for (long gx = -4L; gx <= 4L; gx++) { + Optional a = f.galaxyAtIndex(99L, gx, 1L, -2L); + Optional b = f.galaxyAtIndex(99L, gx, 1L, -2L); + assertEquals("presence must be stable", a.isPresent(), b.isPresent()); + if (a.isPresent()) { + assertEquals(a.get().toString(), b.get().toString()); + } + } + } + + @Test + public void theGalaxyIndexIsDerivedFromTheSector() { + // No stored tier, no new coordinate field: a coarse reading of the sector space that already + // exists. Every sector of one galaxy cell must name the same galaxy. + GalaxyGenConfig config = cfg(1.0d); + GalaxyField f = new GalaxyField(config, UniverseLawsV0.INSTANCE); + long s = config.galaxySpacing; + // The lattice is offset by half a cell, so the ORIGIN is a cell CENTRE. Without that, every + // sector with a negative coordinate would sit in a neighbouring cell and the space around the + // shipped solar system would be reading someone else's galaxy. + assertEquals(0L, GalaxyField.galaxyIndex(0L, s)); + assertEquals("just below the origin is still the home cell", 0L, + GalaxyField.galaxyIndex(-1L, s)); + assertEquals(0L, GalaxyField.galaxyIndex(-s / 2L, s)); + assertEquals(0L, GalaxyField.galaxyIndex(s / 2L - 1L, s)); + assertEquals("half a cell out is the next one", 1L, GalaxyField.galaxyIndex(s / 2L + 1L, s)); + assertEquals(-1L, GalaxyField.galaxyIndex(-s / 2L - 1L, s)); + assertEquals("the home cell's low corner is half a cell below the origin", -(s / 2L), + GalaxyField.cellLowCorner(0L, s)); + + Galaxy home = f.home(5L); + for (long probe : new long[] {-s / 2L, -1L, 0L, 1L, s / 3L, s / 2L - 1L}) { + Optional owning = f.galaxyOwningSector(5L, probe, 0L, 0L); + assertTrue("sector " + probe + " must belong to a galaxy cell that has one", + owning.isPresent()); + assertEquals("and it must be the same galaxy throughout the cell", home.toString(), + owning.get().toString()); + } + } + + @Test + public void everyPointIsInAGalaxyCellButNotEveryPointIsInAGalaxy() { + // The two questions are different and both have to be answerable: a galaxy occupies a small + // sphere inside its cell, and the rest of that cell is void. There is no "nowhere" state. + GalaxyField f = field(1.0d); + Galaxy home = f.home(11L); + long inside = UniverseScale.cellsForLightYears(home.radiusLy() * 0.5d); + long outside = UniverseScale.cellsForLightYears(home.radiusLy() * 4d); + + assertTrue(f.galaxyOwningSector(11L, inside, 0L, 0L).isPresent()); + assertTrue("a point at half the radius is in the galaxy", home.containsSector(inside, 0L, 0L)); + + Optional farOwner = f.galaxyOwningSector(11L, outside, 0L, 0L); + assertTrue("a point deep in the same cell still HAS an owning cell", farOwner.isPresent()); + assertEquals(home.toString(), farOwner.get().toString()); + assertFalse("but it is not inside the galaxy", home.containsSector(outside, 0L, 0L)); + assertEquals("so the profile there is zero", 0d, home.densityAtSector(outside, 0L, 0L), 0d); + } + + @Test + public void aGalaxyNeverStraddlesItsOwnCellFace() { + // Containment is what keeps three things true at once: at most one galaxy per cell, galaxies + // that cannot overlap, and an ownership answer that reads the containing cell and nothing else. + GalaxyGenConfig config = cfg(1.0d); + GalaxyField f = new GalaxyField(config, UniverseLawsV0.INSTANCE); + long s = config.galaxySpacing; + int checked = 0; + for (long gx = -3L; gx <= 3L; gx++) { + for (long gy = -2L; gy <= 2L; gy++) { + for (long gz = -2L; gz <= 2L; gz++) { + Optional g = f.galaxyAtIndex(4242L, gx, gy, gz); + if (!g.isPresent() || GalaxyField.isHomeCell(gx, gy, gz)) { + continue; + } + // The whole RETINUE's reach, not the primary's radius: satellites are children + // inside this cube, and one seated outside it is a galaxy the index would hand to + // a neighbouring cell. + long reach = UniverseScale.cellsForLightYears( + UniverseScale.retinueReachLy(g.get().radiusLy())); + assertInsideCell("x", g.get().centre().sectorX(), gx, s, reach); + assertInsideCell("y", g.get().centre().sectorY(), gy, s, reach); + assertInsideCell("z", g.get().centre().sectorZ(), gz, s, reach); + checked++; + } + } + } + assertTrue("the sweep must find galaxies", checked > 10); + } + + private static void assertInsideCell(String axis, long centre, long index, long spacing, + long reach) { + long lo = GalaxyField.cellLowCorner(index, spacing); + long hi = lo + spacing - 1L; + assertTrue("a galaxy reaches past its cell's low " + axis + " face", centre - reach >= lo); + assertTrue("a galaxy reaches past its cell's high " + axis + " face", centre + reach <= hi); + } + + @Test + public void radiusIsDrawnConditionalOnItsType() { + // Never independently. Independent draws produce dwarfs the size of a spiral and spirals the + // size of a dwarf — a real galaxy's type and its size are one fact, not two. + GalaxyField f = field(1.0d); + int checked = 0; + for (long gx = -6L; gx <= 6L; gx++) { + for (long gy = -3L; gy <= 3L; gy++) { + Optional g = f.galaxyAtIndex(31337L, gx, gy, 0L); + if (!g.isPresent()) { + continue; + } + GalaxyGenConfig.GalaxyType t = g.get().type(); + assertTrue(g.get() + " falls outside its own type's band", + g.get().radiusLy() >= t.minRadiusLy && g.get().radiusLy() <= t.maxRadiusLy); + assertTrue("a type with no arms must not carry a spiral's structure", + t.armCount >= 0); + checked++; + } + } + assertTrue(checked > 10); + } + + @Test + public void galaxyDensityDrivesHowManyGalaxiesThereAre() { + int sparse = countGalaxies(field(0.1d), 5L); + int dense = countGalaxies(field(0.9d), 5L); + assertTrue("a higher galaxyDensity must seat more galaxies (" + sparse + " vs " + dense + ")", + dense > sparse); + } + + @Test + public void galaxyDensityIsUniformWhileTheCosmicWebIsANeutralConstant() { + // The web slot exists and is deliberately the constant 1 today: galaxy density is not REQUIRED + // to be uniform, and this is where non-uniformity will live. Until it does, the occupied + // fraction must come out AT the configured density rather than biased by a half-built field. + GalaxyField f = field(0.5d); + int occupied = 0; + int total = 0; + for (long gx = -8L; gx <= 8L; gx++) { + for (long gy = -8L; gy <= 8L; gy++) { + for (long gz = -3L; gz <= 3L; gz++) { + if (GalaxyField.isHomeCell(gx, gy, gz)) { + continue; // reserved, so it is not a sample of the draw + } + total++; + if (f.galaxyAtIndex(6060L, gx, gy, gz).isPresent()) { + occupied++; + } + } + } + } + double fraction = occupied / (double) total; + assertEquals("the occupied fraction must sit at galaxyDensity", 0.5d, fraction, 0.05d); + } + + @Test + public void theVoidBetweenGalaxiesHoldsNothingThatFormedThere() { + // Outside every galaxy the BOUND profile is zero, so the intergalactic void is what the profile + // leaves empty rather than a second rule someone has to remember to apply. + // + // "Empty of stars", not "empty": what a ship meets out here is material the galaxies threw out, + // and that is the ejecta halo rather than the profile. This pins the half that has not moved — + // nothing CONDENSES out here — and VoidContentTest pins the half that has. + ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(cfg(1.0d)); + Galaxy home = gen.galaxies().home(77L); + // Past the whole RETINUE, not just past the primary: a satellite sits one to three diameters + // out, so probing at three radii would be probing inside a galaxy and this test would be + // asserting that a galaxy is empty. The void starts where the group ends. + long beyond = UniverseScale.cellsForLightYears( + UniverseScale.retinueReachLy(home.radiusLy()) * 1.5d); + long spacing = GalaxyGenConfig.DEFAULT_MIN_SPACING; + for (long i = 0; i < 40; i++) { + GalacticCoord probe = GalacticCoord.ofSectorLocal(beyond + i * spacing, 0L, 0L, 0L, 0L, 0L); + assertEquals("star-forming material turned up in intergalactic space at " + probe.cellKey(), + 0d, gen.galaxies().materialAtSector(77L, probe.sectorX(), probe.sectorY(), + probe.sectorZ()).bound, 0d); + } + } + + @Test + public void theGalaxyLatticeFitsTheSECTORSPACE_whichIsWhatNamesAPosition() { + // What actually bounds this layer, and what does NOT. + // + // It does not: a galaxy cube no longer fits one long of BLOCKS, and never had to. A position + // here is a cell NAME — a sector triple — plus an offset inside that cell, so the addressable + // range is the sector space, not a block count. This test used to assert the opposite, and + // that false constraint is what the galaxy scale had been compressed thirty-fold to satisfy. + long spacing = GalaxyGenConfig.DEFAULT_GALAXY_SPACING; + long blockLimitCells = Long.MAX_VALUE / GalacticCoord.CELL; + assertTrue("a galaxy cube that fits a long of blocks means the scale is still compressed: " + + spacing + " cells vs " + blockLimitCells, + spacing > blockLimitCells); + + // It does: the DIAGONAL of a galaxy cube has to be nameable, because a sector coordinate that + // wraps renames the cell. That is the real ceiling and it is orders away. + double diagonal = Math.sqrt(3d) * spacing; + double headroom = Long.MAX_VALUE / diagonal; + System.out.println(String.format( + "galaxy cube %d cells (%.3e ly), diagonal %.3e cells, sector headroom %.2ex", + spacing, UniverseScale.lightYearsForCells(spacing), diagonal, headroom)); + assertTrue("the galaxy lattice must fit the sector space with room to spare — headroom is only " + + String.format("%.2f", headroom) + "x", headroom >= 1000d); + } + + @Test + public void theReferenceSizeIsTheSizeTheTypeTableIsWrittenAgainst() { + // The reference anchors the galaxy SEPARATION, and the type bands are absolute light years so + // they can be checked against a catalogue. Nothing mechanical tied the two together, so the + // bands could sit two orders from the reference and nothing would notice — which is exactly + // what happened. This is that tie: the reference has to be a size an ordinary spiral IS. + GalaxyGenConfig config = GalaxyGenConfig.defaults(); + GalaxyGenConfig.GalaxyType spiral = typeNamed(config, "Spiral"); + assertTrue("the reference galaxy radius (" + UniverseScale.REFERENCE_GALAXY_RADIUS_LY + + " ly) falls outside the spiral band [" + spiral.minRadiusLy + ", " + + spiral.maxRadiusLy + "] — one of the two was moved without the other", + UniverseScale.REFERENCE_GALAXY_RADIUS_LY >= spiral.minRadiusLy + && UniverseScale.REFERENCE_GALAXY_RADIUS_LY <= spiral.maxRadiusLy); + } + + @Test + public void authoredContentIsAdmittedToTheDISCGIANTSandToNoDwarf() { + // The floor is a constraint on the TYPE DRAW, so what it really states is a SET: the classes a + // galaxy holding authored content may be. A floor that slipped below the dwarf-irregular band + // would let a pack's content be seated in an object a few thousand light years across and + // land outside it on the next seed. + GalaxyGenConfig config = GalaxyGenConfig.defaults(); + double floor = UniverseScale.MIN_AUTHORED_GALAXY_RADIUS_LY; + for (GalaxyGenConfig.GalaxyType t : config.galaxyTypes) { + boolean dwarf = t.name.startsWith("Dwarf"); + boolean qualifies = t.minRadiusLy >= floor; + assertEquals(t.name + " qualifies for authored content: expected " + !dwarf, + !dwarf, qualifies); + } + } + + private static GalaxyGenConfig.GalaxyType typeNamed(GalaxyGenConfig config, String name) { + for (GalaxyGenConfig.GalaxyType t : config.galaxyTypes) { + if (name.equals(t.name)) { + return t; + } + } + throw new AssertionError("the stock table has no type named " + name); + } + + /** + * The population a galaxy of this shape holds, at the SHIPPED densities. + * + *

    Estimated rather than counted: sweeping every super-cell of a real-sized galaxy is 10¹¹ + * draws. The profile is integrated by Monte Carlo over the galaxy's own sphere, and it is the same + * function the generator consults, so this measures the shipped shape and not a model of it.

    + */ + private static double estimateSystems(Galaxy galaxy, GalaxyGenConfig config) { + double superCellLy = UniverseScale.lightYearsForCells(config.minSpacing); + double sphereLy3 = 4d / 3d * Math.PI * Math.pow(galaxy.radiusLy(), 3); + double superCells = sphereLy3 / Math.pow(superCellLy, 3); + + // A fixed LCG, so the estimate is the same number on every run and a red is a real change. + long state = 0x2545F4914F6CDD1DL; + int samples = 200_000; + double sum = 0d; + for (int i = 0; i < samples; i++) { + double[] p = new double[3]; + for (int axis = 0; axis < 3; axis++) { + state = state * 6364136223846793005L + 1442695040888963407L; + p[axis] = ((state >>> 11) * 0x1.0p-53 - 0.5d) * 2d * galaxy.radiusLy(); + } + sum += galaxy.densityAt(p[0], p[1], p[2]); + } + // The samples fill the CUBE around the galaxy; densityAt is already zero outside the radius, + // so the cube mean scales straight onto the cube's volume. + double cubeLy3 = Math.pow(2d * galaxy.radiusLy(), 3); + double meanOverSphere = (sum / samples) * cubeLy3 / sphereLy3; + return config.density * superCells * meanOverSphere; + } + + /** A spiral at exactly the reference radius: the galaxy the whole layer is quoted against. */ + private static Galaxy referenceSpiral() { + return new Galaxy(0L, 0L, 0L, 0, GalacticCoord.ORIGIN, + typeNamed(GalaxyGenConfig.defaults(), "Spiral"), + UniverseScale.REFERENCE_GALAXY_RADIUS_LY, + 0d, 0d, Math.toRadians(20d), 0d, LightYearVector.ZERO, UniverseLawsV0.INSTANCE); + } + + @Test + public void aReferenceSpiralHoldsTenToTheEleventhSystems() { + // STATED BEFORE THE SWEEP. A galaxy at the reference radius, at the shipped star separation and + // the shipped disc thickness, must come out at the population a real one has: ~10^11. This is + // not a balance pin — it is the arithmetic that made the real scale choosable at all. Size, + // separation and population are ONE fact (pi.R^2.h at h = 1000 ly and ~76 ly^3 per seat), so a + // galaxy that came out at 10^6 here would mean the radius, the separation or the disc height + // had stopped agreeing with each other. + final double EXPECTED_SYSTEMS = 1e11d; + final double TOLERANCE_FACTOR = 3d; + + GalaxyGenConfig config = GalaxyGenConfig.defaults(); + Galaxy reference = referenceSpiral(); + double systems = estimateSystems(reference, config); + + System.out.println(String.format( + "reference spiral r=%.0f ly, disc height %.0f ly, star separation %.2f ly" + + " -> ~%.3e systems (expected %.0e +/- x%.0f)", + reference.radiusLy(), reference.radiusLy() * reference.type().scaleHeightRatio, + UniverseScale.MEAN_STAR_SEPARATION_LY, systems, EXPECTED_SYSTEMS, TOLERANCE_FACTOR)); + + assertTrue("a reference-sized galaxy holding ~" + String.format("%.3e", systems) + + " systems is not the 10^11 the scale was taken for", + systems >= EXPECTED_SYSTEMS / TOLERANCE_FACTOR + && systems <= EXPECTED_SYSTEMS * TOLERANCE_FACTOR); + } + + @Test + public void everySeedsHomeGalaxyIsAPlaceOfTheRightOrder() { + // The home galaxy's radius is DRAWN, so its population is not one number — a spiral at the + // small end of its band and a giant elliptical differ by three orders, which is what a drawn + // radius cubed means. The band here is therefore wide on purpose; what it guards is that no + // seed opens on a village, and that none opens on something the lattice cannot address. + GalaxyGenConfig config = GalaxyGenConfig.defaults(); + GalaxyField f = new GalaxyField(config, UniverseLawsV0.INSTANCE); + for (long seed : new long[] {0xC0FFEEL, 1L, 2L, 3L, 17L, 99L}) { + Galaxy home = f.home(seed); + double systems = estimateSystems(home, config); + System.out.println("seed " + seed + " home " + home + ": ~" + + String.format("%.3e", systems) + " systems"); + assertTrue("seed " + seed + "'s home galaxy holds only " + (long) systems + " systems", + systems > 1e9d); + assertTrue("seed " + seed + "'s home galaxy holds " + String.format("%.3e", systems) + + " systems, past the largest galaxy a catalogue has (~10^14 stars)", + systems < 3e14d); + } + } + + // ─── The retinue: satellite galaxies ─────────────────────────────────────── + + /** The separation in the primary's DIAMETERS — the unit the satellite band is stated in. */ + private static double diametersApart(Galaxy primary, Galaxy satellite) { + double dx = UniverseScale.lightYearsForCells( + (double) (satellite.centre().sectorX() - primary.centre().sectorX())); + double dy = UniverseScale.lightYearsForCells( + (double) (satellite.centre().sectorY() - primary.centre().sectorY())); + double dz = UniverseScale.lightYearsForCells( + (double) (satellite.centre().sectorZ() - primary.centre().sectorZ())); + return Math.sqrt(dx * dx + dy * dy + dz * dz) / (2d * primary.radiusLy()); + } + + @Test + public void aGiantKeepsARetinueAndADwarfKeepsNone() { + // The whole point of the feature: on the lattice alone the nearest galaxy is always 25 + // diameters away, because a cube holds one. A dwarf keeps none — it IS somebody's satellite. + GalaxyField f = field(1.0d); + int giantsWithRetinue = 0; + int checked = 0; + for (long gx = -6L; gx <= 6L; gx++) { + for (long gy = -3L; gy <= 3L; gy++) { + Optional g = f.galaxyAtIndex(31337L, gx, gy, 0L); + if (!g.isPresent()) { + continue; + } + Galaxy primary = g.get(); + int count = f.satellitesOf(31337L, primary).size(); + checked++; + if (primary.type().maxSatellites == 0) { + assertEquals(primary + " keeps no satellites", 0, count); + } else { + assertTrue(primary + " kept " + count + " satellites, outside its type's band [" + + primary.type().minSatellites + ", " + + primary.type().maxSatellites + "]", + count >= primary.type().minSatellites + && count <= primary.type().maxSatellites); + giantsWithRetinue++; + } + } + } + assertTrue("the sweep must find galaxies", checked > 10); + assertTrue("the sweep must find at least one galaxy that HAS a retinue, or this proves" + + " nothing about satellites at all", giantsWithRetinue > 0); + } + + @Test + public void aRetinueIsAPureFunctionOfSeedAndCell() { + // Same rule as the primary: nothing is stored, so two queries about the same group must never + // disagree — including across two GalaxyField instances, which is what a reload really is. + GalaxyField a = field(1.0d); + GalaxyField b = field(1.0d); + Galaxy primary = a.home(0xBEEFL); + List first = a.satellitesOf(0xBEEFL, primary); + List second = b.satellitesOf(0xBEEFL, b.home(0xBEEFL)); + + assertEquals("the retinue must have the same size on a fresh field", first.size(), + second.size()); + for (int i = 0; i < first.size(); i++) { + assertEquals(first.get(i).toString(), second.get(i).toString()); + } + } + + @Test + public void noTwoGalaxiesInACubeOverlap() { + // The single-answer invariant. Two overlapping spheres would make "which galaxy is this point + // in" a question with two answers, and every frame, profile and cluster read rests on it + // having one. It is geometry rather than a tie-break: a satellite is at least one full + // DIAMETER out and at most a fraction of the primary's radius across. + GalaxyField f = field(1.0d); + int pairs = 0; + for (long seed = 1L; seed <= 40L; seed++) { + Galaxy primary = f.home(seed); + List retinue = f.satellitesOf(seed, primary); + for (int i = 0; i < retinue.size(); i++) { + Galaxy s = retinue.get(i); + assertTrue(s + " is not smaller than its primary " + primary, + s.radiusLy() <= UniverseScale.MAX_SATELLITE_RADIUS_FRACTION + * primary.radiusLy()); + double d = diametersApart(primary, s); + assertTrue(s + " sits " + String.format("%.2f", d) + " diameters out, outside the band", + d >= UniverseScale.MIN_SATELLITE_DISTANCE_IN_DIAMETERS * 0.99d + && d <= UniverseScale.MAX_SATELLITE_DISTANCE_IN_DIAMETERS * 1.01d); + assertTrue(s + " overlaps its primary " + primary, + d * 2d * primary.radiusLy() > primary.radiusLy() + s.radiusLy()); + for (int j = i + 1; j < retinue.size(); j++) { + Galaxy other = retinue.get(j); + double sep = separationLy(s, other); + assertTrue(s + " overlaps " + other + " (" + (long) sep + " ly apart)", + sep > s.radiusLy() + other.radiusLy()); + pairs++; + } + } + } + assertTrue("the sweep must compare at least one PAIR of satellites, or the overlap check" + + " between two of them never executed", pairs > 0); + } + + private static double separationLy(Galaxy a, Galaxy b) { + double dx = UniverseScale.lightYearsForCells( + (double) (a.centre().sectorX() - b.centre().sectorX())); + double dy = UniverseScale.lightYearsForCells( + (double) (a.centre().sectorY() - b.centre().sectorY())); + double dz = UniverseScale.lightYearsForCells( + (double) (a.centre().sectorZ() - b.centre().sectorZ())); + return Math.sqrt(dx * dx + dy * dy + dz * dz); + } + + @Test + public void aSatelliteIsCloserThanTheNEARESTGIANT() { + // The measurement the feature exists for, stated as the comparison rather than as a number: + // the lattice spacing is the giant-to-giant distance and stays real, and the retinue fills in + // what was missing beneath it. + GalaxyField f = field(1.0d); + double lattice = UniverseScale.GALAXY_SEPARATION_IN_DIAMETERS; + int measured = 0; + double nearest = Double.MAX_VALUE; + for (long seed = 1L; seed <= 40L; seed++) { + Galaxy primary = f.home(seed); + for (Galaxy s : f.satellitesOf(seed, primary)) { + nearest = Math.min(nearest, diametersApart(primary, s)); + measured++; + } + } + assertTrue("no seed produced a satellite to measure", measured > 0); + System.out.println(String.format( + "nearest satellite over 40 seeds: %.2f diameters, against a lattice spacing of %.0f", + nearest, lattice)); + assertTrue("a satellite at " + String.format("%.2f", nearest) + " diameters is no closer than" + + " the lattice already put the nearest giant", nearest < lattice); + } + + @Test + public void aSatelliteIsNamedAPARTfromItsPrimary() { + // A satellite is a destination with an address. Two galaxies in one cube sharing a name would + // be two places a player could neither tell apart nor write down. + GalaxyField f = field(1.0d); + Galaxy primary = f.home(0xC0FFEEL); + List retinue = f.satellitesOf(0xC0FFEEL, primary); + assertTrue("the fixture needs a home galaxy WITH a retinue", !retinue.isEmpty()); + + Set names = new HashSet<>(); + assertTrue(names.add(primary.name())); + assertFalse("a primary must not report itself a satellite", primary.isSatellite()); + for (Galaxy s : retinue) { + assertTrue("two galaxies in one cube share the name " + s.name(), names.add(s.name())); + assertTrue(s + " must report itself a satellite", s.isSatellite()); + assertTrue("a satellite's name must be derived from its primary's: " + s.name(), + s.name().startsWith(primary.name() + "-S")); + } + assertEquals("a satellite keeps no retinue of its own — the group is one level deep", + 0, f.satellitesOf(0xC0FFEEL, retinue.get(0)).size()); + } + + @Test + public void aSatelliteIsAPLACE_withStarsOfItsOwn() { + // THE assumption the retinue was designed around, and the one nobody had checked: that the star + // field can be generated at an offset inside a parent's cube. It can — placement reads the + // profile of the galaxy CONTAINING a point, so a satellite is populated by the same generator + // that populates its primary. Had the profile been read off the cube's OWNER instead, every + // satellite would be named, addressable and completely empty, which is what this catches. + GalaxyGenConfig config = cfg(1.0d); + ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(config); + GalaxyField f = gen.galaxies(); + + long seed = 0xC0FFEEL; + Galaxy primary = f.home(seed); + List retinue = f.satellitesOf(seed, primary); + assertTrue("the fixture needs a home galaxy WITH a retinue", !retinue.isEmpty()); + Galaxy satellite = retinue.get(0); + + // Its centre is inside it, and the profile there is the SATELLITE's, not zero. + GalacticCoord core = satellite.centre(); + assertEquals("the cell at a satellite's centre must resolve to the satellite", + satellite.toString(), + f.galaxyContaining(seed, core).get().toString()); + assertTrue("a satellite's own profile at its centre must be positive", + satellite.densityAtSector(core.sectorX(), core.sectorY(), core.sectorZ()) > 0d); + assertEquals("and the cube's PRIMARY must read zero there — that is why the containing galaxy" + + " is the one to ask", 0d, + primary.densityAtSector(core.sectorX(), core.sectorY(), core.sectorZ()), 0d); + + // And the generator actually seats systems in it. + long stride = config.minSpacing; + Map found = gen.systemsInRegion(seed, + GalacticCoord.ofSectorLocal(core.sectorX() - 3L * stride, + core.sectorY() - 3L * stride, core.sectorZ() - 3L * stride, 0L, 0L, 0L), + GalacticCoord.ofSectorLocal(core.sectorX() + 3L * stride, + core.sectorY() + 3L * stride, core.sectorZ() + 3L * stride, 0L, 0L, 0L)); + System.out.println("satellite " + satellite + " holds " + found.size() + + " systems in the 7x7x7 territories around its core"); + assertFalse("a satellite with no systems in it is not a place anybody can go to", + found.isEmpty()); + } + + @Test + public void aCellInsideASatelliteIsBOUNDtoTheSATELLITE() { + // The frame decides both rotation and expansion, so getting this wrong does not make a + // satellite slightly wrong — it makes its interior comove with a void it is not in, while the + // primary it orbits turns. + GalaxyField f = field(1.0d); + long seed = 0xC0FFEEL; + Galaxy primary = f.home(seed); + List retinue = f.satellitesOf(seed, primary); + assertTrue("the fixture needs a home galaxy WITH a retinue", !retinue.isEmpty()); + Galaxy satellite = retinue.get(0); + GalacticCoord core = satellite.centre(); + + assertEquals("a cell inside a satellite is bound, not comoving", GalacticFrame.GALACTIC, + f.frameAt(seed, core)); + assertEquals("and its position is the SATELLITE's bound law", + satellite.boundPositionOfCellAt(core, 5_000L).toString(), + f.positionAt(seed, core, 5_000L).toString()); + + // The control: a point in the same cube but in no galaxy is still comoving. + long past = UniverseScale.cellsForLightYears( + UniverseScale.retinueReachLy(primary.radiusLy()) * 1.5d); + GalacticCoord voidCell = GalacticCoord.ofSectorLocal(primary.centre().sectorX() + past, + primary.centre().sectorY(), primary.centre().sectorZ(), 0L, 0L, 0L); + assertEquals("past the whole group, a cell is comoving again", GalacticFrame.COMOVING, + f.frameAt(seed, voidCell)); + } + + @Test + public void aSatelliteCarriesItsPrimarysMotionSoTheGroupTravelsTogether() { + // A group is bound: if a satellite drew its own peculiar velocity it would drift away from the + // galaxy it orbits over the drift horizon. The home galaxy's retinue must stand as still as + // the home galaxy does, or authored content's neighbours would leave it behind. + GalaxyField f = field(1.0d); + for (long seed : new long[] {1L, 7L, 0xC0FFEEL}) { + Galaxy home = f.home(seed); + for (Galaxy s : f.satellitesOf(seed, home)) { + assertEquals("the home galaxy's satellites must not drift either", 0d, + s.peculiarVelocity().length(), 0d); + } + Optional mover = f.galaxyAtIndex(seed, 3L, 1L, -2L); + if (mover.isPresent()) { + for (Galaxy s : f.satellitesOf(seed, mover.get())) { + assertEquals("a satellite travels with its primary", + mover.get().peculiarVelocity().toString(), + s.peculiarVelocity().toString()); + } + } + } + } + + // ─── The intergalactic regime (R3 + R8) ──────────────────────────────────── + + @Test + public void theHomeGalaxyHasNoMotionOfItsOwn() { + // It is the rest frame everything else is measured against: every other galaxy moves relative + // to it, which is also what an observer actually sees. What must NOT happen is authored + // content being left behind by its own galaxy — so the check is that the origin keeps its + // place INSIDE the galaxy, not that the galaxy sits still on a static grid it does not live on. + GalaxyField f = field(GalaxyGenConfig.DEFAULT_GALAXY_DENSITY); + for (long seed = 1L; seed <= 30L; seed++) { + Galaxy home = f.home(seed); + assertEquals("the home galaxy must have no peculiar velocity", 0d, + home.peculiarVelocity().length(), 0d); + double at0 = home.boundPositionOfCellAt(GalacticCoord.ORIGIN, 0L) + .distanceTo(home.centreAt(0L)); + for (long t : new long[] {1_000_000L, 1_000_000_000_000L}) { + assertEquals("authored content must ride its galaxy, not be left behind by it", at0, + home.boundPositionOfCellAt(GalacticCoord.ORIGIN, t).distanceTo(home.centreAt(t)), + at0 * 1e-9d); + } + } + } + + @Test + public void aGalaxyCannotDriftOutOfItsOwnCell() { + // The invariant peculiar velocity threatens: a galaxy that wandered into a neighbouring cell + // would break at-most-one-per-cell, non-overlap, AND the O(1) ownership answer at once. The + // bound is real code, and it is measured here rather than asserted — at realistic speeds it is + // orders away from binding, which is the finding. + GalaxyGenConfig config = cfg(1.0d); + GalaxyField f = new GalaxyField(config, UniverseLawsV0.INSTANCE); + double halfCellLy = UniverseScale.lightYearsForCells(config.galaxySpacing / 2d); + double worstFraction = 0d; + int checked = 0; + for (long gx = -4L; gx <= 4L; gx++) { + for (long gy = -2L; gy <= 2L; gy++) { + Optional g = f.galaxyAtIndex(2024L, gx, gy, 0L); + if (!g.isPresent() || GalaxyField.isHomeCell(gx, gy, 0L)) { + continue; + } + double drift = g.get().peculiarVelocity().length() + * (double) Cosmology.DRIFT_HORIZON_TICKS; + double room = halfCellLy - g.get().radiusLy(); + assertTrue(g.get() + " drifts " + drift + " ly against " + room + " ly of room", + drift <= room); + worstFraction = Math.max(worstFraction, drift / room); + checked++; + } + } + assertTrue(checked > 5); + System.out.println("worst galaxy drift over the horizon: " + + String.format("%.3e", worstFraction) + " of its available room"); + } + + @Test + public void aGalaxyDrawsARealisticPeculiarVelocity() { + GalaxyField f = field(1.0d); + int checked = 0; + for (long gx = -5L; gx <= 5L; gx++) { + Optional g = f.galaxyAtIndex(555L, gx, 3L, 0L); + if (!g.isPresent() || GalaxyField.isHomeCell(gx, 3L, 0L)) { + continue; + } + // 50..600 km/s, expressed in this layer's unit. + double speed = g.get().peculiarVelocity().length(); + assertTrue("a galaxy must actually move", speed > 0d); + assertTrue("and not faster than the band allows", + speed <= UniverseScale.lightYearsPerTick(600d) * 1.000001d); + checked++; + } + assertTrue(checked > 3); + } + + @Test + public void aPointIsEitherBoundToItsGalaxyOrComovingInTheVoid() { + // Two states and no third: there is no "nowhere". Every point belongs to exactly one galaxy + // CELL, and inside that cell it is either in the galaxy or in the void of it. + GalaxyField f = field(1.0d); + Galaxy home = f.home(11L); + GalacticCoord inside = GalacticCoord.ofSectorLocal( + UniverseScale.cellsForLightYears(home.radiusLy() * 0.5d), 0L, 0L, 0L, 0L, 0L); + GalacticCoord outside = GalacticCoord.ofSectorLocal( + UniverseScale.cellsForLightYears(home.radiusLy() * 4d), 0L, 0L, 0L, 0L, 0L); + + assertEquals(GalacticFrame.GALACTIC, f.frameAt(11L, inside)); + assertEquals(GalacticFrame.COMOVING, f.frameAt(11L, outside)); + } + + @Test + public void aBoundPointRotatesAndAVoidPointDoesNot() { + // The two laws, told apart by what they DO. A bound point turns with the disc and keeps its + // distance from the centre; a void point is carried by the Hubble flow and never rotates. + GalaxyField f = field(1.0d); + Galaxy home = f.home(11L); + long boundCells = UniverseScale.cellsForLightYears(home.radiusLy() * 0.5d); + GalacticCoord bound = GalacticCoord.ofSectorLocal(boundCells, 0L, 0L, 0L, 0L, 0L); + long t = 200_000_000_000L; // long enough that the slow rotation is measurable + + LightYearVector at0 = f.positionAt(11L, bound, 0L); + LightYearVector later = f.positionAt(11L, bound, t); + assertTrue("a bound point must move with the disc", later.distanceTo(at0) > 0d); + assertEquals("and keep its radius from the centre, because a galaxy does not expand", + at0.distanceTo(home.centreAt(0L)), later.distanceTo(home.centreAt(t)), + home.radiusLy() * 1e-9d); + + GalacticCoord voidCell = GalacticCoord.ofSectorLocal( + UniverseScale.cellsForLightYears(home.radiusLy() * 4d), 0L, 0L, 0L, 0L, 0L); + LightYearVector voidAt0 = f.positionAt(11L, voidCell, 0L); + LightYearVector voidLater = f.positionAt(11L, voidCell, t); + assertEquals("a void point is carried straight outwards, never sideways", 0d, + voidLater.y(), 1e-9d); + assertEquals("a void point is carried straight outwards, never sideways", 0d, + voidLater.z(), 1e-9d); + assertTrue("and it is carried by the Hubble flow", voidLater.x() > voidAt0.x()); + assertEquals("by exactly the scale factor", voidAt0.x() * Cosmology.scaleFactorAt(t), + voidLater.x(), voidAt0.x() * 1e-12d); + } + + // ─── Authored content is declared against a galaxy (R11) ─────────────────── + + @Test + public void aDeclaredGalaxyIsSeatedWhateverTheHashSays() { + // A galaxy is a hash draw and may simply not be there under another seed, while authored + // content must exist under EVERY seed. So naming a galaxy in the catalogue reserves its cell. + long seed = 424242L; + GalaxyField plain = field(0.2d); + GalaxyKey empty = null; + for (long gx = 1L; gx <= 40L && empty == null; gx++) { + if (!plain.galaxyAtIndex(seed, gx, 0L, 0L).isPresent()) { + empty = GalaxyKey.of(gx, 0L, 0L); + } + } + assertNotNull("the sweep must find a void galaxy cell to reserve", empty); + + GalaxyGenConfig reserved = cfg(0.2d).withReservedGalaxies(Collections.singletonList(empty)); + GalaxyField withKey = new GalaxyField(reserved, UniverseLawsV0.INSTANCE); + assertTrue("a declared key must force its cell to hold a galaxy", + withKey.galaxyAtIndex(seed, empty.gx(), empty.gy(), empty.gz()).isPresent()); + assertTrue(withKey.isReserved(empty.gx(), empty.gy(), empty.gz())); + assertTrue("and it must be reachable by key", withKey.declarationOriginOf(seed, empty).isPresent()); + } + + @Test + public void aGalaxyHoldingAuthoredContentIsDrawnBigEnoughForIt() { + // The guarantee is a constraint on the type DRAW, never a clamp applied afterwards: a pack + // that places a system 700 light years out must work on every seed. + long seed = 909L; + GalaxyKey key = GalaxyKey.of(6L, -2L, 3L); + GalaxyField f = new GalaxyField( + cfg(0.2d).withReservedGalaxies(Collections.singletonList(key)), UniverseLawsV0.INSTANCE); + Galaxy declared = f.galaxyAtIndex(seed, key.gx(), key.gy(), key.gz()).get(); + assertTrue("a reserved galaxy is only " + declared.radiusLy() + " ly across", + declared.radiusLy() >= UniverseScale.MIN_AUTHORED_GALAXY_RADIUS_LY); + } + + @Test + public void aHomeDeclarationResolvesToItself() { + // This is what centring the home galaxy on the ORIGIN buys, and it is the whole migration + // story: a coordinate authored before galaxies existed means exactly what it used to. + GalaxyField f = field(GalaxyGenConfig.DEFAULT_GALAXY_DENSITY); + GalacticCoord local = GalacticCoord.ofSectorLocal(1_500_000L, -20_000L, 7L, 0L, 0L, 0L); + GalacticAnchor anchor = GalacticAnchor.inHome(local); + assertEquals(local.cellKey(), + anchor.resolve(f.declarationOriginOf(3L, GalaxyKey.HOME)).cellKey()); + } + + @Test + public void aDeclarationInAnotherGalaxyResolvesAgainstThatGalaxysCentre() { + long seed = 77L; + GalaxyKey key = GalaxyKey.of(2L, 0L, 0L); + GalaxyField f = new GalaxyField( + cfg(1.0d).withReservedGalaxies(Collections.singletonList(key)), UniverseLawsV0.INSTANCE); + GalacticCoord centre = f.centreOf(seed, key).get(); + GalacticCoord local = GalacticCoord.ofSectorLocal(500_000L, 0L, 0L, 0L, 0L, 0L); + + GalacticCoord resolved = + GalacticAnchor.of(key, local).resolve(f.declarationOriginOf(seed, key)); + assertEquals(centre.sectorX() + 500_000L, resolved.sectorX()); + assertTrue("and it must land inside the galaxy it named", + f.galaxyAtIndex(seed, key.gx(), key.gy(), key.gz()).get() + .containsSector(resolved.sectorX(), resolved.sectorY(), resolved.sectorZ())); + } + + @Test + public void withNoGalaxyTierADeclarationIsAlreadyAbsolute() { + // An authored-only universe has nothing for a declaration to be local TO, so local and + // absolute coincide — which is both the only reading that can be right and the behaviour that + // existed before galaxies did. + GalacticCoord local = GalacticCoord.ofSectorLocal(42L, -7L, 3L, 0L, 0L, 0L); + assertEquals(local.cellKey(), + GalacticAnchor.inHome(local).resolve(Optional.empty()).cellKey()); + } + + private static int countGalaxies(GalaxyField f, long seed) { + int found = 0; + for (long gx = -5L; gx <= 5L; gx++) { + for (long gy = -5L; gy <= 5L; gy++) { + for (long gz = -2L; gz <= 2L; gz++) { + if (!GalaxyField.isHomeCell(gx, gy, gz) && f.galaxyAtIndex(seed, gx, gy, gz).isPresent()) { + found++; + } + } + } + } + return found; + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/GalaxyTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/GalaxyTest.java new file mode 100644 index 000000000..94fa1f091 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/GalaxyTest.java @@ -0,0 +1,279 @@ +package zmaster587.advancedRocketry.test.unit; + +import org.junit.Test; + +import zmaster587.advancedRocketry.space.GalacticCoord; +import zmaster587.advancedRocketry.universe.Cosmology; +import zmaster587.advancedRocketry.universe.Galaxy; +import zmaster587.advancedRocketry.universe.GalaxyGenConfig; +import zmaster587.advancedRocketry.universe.LightYearVector; +import zmaster587.advancedRocketry.universe.UniverseLawsV0; +import zmaster587.advancedRocketry.universe.UniverseScale; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Contract tests for one galaxy as a shape: what it contains, how its density falls off, and how it + * turns. Pure-JUnit; no MC bootstrap, no generator. + * + *

    What is pinned is the SHAPE of each law, never a tuned number: that the boundary is the declared + * radius and not a level of the profile, that density falls with radius and with height above the + * plane, that a disc really is flatter than it is wide, that the arms modulate rather than gate, and + * that rotation shears differently for a dwarf than for a massive spiral. The constants those laws + * carry are balance knobs and are fed in as inputs.

    + */ +public class GalaxyTest { + + private static final double RADIUS = 1500d; + + private static GalaxyGenConfig.GalaxyType spiral() { + return new GalaxyGenConfig.GalaxyType("Spiral", GalaxyGenConfig.GalaxyProfile.DISC, + 900d, 2200d, 0.02d, 2, 220d, 0.08d, 1, 3, 7); + } + + private static GalaxyGenConfig.GalaxyType smoothDisc() { + return new GalaxyGenConfig.GalaxyType("Smooth", GalaxyGenConfig.GalaxyProfile.DISC, + 900d, 2200d, 0.02d, 0, 220d, 0.08d, 1, 3, 7); + } + + private static GalaxyGenConfig.GalaxyType dwarf() { + return new GalaxyGenConfig.GalaxyType("Dwarf", GalaxyGenConfig.GalaxyProfile.SPHEROID, + 120d, 500d, 0.70d, 0, 20d, 0.90d, 0, 0, 700); + } + + /** A galaxy with its plane on the world's XZ plane, so a test can reason in plain coordinates. */ + private static Galaxy flat(GalaxyGenConfig.GalaxyType type) { + return new Galaxy(0L, 0L, 0L, 0, GalacticCoord.ORIGIN, type, RADIUS, 0d, 0d, + Math.toRadians(20d), 0d, LightYearVector.ZERO, UniverseLawsV0.INSTANCE); + } + + /** The same galaxy, seated away from the origin and moving — the subject of the R3 laws. */ + private static Galaxy adrift(GalacticCoord seat, LightYearVector velocity) { + return new Galaxy(1L, 0L, 0L, 0, seat, smoothDisc(), RADIUS, 0d, 0d, Math.toRadians(20d), 0d, + velocity, UniverseLawsV0.INSTANCE); + } + + @Test + public void theBoundaryIsTheDeclaredRadius() { + // Not a level of the profile. A profile is continuous and has no boundary, so a frame decided + // by "is it dense enough here" would flip for anything hovering on the threshold — and the + // frame decides whether a thing rotates with the galaxy or is carried by the void. + Galaxy g = flat(spiral()); + assertTrue(g.contains(RADIUS * 0.999d, 0d, 0d)); + assertFalse(g.contains(RADIUS * 1.001d, 0d, 0d)); + // It is a SPHERE, so the halo well above a thin disc is still inside the galaxy. + assertTrue("the halo above a disc is bound to the galaxy too", g.contains(0d, RADIUS * 0.9d, 0d)); + assertEquals("and there are no stars out there", 0d, g.densityAt(RADIUS * 1.5d, 0d, 0d), 0d); + } + + @Test + public void densityFallsWithRadiusAndWithHeight() { + Galaxy g = flat(smoothDisc()); + double centre = g.densityAt(0d, 0d, 0d); + double midway = g.densityAt(RADIUS * 0.4d, 0d, 0d); + double rim = g.densityAt(RADIUS * 0.9d, 0d, 0d); + assertTrue("the nucleus is the densest point", centre > midway); + assertTrue("and it keeps thinning outwards", midway > rim); + + // Off the plane at the same radius: a disc is a disc. + double inPlane = g.densityAt(RADIUS * 0.4d, 0d, 0d); + double aloft = g.densityAt(RADIUS * 0.4d, RADIUS * 0.1d, 0d); + assertTrue("a disc must thin out of its plane (" + inPlane + " vs " + aloft + ")", + inPlane > aloft); + } + + @Test + public void aDiscIsFlatterThanItIsWide() { + // The one claim that separates a disc from a sphere: the same fraction of the radius costs + // far more density vertically than radially. + Galaxy g = flat(smoothDisc()); + double outward = g.densityAt(RADIUS * 0.05d, 0d, 0d); + double upward = g.densityAt(0d, RADIUS * 0.05d, 0d); + assertTrue("going up must cost more than going out (" + upward + " vs " + outward + ")", + upward < outward); + } + + @Test + public void armsModulateTheDiscTheyDoNotGateIt() { + // An arm is where a disc is denser, not where it exists. If the between-arm density were zero + // the galaxy would be a set of curves rather than a disc with structure in it. + Galaxy armed = flat(spiral()); + double min = Double.MAX_VALUE; + double max = 0d; + double r = RADIUS * 0.5d; + for (int i = 0; i < 360; i++) { + double theta = Math.toRadians(i); + double d = armed.densityAt(r * Math.cos(theta), 0d, r * Math.sin(theta)); + min = Math.min(min, d); + max = Math.max(max, d); + } + assertTrue("arms must make the disc vary with angle", max > min); + assertTrue("but between the arms there are still stars", min > 0d); + } + + @Test + public void aTypeWithNoArmsIsAxisymmetric() { + // The no-arms case is the same code path with an empty term, so a smooth disc has to come out + // genuinely smooth rather than nearly so. + Galaxy smooth = flat(smoothDisc()); + double r = RADIUS * 0.5d; + double reference = smooth.densityAt(r, 0d, 0d); + for (int i = 0; i < 360; i += 15) { + double theta = Math.toRadians(i); + assertEquals("a smooth disc must not vary with angle", reference, + smooth.densityAt(r * Math.cos(theta), 0d, r * Math.sin(theta)), 1e-12d); + } + } + + @Test + public void orientationRotatesTheDiscWithoutChangingItsShape() { + // Two galaxies alike but for their orientation must be the same object seen from elsewhere: + // the density a point sees depends on where it is IN THE GALAXY, never on the world axes. + Galaxy flat = new Galaxy(0L, 0L, 0L, 0, GalacticCoord.ORIGIN, smoothDisc(), RADIUS, 0d, 0d, + Math.toRadians(20d), 0d, LightYearVector.ZERO, UniverseLawsV0.INSTANCE); + Galaxy tilted = new Galaxy(0L, 0L, 0L, 0, GalacticCoord.ORIGIN, smoothDisc(), RADIUS, + Math.toRadians(90d), 0d, Math.toRadians(20d), 0d, LightYearVector.ZERO, UniverseLawsV0.INSTANCE); + // The tilted galaxy's pole is +X, so ITS plane is the world's YZ plane. + double r = RADIUS * 0.3d; + assertEquals("the same point of the galaxy must read the same however it is oriented", + flat.densityAt(r, 0d, 0d), tilted.densityAt(0d, 0d, r), 1e-12d); + assertEquals("and so must its pole", flat.densityAt(0d, r, 0d), tilted.densityAt(r, 0d, 0d), + 1e-12d); + } + + @Test + public void rotationIsSolidBodyInTheCoreAndShearsOutside() { + // omega(r) constant means no shear; omega falling with r IS the shear. A galaxy that sheared + // nowhere would carry its arms round rigidly forever, and one that sheared everywhere would + // tear its own nucleus apart. + Galaxy g = flat(spiral()); + double core = RADIUS * spiral().coreRadiusFraction; + assertEquals("well inside the core the curve is solid-body, so omega is flat", + g.angularSpeedAt(core * 0.001d), g.angularSpeedAt(core * 0.01d), + g.angularSpeedAt(0d) * 1e-3d); + assertTrue("outside the core, omega must fall with radius", + g.angularSpeedAt(RADIUS * 0.9d) < g.angularSpeedAt(RADIUS * 0.3d)); + assertTrue("and it is finite at the very centre", g.angularSpeedAt(0d) > 0d + && !Double.isInfinite(g.angularSpeedAt(0d))); + } + + @Test + public void aDwarfShearsLessThanAMassiveSpiral() { + // The type earns its keep here, and it is what a real rotation curve does: a dwarf turns + // nearly as a solid body while a massive spiral's curve is flat and shears strongly. Measured + // as the ratio of omega across the same FRACTIONAL radii, so it compares shapes, not speeds. + Galaxy small = flat(dwarf()); + Galaxy big = flat(spiral()); + double dwarfShear = small.angularSpeedAt(RADIUS * 0.2d) / small.angularSpeedAt(RADIUS * 0.8d); + double spiralShear = big.angularSpeedAt(RADIUS * 0.2d) / big.angularSpeedAt(RADIUS * 0.8d); + assertTrue("a dwarf must shear less than a spiral (" + dwarfShear + " vs " + spiralShear + ")", + dwarfShear < spiralShear); + } + + @Test + public void thetaIsEvaluatedNeverIntegrated() { + // Analytic in t: theta at 2t must be exactly theta0 plus twice the advance, with no drift a + // step-by-step accumulation would build up. + Galaxy g = flat(spiral()); + double r = RADIUS * 0.5d; + double theta0 = 1.234d; + double advance = g.thetaAt(theta0, r, 1_000_000L) - theta0; + assertEquals(theta0 + 2d * advance, g.thetaAt(theta0, r, 2_000_000L), 1e-12d); + } + + @Test + public void rotationIsSlowEnoughToBeInvisibleWithinASave() { + // Recorded as a measurement, not a requirement: the mechanic exists even when slow, and the + // speed is tuning. What this pins is that the law is expressed in the SAME clock the game + // counts in — a period that came out in ticks-per-turn of order one would mean the km/s + // conversion had lost a calendar somewhere. + Galaxy g = flat(spiral()); + double turnTicks = g.rotationPeriodTicks(RADIUS * 0.5d); + assertTrue("a galactic turn must dwarf any play session (" + turnTicks + " ticks)", + turnTicks > 1e11d); + assertFalse("but it must be a finite number of ticks", Double.isInfinite(turnTicks)); + } + + // ─── Expansion and peculiar motion (R3) ──────────────────────────────────── + + @Test + public void expansionIsMonotoneAndStartsAtOne() { + // t = 0 is world creation, so the universe's age IS the save's age. And a(t) only ever grows: + // shear separates reversibly (theta wraps), expansion does not. A galaxy that recedes past a + // drive's reach has receded permanently, which is a stronger claim than "the sky moves". + assertEquals("a(0) must be exactly 1", 1d, Cosmology.scaleFactorAt(0L), 0d); + double previous = 1d; + for (long t = 1_000_000L; t <= 1_000_000_000_000L; t *= 10L) { + double a = Cosmology.scaleFactorAt(t); + assertTrue("a(" + t + ") = " + a + " did not grow past " + previous, a > previous); + previous = a; + } + } + + @Test + public void expansionCarriesTheCentreAndNothingInsideTheGalaxy() { + // The whole reason expansion is applied to the CENTRE only: a bound system does not expand, + // and scaling intra-galactic coordinates would grow every r and corrupt omega(r) from within. + // Measured as the separation between two bound points, which must not change with the scale + // factor even while their galaxy is being carried away. + Galaxy g = adrift(GalacticCoord.ofSectorLocal(4_000_000_000L, 0L, 0L, 0L, 0L, 0L), + LightYearVector.of(1e-9d, 0d, 0d)); + double r = RADIUS * 0.4d; + long far = 500_000_000L; + + // Two points at the same radius, so rotation carries them equally and only expansion could + // separate them. + double now = g.boundPositionAt(0L, r, 0d, 0d).distanceTo(g.boundPositionAt(0L, r, 1d, 0d)); + double later = g.boundPositionAt(far, r, 0d, 0d).distanceTo(g.boundPositionAt(far, r, 1d, 0d)); + assertEquals("two bound points must keep their separation while their galaxy is carried away", + now, later, now * 1e-9d); + assertTrue("and the galaxy itself must have moved", + g.centreAt(far).distanceTo(g.centreAt(0L)) > 0d); + } + + @Test + public void aGalaxyMovesUnderBothExpansionAndItsOwnVelocity() { + // Expansion alone lets a galaxy only RECEDE, so an approaching neighbour would be + // unrepresentable — and at short range peculiar motion dominates expansion in a real group. + GalacticCoord seat = GalacticCoord.ofSectorLocal(4_000_000_000L, 0L, 0L, 0L, 0L, 0L); + Galaxy still = adrift(seat, LightYearVector.ZERO); + Galaxy inbound = adrift(seat, LightYearVector.of(-1e-9d, 0d, 0d)); + long t = 100_000_000L; + + double seatLy = still.centreAt(0L).x(); + assertTrue("expansion alone can only push a galaxy outwards", + still.centreAt(t).x() > seatLy); + assertTrue("but its own velocity must be able to bring it closer", + inbound.centreAt(t).x() < seatLy); + } + + @Test + public void theCentreLawIsEvaluatedNeverIntegrated() { + // Analytic in t, like everything else in this layer: asking for tick N is one evaluation, so + // there is no step size and nothing to accumulate. + Galaxy g = adrift(GalacticCoord.ofSectorLocal(2_000_000_000L, 0L, 0L, 0L, 0L, 0L), + LightYearVector.of(3e-10d, -1e-10d, 2e-10d)); + long t = 12_345_678L; + double a = Cosmology.scaleFactorAt(t); + LightYearVector expected = LightYearVector.ofCell(g.centre(), UniverseLawsV0.INSTANCE) + .plus(g.peculiarVelocity().scale((double) t)).scale(a); + assertEquals(expected.x(), g.centreAt(t).x(), Math.abs(expected.x()) * 1e-12d); + assertEquals(expected.y(), g.centreAt(t).y(), 1e-9d); + assertEquals(expected.z(), g.centreAt(t).z(), 1e-9d); + } + + @Test + public void aSectorReadingAgreesWithTheLengthItStandsFor() { + // The generator asks in cell names; everything above is written in light years. The two have + // to be the same question, or the star field would be placed by one metric and bounded by + // another — which is the failure this whole layer keeps removing. + Galaxy g = flat(smoothDisc()); + long cells = UniverseScale.cellsForLightYears(RADIUS * 0.5d); + assertEquals(g.densityAt(UniverseScale.lightYearsForCells(cells), 0d, 0d), + g.densityAtSector(cells, 0L, 0L), 1e-12d); + assertFalse("and a sector past the radius is outside", + g.containsSector(UniverseScale.cellsForLightYears(RADIUS * 1.5d), 0L, 0L)); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/HyperdriveStatsTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/HyperdriveStatsTest.java index 265b87934..8de85b739 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/unit/HyperdriveStatsTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/HyperdriveStatsTest.java @@ -10,6 +10,7 @@ import zmaster587.advancedRocketry.hyperdrive.ComponentScan; import zmaster587.advancedRocketry.hyperdrive.DampenerField; +import zmaster587.advancedRocketry.hyperdrive.DriveTier; import zmaster587.advancedRocketry.hyperdrive.DriveTuning; import zmaster587.advancedRocketry.hyperdrive.JumpSpeed; import zmaster587.advancedRocketry.hyperdrive.ShipDriveStats; @@ -84,8 +85,8 @@ public void theScanIsBoundedAndSaysWhenItStopped() { @Test public void aBiggerGeneratorIsABetterGenerator() { - ShipDriveStats small = ShipDriveStats.ofPower(2_000L); - ShipDriveStats large = ShipDriveStats.ofPower(20_000L); + ShipDriveStats small = ShipDriveStats.ofPower(2_000L, DriveTier.baseline()); + ShipDriveStats large = ShipDriveStats.ofPower(20_000L, DriveTier.baseline()); assertTrue("more power crosses deeper wells and crosses them faster", large.drivePower() > small.drivePower()); @@ -100,12 +101,12 @@ public void aShipWithNoGeneratorHasNoDrive() { assertFalse(ShipDriveStats.NONE.present()); assertEquals(0L, ShipDriveStats.NONE.burstCost()); assertFalse("a generator of zero power is the same thing as no generator", - ShipDriveStats.ofPower(0L).present()); + ShipDriveStats.ofPower(0L, DriveTier.baseline()).present()); } @Test public void driveStatsSurviveAnNbtRoundTrip() { - ShipDriveStats original = ShipDriveStats.ofPower(12_345L); + ShipDriveStats original = ShipDriveStats.ofPower(12_345L, DriveTier.baseline()); NBTTagCompound nbt = new NBTTagCompound(); original.writeToNBT(nbt); @@ -120,16 +121,16 @@ public void driveStatsSurviveAnNbtRoundTrip() { @Test public void aHeavierShipOnTheSameDriveIsSlower() { - long light = JumpSpeed.blocksPerTick(DriveTuning.BASELINE_DRIVE_POWER, 1_000L); - long heavy = JumpSpeed.blocksPerTick(DriveTuning.BASELINE_DRIVE_POWER, 100_000L); + long light = JumpSpeed.blocksPerTick(DriveTuning.BASELINE_DRIVE_POWER, 1_000L, DriveTier.baseline()); + long heavy = JumpSpeed.blocksPerTick(DriveTuning.BASELINE_DRIVE_POWER, 100_000L, DriveTier.baseline()); assertTrue("mass is what makes a cruiser need a cruiser's drive", heavy < light); } @Test public void aStrongerDriveOnTheSameHullIsFaster() { - long weak = JumpSpeed.blocksPerTick(1_000L, DriveTuning.BASELINE_SHIP_MASS); - long strong = JumpSpeed.blocksPerTick(50_000L, DriveTuning.BASELINE_SHIP_MASS); + long weak = JumpSpeed.blocksPerTick(1_000L, DriveTuning.BASELINE_SHIP_MASS, DriveTier.baseline()); + long strong = JumpSpeed.blocksPerTick(50_000L, DriveTuning.BASELINE_SHIP_MASS, DriveTier.baseline()); assertTrue(strong > weak); } @@ -139,14 +140,14 @@ public void evenAnAbsurdlyOverloadedShipStillMoves() { // The transit integrator refuses a zero step, so a ship that computes to "slower than one // block per tick" must round up to one rather than becoming a permanent fixture of // hyperspace. - long speed = JumpSpeed.blocksPerTick(1L, Long.MAX_VALUE / 2L); + long speed = JumpSpeed.blocksPerTick(1L, Long.MAX_VALUE / 2L, DriveTier.baseline()); assertTrue("a crawling ship is a slow ship, not a stuck one", speed >= 1L); } @Test public void aShipWithNoDriveHasNoSpeedAtAll() { - assertEquals("refused upstream, not flown slowly", 0L, JumpSpeed.blocksPerTick(0L, 100L)); + assertEquals("refused upstream, not flown slowly", 0L, JumpSpeed.blocksPerTick(0L, 100L, DriveTier.baseline())); } @Test diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/InterstellarLegDistanceTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/InterstellarLegDistanceTest.java index d277b2f39..15278baa8 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/unit/InterstellarLegDistanceTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/InterstellarLegDistanceTest.java @@ -7,13 +7,16 @@ import java.util.List; import java.util.Map; +import zmaster587.advancedRocketry.hyperdrive.DriveTier; import zmaster587.advancedRocketry.hyperdrive.DriveTuning; import zmaster587.advancedRocketry.hyperdrive.JumpSpeed; import zmaster587.advancedRocketry.space.CellFrames; import zmaster587.advancedRocketry.space.GalacticCoord; import zmaster587.advancedRocketry.universe.ClusteredGalaxyGenerator; import zmaster587.advancedRocketry.universe.GalaxyGenConfig; -import zmaster587.advancedRocketry.universe.StarSystem; +import zmaster587.advancedRocketry.universe.PlanetarySystem; +import zmaster587.advancedRocketry.universe.UniverseScale; +import zmaster587.advancedRocketry.util.AstronomicalBodyHelper; import static org.junit.Assert.assertTrue; @@ -34,7 +37,8 @@ public class InterstellarLegDistanceTest { /** A baseline drive hauling the placeholder hull: the reference ship every band is quoted for. */ private static final long BASELINE_SPEED = - JumpSpeed.blocksPerTick(DriveTuning.BASELINE_DRIVE_POWER, DriveTuning.PLACEHOLDER_SHIP_MASS); + JumpSpeed.blocksPerTick(DriveTuning.BASELINE_DRIVE_POWER, DriveTuning.PLACEHOLDER_SHIP_MASS, + DriveTier.baseline()); private static GalacticCoord cell(long sx, long sy, long sz) { return GalacticCoord.ofSectorLocal(sx, sy, sz, 0L, 0L, 0L); @@ -55,7 +59,7 @@ public void theNearestSystemIsFarEnoughToBeAJumpAndCloseEnoughToBeReached() { List ticks = new ArrayList<>(); List rows = new ArrayList<>(); for (long seed = 1L; seed <= 20L; seed++) { - Map found = gen.systemsInRegion(seed, + Map found = gen.systemsInRegion(seed, cell(-SEARCH_RADIUS_CELLS, -SEARCH_RADIUS_CELLS, -SEARCH_RADIUS_CELLS), cell(SEARCH_RADIUS_CELLS, SEARCH_RADIUS_CELLS, SEARCH_RADIUS_CELLS)); // The leg a PLAYER flies runs anchor to anchor: he sits in a system and jumps to another @@ -83,7 +87,7 @@ public void theNearestSystemIsFarEnoughToBeAJumpAndCloseEnoughToBeReached() { .append(BASELINE_SPEED).append(" blocks/tick ===\n"); report.append("cell edge ").append(GalacticCoord.CELL).append(" blocks, minSpacing ") .append(cfg.minSpacing).append(" cells, density ").append(cfg.density) - .append(", clusterScale ").append(cfg.clusterScale).append('\n'); + .append(", galaxy spacing ").append(cfg.galaxySpacing).append(" cells\n"); for (String row : rows) { report.append(" ").append(row).append('\n'); } @@ -119,6 +123,98 @@ private static GalacticCoord nearestTo(java.util.Collection cells return best; } + // ── the band between the two lattices ───────────────────────────────────── + + /** + * How much further a galaxy crossing is than one interstellar step, as arithmetic on the two + * constants: a reference galaxy's DIAMETER over the mean star separation. STATED HERE, before the + * sweep below measures it through the real generator. + * + *

    At the shipped numbers this is about ×23 641. The measurement can disagree with the + * arithmetic in one way that matters: if the generator's actual nearest-neighbour distance drifts + * away from the separation it is configured with, the two lattices are not the scales apart the + * design believes they are.

    + */ + private static final double DECLARED_STAR_TO_GALAXY_BAND = + 2d * UniverseScale.REFERENCE_GALAXY_RADIUS_LY / UniverseScale.MEAN_STAR_SEPARATION_LY; + + /** + * How wide a band the drive ladder needs to have a SECOND TIER in it at all — the reason the + * universe was taken to its real scale rather than a design preference. A tier buys an order of + * magnitude or so of speed; a star→galaxy gap narrower than this leaves no rung above the first, + * and with no rung there is nothing for the research branch or the technology unlocks to open. + */ + private static final double MIN_BAND_FOR_A_SECOND_DRIVE_TIER = 1_000d; + + @Test + public void crossingAGalaxyIsWideEnoughAboveOneStepToHoldASecondDriveTier() { + System.out.println(String.format( + "star -> galaxy band: %.0f x (galaxy diameter %.0f ly / star separation %.2f ly)", + DECLARED_STAR_TO_GALAXY_BAND, 2d * UniverseScale.REFERENCE_GALAXY_RADIUS_LY, + UniverseScale.MEAN_STAR_SEPARATION_LY)); + assertTrue("a star -> galaxy band of only x" + (long) DECLARED_STAR_TO_GALAXY_BAND + + " leaves no room for a drive tier above the first", + DECLARED_STAR_TO_GALAXY_BAND >= MIN_BAND_FOR_A_SECOND_DRIVE_TIER); + } + + @Test + public void theMeasuredBandMatchesTheArithmeticItIsDerivedFrom() { + // The same 20 seeds as the leg reading above, and the same real generator. The lattice is + // STRATIFIED rather than Poisson, so a measured neighbour distance runs somewhat wider than + // the configured edge and the measured band comes out somewhat narrower than the declared one. + // A factor of two is the spread that allows; anything past it means the two lattices are no + // longer the scales apart the drive ladder is derived against. + final double TOLERANCE_FACTOR = 2d; + + ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(GalaxyGenConfig.defaults()); + List bands = new ArrayList<>(); + for (long seed = 1L; seed <= 20L; seed++) { + Double stepLy = nearestNeighbourLightYears(gen, seed); + if (stepLy == null) { + continue; + } + bands.add(2d * UniverseScale.REFERENCE_GALAXY_RADIUS_LY / stepLy); + } + Collections.sort(bands); + assertTrue("no seed produced a pair of systems to measure a step from", !bands.isEmpty()); + + double median = bands.get(bands.size() / 2); + System.out.println(String.format( + "measured band over %d seeds: min x%.0f, median x%.0f, max x%.0f (declared x%.0f)", + bands.size(), bands.get(0), median, bands.get(bands.size() - 1), + DECLARED_STAR_TO_GALAXY_BAND)); + + assertTrue("the measured band x" + (long) median + " is not the declared x" + + (long) DECLARED_STAR_TO_GALAXY_BAND + " within a factor of " + + TOLERANCE_FACTOR, + median >= DECLARED_STAR_TO_GALAXY_BAND / TOLERANCE_FACTOR + && median <= DECLARED_STAR_TO_GALAXY_BAND * TOLERANCE_FACTOR); + } + + /** The distance from the system nearest the origin to ITS nearest neighbour, in light years. */ + private static Double nearestNeighbourLightYears(ClusteredGalaxyGenerator gen, long seed) { + Map all = gen.systemsInRegion(seed, + cell(-SEARCH_RADIUS_CELLS, -SEARCH_RADIUS_CELLS, -SEARCH_RADIUS_CELLS), + cell(SEARCH_RADIUS_CELLS, SEARCH_RADIUS_CELLS, SEARCH_RADIUS_CELLS)); + // STAR systems only. Since the void was populated an unbound world sits in nearly every + // territory the stars left empty, so a leg measured over all seats is the lattice EDGE and not + // the star separation this band is declared against. A jump is aimed at what a telescope + // found, which is a star. + java.util.Set found = new java.util.LinkedHashSet<>(); + for (Map.Entry e : all.entrySet()) { + if (e.getValue().star().isPresent()) { + found.add(e.getKey()); + } + } + GalacticCoord home = nearestTo(found, cell(0L, 0L, 0L)); + GalacticCoord neighbour = home == null ? null : nearestTo(found, home); + if (neighbour == null) { + return null; + } + double blocks = CellFrames.STATIC.distanceBetween(home, neighbour, 0L); + return blocks / (double) AstronomicalBodyHelper.BLOCKS_PER_LIGHT_YEAR; + } + @Test public void aFartherTargetCostsStrictlyMoreTicksThanANearerOne() { double near = CellFrames.STATIC.distanceBetween(cell(0, 0, 0), cell(4, 0, 0), 0L); diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/NebulaConcealmentTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/NebulaConcealmentTest.java new file mode 100644 index 000000000..6691d57cd --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/NebulaConcealmentTest.java @@ -0,0 +1,226 @@ +package zmaster587.advancedRocketry.test.unit; + +import java.util.Collections; +import java.util.Map; +import java.util.Optional; + +import org.junit.After; +import org.junit.Test; + +import zmaster587.advancedRocketry.api.Constants; +import zmaster587.advancedRocketry.api.dimension.solar.StellarBody; +import zmaster587.advancedRocketry.navigation.CrystalMemory; +import zmaster587.advancedRocketry.space.GalacticCoord; +import zmaster587.advancedRocketry.universe.EmptyGalaxyGenerator; +import zmaster587.advancedRocketry.universe.GalaxyGenConfig; +import zmaster587.advancedRocketry.universe.IGalaxyGenerator; +import zmaster587.advancedRocketry.universe.Nebula; +import zmaster587.advancedRocketry.universe.PlanetarySystem; +import zmaster587.advancedRocketry.universe.SystemBody; +import zmaster587.advancedRocketry.universe.SystemBodyKind; +import zmaster587.advancedRocketry.universe.TelescopeScan; +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.assertTrue; + +/** + * What a cloud between an observer and a system costs the look. + * + *

    These pin the player-facing promise and the physics it is stated in: a survey through dust + * still learns that something is THERE (the address), and stops being able to say what (the bodies). + * The mechanic is a reason to fly somewhere rather than survey it from home, so what it may never do + * is make a system vanish — that is indistinguishable from an empty sky, which is the exact defect + * this instrument carried until a survey learned to resolve a look through the system that OWNS the + * cell it looked at.

    + * + *

    The THRESHOLD is a tunable and nothing here pins its shipped value; what is pinned is that the + * threshold is read in magnitudes, that it is honoured, and that turning it off restores the clear + * sky exactly.

    + */ +public class NebulaConcealmentTest { + + private static final long STEP = GalaxyGenConfig.DEFAULT_MIN_SPACING; + + /** Where the observer stands, and where the system it is looking at is seated. */ + private static final GalacticCoord HOME = GalacticCoord.ORIGIN; + private static final GalacticCoord TARGET = GalacticCoord.ofSectorLocal(4 * STEP, 0, 0, 0, 0, 0); + + private double previousThreshold; + + private static StellarBody star(int id) { + StellarBody s = new StellarBody(); + s.setId(id); + s.setName("Star-" + id); + return s; + } + + /** + * A generator that reports a stated column of dust between ANY two points, and no systems of its + * own — so what a look loses is decided by the column alone. + */ + private static IGalaxyGenerator dustyBy(final double columnDensityLightYears) { + return new IGalaxyGenerator() { + @Override + public Optional systemAt(long seed, GalacticCoord coord) { + return Optional.empty(); + } + + @Override + public Map systemsInRegion(long seed, GalacticCoord min, + GalacticCoord max) { + return Collections.emptyMap(); + } + + @Override + public double columnDensityBetween(long seed, GalacticCoord from, GalacticCoord to) { + return columnDensityLightYears; + } + }; + } + + /** A registry holding one system with a named planet, seated at {@link #TARGET}. */ + private static UniverseRegistry oneSystem() { + UniverseRegistry.setStarLookup(NebulaConcealmentTest::star); + UniverseRegistry registry = new UniverseRegistry(); + registry.place(TARGET, 4); + registry.addPoi(SystemBody.fixedAt(TARGET, SystemBodyKind.STAR, Constants.INVALID_PLANET, 4)); + registry.addPoi(SystemBody.fixedAt(TARGET, SystemBodyKind.PLANET, 401, 4)); + return registry; + } + + private static int look(UniverseRegistry registry, CrystalMemory crystal) { + // An aperture nothing in this fixture can fall below, because what is under test is the + // DUST and not the brightness: a limit that also gated the look would make "the dusty case + // named nothing" true for two reasons and pin neither. + return TelescopeScan.resolveLook(registry, TARGET, crystal, 7_000L, + dimId -> "Body-" + dimId, HOME, Double.POSITIVE_INFINITY, true); + } + + /** The column, in density-light-years, that the shipped threshold sits at. */ + private static double columnAtThreshold() { + return zmaster587.advancedRocketry.api.ARConfiguration.getCurrentConfig() + .telescopeObscuredAtMagnitudes / Nebula.MAGNITUDES_PER_DENSITY_LIGHT_YEAR; + } + + @org.junit.Before + public void armThreshold() { + previousThreshold = zmaster587.advancedRocketry.api.ARConfiguration.getCurrentConfig() + .telescopeObscuredAtMagnitudes; + // A stated threshold, so nothing here depends on the shipped default staying put. + zmaster587.advancedRocketry.api.ARConfiguration.getCurrentConfig() + .telescopeObscuredAtMagnitudes = 5d; + } + + @After + public void restoreSeams() { + zmaster587.advancedRocketry.api.ARConfiguration.getCurrentConfig() + .telescopeObscuredAtMagnitudes = previousThreshold; + UniverseRegistry.setGenerator(null); + UniverseRegistry.setStarLookup(null); + } + + @Test + public void aClearSightLineNamesTheBodies() { + // The control. Without it "the dusty case names nothing" would be a statement about a + // fixture that never named anything. + UniverseRegistry.setGenerator(new EmptyGalaxyGenerator()); + UniverseRegistry registry = oneSystem(); + CrystalMemory crystal = new CrystalMemory(); + + look(registry, crystal); + + assertNotNull("a look through clear space must name the system's planet", crystal.forBody(401)); + } + + @Test + public void aLookThroughThickDustLearnsTheADDRESSAndNotTheBODIES() { + // THE mechanic. The operator is left knowing there is something out there and having to go + // and see what — which is the reason to fly rather than survey. + UniverseRegistry.setGenerator(dustyBy(columnAtThreshold() * 2d)); + UniverseRegistry registry = oneSystem(); + CrystalMemory crystal = new CrystalMemory(); + + int written = look(registry, crystal); + + assertTrue("an obscured look must still write something: a system that VANISHES is" + + " indistinguishable from empty sky, which is the defect this whole path had", + written >= 1); + assertEquals("and what it writes is one bare address, not a body list", 1, crystal.size()); + assertTrue("the system's planet must NOT be named through the dust", + crystal.forBody(401) == null); + } + + @Test + public void thinDustDoesNotHideAnything() { + // The other side of the threshold, so "obscured" is a property of how much dust there is and + // not of there being any. + UniverseRegistry.setGenerator(dustyBy(columnAtThreshold() * 0.5d)); + UniverseRegistry registry = oneSystem(); + CrystalMemory crystal = new CrystalMemory(); + + look(registry, crystal); + + assertNotNull("a cloud below the threshold must not cost the look its detail", + crystal.forBody(401)); + } + + @Test + public void theThresholdIsReadInMagnitudes() { + // The unit is the contract: the config states extinction, and the calibration from this + // model's density to magnitudes lives in one place. + UniverseRegistry.setGenerator(dustyBy(columnAtThreshold())); + UniverseRegistry registry = oneSystem(); + + double magnitudes = registry.extinctionBetween(HOME, TARGET); + assertEquals("a column at the threshold must read as the configured magnitudes", + zmaster587.advancedRocketry.api.ARConfiguration.getCurrentConfig() + .telescopeObscuredAtMagnitudes, + magnitudes, 1.0E-6d); + assertTrue("and must be judged obscured at exactly that reading", + TelescopeScan.isObscured(registry, HOME, TARGET)); + } + + @Test + public void turningTheThresholdOffRestoresTheClearSky() { + // A config flag has to REMOVE its mechanic, not soften it. Zero is the off switch, because + // "obscured at zero magnitudes" would otherwise mean everything is always hidden. + UniverseRegistry.setGenerator(dustyBy(columnAtThreshold() * 100d)); + UniverseRegistry registry = oneSystem(); + zmaster587.advancedRocketry.api.ARConfiguration.getCurrentConfig() + .telescopeObscuredAtMagnitudes = 0d; + CrystalMemory crystal = new CrystalMemory(); + + assertFalse("with the mechanic off nothing is obscured, however thick the dust", + TelescopeScan.isObscured(registry, HOME, TARGET)); + look(registry, crystal); + assertNotNull("and the survey names bodies exactly as it did before the feature existed", + crystal.forBody(401)); + } + + @Test + public void aLookWithNoStatedObserverIsNeverObscured() { + // A caller that cannot say where it is standing cannot claim a sight line either. This is + // what keeps every pre-existing call site behaving exactly as it did. + UniverseRegistry.setGenerator(dustyBy(columnAtThreshold() * 100d)); + UniverseRegistry registry = oneSystem(); + CrystalMemory crystal = new CrystalMemory(); + + TelescopeScan.resolveCell(registry, TARGET, crystal, 7_000L, dimId -> "Body-" + dimId); + + assertNotNull("an observer-less look must resolve as it always did", crystal.forBody(401)); + } + + @Test + public void extinctionIsZeroInAUniverseWithNoClouds() { + // The negative leg for the physics itself: no clusters, no gas, no dimming — and no + // fabricated column from a generator that has none. + UniverseRegistry.setGenerator(new EmptyGalaxyGenerator()); + UniverseRegistry registry = oneSystem(); + + assertEquals("clear space dims nothing", 0d, registry.extinctionBetween(HOME, TARGET), + 1.0E-9d); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/NebulaTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/NebulaTest.java new file mode 100644 index 000000000..deb1012b3 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/NebulaTest.java @@ -0,0 +1,194 @@ +package zmaster587.advancedRocketry.test.unit; + +import org.junit.Test; + +import java.util.List; +import java.util.Optional; + +import zmaster587.advancedRocketry.universe.ClusteredGalaxyGenerator; +import zmaster587.advancedRocketry.universe.Galaxy; +import zmaster587.advancedRocketry.universe.GalaxyGenConfig; +import zmaster587.advancedRocketry.universe.Nebula; +import zmaster587.advancedRocketry.universe.NebulaField; +import zmaster587.advancedRocketry.universe.StarCluster; +import zmaster587.advancedRocketry.universe.UniverseScale; + +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 nebulae — the diffuse cloud a star cluster is wrapped in. + * + *

    What is pinned is that a cloud is DERIVED from its cluster and cannot disagree with it, that its + * appearance is one age sequence rather than three drawn options, that it reaches beyond the cluster + * inside it (so it can be seen from outside, which is the whole point of having it), and that + * {@code densityAt} is a continuous falloff with no edge — because that function is the seam every + * later consequence will be written against.

    + * + *

    There is deliberately no test of what a nebula DOES, because it does nothing yet. None of + * those numbers is ratified.

    + */ +public class NebulaTest { + + private static final long SEED = 0xC10DDL; + + private static ClusteredGalaxyGenerator gen() { + return new ClusteredGalaxyGenerator(GalaxyGenConfig.defaults()); + } + + private static StarCluster clusterOfType(GalaxyGenConfig.ClusterType type) { + return new StarCluster(type, type.subdivision, 100L, 0L, 0L, 2L); + } + + private static GalaxyGenConfig.ClusterType typeWithGas(double gas) { + return new GalaxyGenConfig.ClusterType("Test", 4, 5d, 15d, gas, false, 1); + } + + // ─── The derivation ──────────────────────────────────────────────────────── + + @Test + public void aCloudBelongsToItsClusterAndSharesItsPlace() { + // Not seated separately, and that is the design: a cloud and the cluster inside it are one + // object at two ages, so there is no way for them to disagree about where they are. + NebulaField field = gen().nebulae(); + StarCluster cluster = clusterOfType(typeWithGas(0.8d)); + Optional nebula = field.nebulaOf(SEED, cluster); + assertTrue(nebula.isPresent()); + assertEquals(cluster, nebula.get().cluster()); + + double expectedX = UniverseScale.lightYearsForCells( + (double) cluster.centreSuperX() * GalaxyGenConfig.DEFAULT_MIN_SPACING); + assertEquals("a cloud is centred on its cluster", expectedX, nebula.get().centreXLy(), 1e-6d); + } + + @Test + public void aClusterWithNoGasLeftHasNoCloud() { + // An ancient globular has blown its gas away — real ones are gas-free, and that is what the + // type table states rather than something the generator decides separately. + NebulaField field = gen().nebulae(); + assertFalse(field.nebulaOf(SEED, clusterOfType(typeWithGas(0d))).isPresent()); + } + + @Test + public void aCloudReachesBeyondTheClusterInsideIt() { + // The point of having one: a cluster is otherwise a pure refinement of the lattice with no + // property anything outside it can observe. Real clouds dwarf their clusters. + NebulaField field = gen().nebulae(); + StarCluster cluster = clusterOfType(typeWithGas(0.9d)); + Nebula nebula = field.nebulaOf(SEED, cluster).get(); + double clusterRadiusLy = UniverseScale.lightYearsForCells( + (double) cluster.radiusSuperCells() * GalaxyGenConfig.DEFAULT_MIN_SPACING); + assertTrue("a cloud of " + nebula.radiusLy() + " ly must exceed its cluster's " + + clusterRadiusLy, nebula.radiusLy() > clusterRadiusLy); + } + + @Test + public void appearanceIsAnAgeSequenceNotAChoice() { + // One number, three appearances, IN ORDER: dark while the stars are still forming inside it, + // emitting once they ionise what is left, reflecting once the gas is blown clear. Three + // independent draws would let a nearly-gone cloud come out thick and black. + // + // Pinned as the ORDERING over a sample rather than by repeating the thresholds here — a test + // that copies the derivation it is checking cannot fail when the derivation is wrong. + NebulaField field = gen().nebulae(); + double darkestEmission = 0d; + double thinnestDark = 1d; + double darkestReflection = 0d; + double thinnestEmission = 1d; + int seen = 0; + for (int i = 0; i <= 20; i++) { + double stated = i / 20d; + Optional n = field.nebulaOf(SEED + i, clusterOfType(typeWithGas(stated))); + if (!n.isPresent()) { + continue; + } + double gas = n.get().peakDensity(); + seen++; + switch (n.get().appearance()) { + case DARK: + thinnestDark = Math.min(thinnestDark, gas); + break; + case EMISSION: + darkestEmission = Math.max(darkestEmission, gas); + thinnestEmission = Math.min(thinnestEmission, gas); + break; + default: + darkestReflection = Math.max(darkestReflection, gas); + break; + } + } + assertTrue("the sample must contain clouds", seen > 5); + assertTrue("every DARK cloud must be thicker than every EMISSION one", + thinnestDark > darkestEmission); + assertTrue("and every EMISSION one thicker than every REFLECTION one", + thinnestEmission > darkestReflection); + } + + // ─── The seam ────────────────────────────────────────────────────────────── + + @Test + public void densityFallsOffSmoothlyAndStopsAtTheRadius() { + // THE seam: every consequence a nebula could have — a muffled sensor, a drag, something + // concealed, something mined — is a function of how thick it is here. Diffuse matter has no + // edge, so the falloff is continuous; what it does have is a bound, so a consumer can stop. + NebulaField field = gen().nebulae(); + Nebula n = field.nebulaOf(SEED, clusterOfType(typeWithGas(0.8d))).get(); + double cx = n.centreXLy(); + double cy = n.centreYLy(); + double cz = n.centreZLy(); + + double centre = n.densityAt(cx, cy, cz); + double half = n.densityAt(cx + n.radiusLy() * 0.5d, cy, cz); + double edge = n.densityAt(cx + n.radiusLy() * 0.99d, cy, cz); + assertEquals("the centre must be the stated peak", n.peakDensity(), centre, 1e-9d); + assertTrue("it must thin outwards", centre > half); + assertTrue("and keep thinning", half > edge); + assertTrue("but never reach zero inside its own radius", edge > 0d); + assertEquals("and be exactly zero outside it", 0d, + n.densityAt(cx + n.radiusLy() * 1.01d, cy, cz), 0d); + assertTrue(n.contains(cx, cy, cz)); + assertFalse(n.contains(cx + n.radiusLy() * 1.01d, cy, cz)); + } + + @Test + public void aSectorReadingAgreesWithTheLengthItStandsFor() { + // The rest of the layer asks in cell names; a nebula is written in light years. If those two + // disagreed, a cloud would be placed by one metric and read by another. + NebulaField field = gen().nebulae(); + Nebula n = field.nebulaOf(SEED, clusterOfType(typeWithGas(0.8d))).get(); + long sector = UniverseScale.cellsAt(n.centreXLy()); + assertEquals(n.densityAt(UniverseScale.lightYearsForCells(sector), 0d, 0d), + n.densityAtSector(sector, 0L, 0L), 1e-9d); + } + + // ─── Where they turn up ──────────────────────────────────────────────────── + + @Test + public void aGalaxyHoldsNebulaeAndTheyAreDeterministic() { + ClusteredGalaxyGenerator g = gen(); + Galaxy home = g.galaxies().home(SEED); + long spacing = g.clusters().spacingSuperCells(); + List found = g.nebulae().nebulaeInRegion(SEED, home, -3L * spacing, -2L * spacing, + -2L * spacing, 3L * spacing, 2L * spacing, 2L * spacing); + assertFalse("a galaxy must hold clouds", found.isEmpty()); + assertEquals("the same query must answer the same way", found.size(), + g.nebulae().nebulaeInRegion(SEED, home, -3L * spacing, -2L * spacing, -2L * spacing, + 3L * spacing, 2L * spacing, 2L * spacing).size()); + for (Nebula n : found) { + assertNotNull(n.appearance()); + assertTrue("a cloud that exists must have something in it", n.peakDensity() > 0d); + } + } + + @Test + public void thereIsNoDiffuseMatterOutsideAGalaxy() { + // A cloud only exists where a cluster does, and a cluster only exists inside a galaxy — one + // chain, not a separate rule about the void. + ClusteredGalaxyGenerator g = gen(); + Galaxy home = g.galaxies().home(SEED); + long farSector = UniverseScale.cellsForLightYears(home.radiusLy() * 4d); + assertEquals(0d, g.nebulae().densityAtSector(SEED, home, farSector, 0L, 0L), 0d); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/PacketSystemBodiesSyncTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/PacketSystemBodiesSyncTest.java index 28157f65f..af6a0a00e 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/unit/PacketSystemBodiesSyncTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/PacketSystemBodiesSyncTest.java @@ -37,11 +37,12 @@ public void twoDimsWithDifferingBodyListsRoundTrip() { // A descend target carries a shell; the body beside it carries none. The two must survive // the wire as DIFFERENT numbers — a codec that dropped the field, or wrote one body's value // for every body, would still round-trip a payload where they all agreed. - dimA.add(new RenderBody(2, 100L, -200L, 300L, 41, true, 512L)); - dimA.add(new RenderBody(0, -7L, 8L, -9L, 55, false, 0L)); + dimA.add(new RenderBody(2, 100L, -200L, 300L, 41, true, 512L, 25_512L, RenderBody.NO_PARENT)); + dimA.add(new RenderBody(0, -7L, 8L, -9L, 55, false, 0L, 0L, 0)); List dimB = new ArrayList<>(); - dimB.add(new RenderBody(5, 1_000_000_000_000L, 0L, -1_000_000_000_000L, 7, false, 7_777L)); + dimB.add(new RenderBody(5, 1_000_000_000_000L, 0L, -1_000_000_000_000L, 7, false, 7_777L, + 2_800_000L, RenderBody.NO_PARENT)); sent.put(11, dimA); sent.put(-4, dimB); @@ -118,5 +119,10 @@ private static void assertBody(RenderBody expected, RenderBody actual) { assertEquals("dimId", expected.dimId, actual.dimId); assertEquals("descendTarget", expected.descendTarget, actual.descendTarget); assertEquals("boundaryRadius", expected.boundaryRadius, actual.boundaryRadius); + // The body's OWN size, distinct from the shell around it: the sky cannot draw a giant as a + // giant if this is dropped, and dropping it looks exactly like the old distance-only sizing. + assertEquals("radiusBlocks", expected.radiusBlocks, actual.radiusBlocks); + // Whose moon it is. Dropped, a giant and its retinue arrive as unrelated dots. + assertEquals("parentIndex", expected.parentIndex, actual.parentIndex); } } diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/PilotInputCadenceTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/PilotInputCadenceTest.java new file mode 100644 index 000000000..4a2467f2f --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/PilotInputCadenceTest.java @@ -0,0 +1,103 @@ +package zmaster587.advancedRocketry.test.unit; + +import org.junit.Test; + +import zmaster587.advancedRocketry.api.FreeFlightInput; +import zmaster587.advancedRocketry.api.PilotInputCadence; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +/** + * Contract tests for {@link PilotInputCadence} — when a pilot's command goes on the wire. + * + *

    What these pin is the property the mechanism exists for: a held command is re-asserted within + * a bounded time, so the cost of the server forgetting it is that bound and not the rest of the + * flight. The interval and the phase are read from the class rather than restated, so re-tuning + * changes the behaviour these tests describe without making them lie.

    + */ +public class PilotInputCadenceTest { + + private static FreeFlightInput held() { + return new FreeFlightInput(0f, 1f, 0f, 0f, 0f, 0f, 0f, false); + } + + @Test + public void aChangedInputGoesOutImmediately() { + assertTrue("the first input ever must be sent", + PilotInputCadence.shouldSend(held(), null, 1L, 0)); + assertTrue("a different input must be sent on the tick it changes", + PilotInputCadence.shouldSend(held(), FreeFlightInput.zero(), 7L, 0)); + } + + /** + * The defect this class was written for: a key held down, unchanged, while the server's copy of + * it is gone. Over any window as long as the repeat interval the command must be re-asserted at + * least once — asserted as a property of the window, not as "tick 20 specifically". + */ + @Test + public void aHeldInputIsReassertedWithinTheRepeatInterval() { + FreeFlightInput input = held(); + int phase = PilotInputCadence.phaseOfSeat(11, 64, -7); + + int sends = 0; + for (long tick = 1; tick <= PilotInputCadence.REPEAT_TICKS; tick++) { + if (PilotInputCadence.shouldSend(input, input, tick, phase)) { + sends++; + } + } + assertEquals("exactly one re-assert per interval — more is a burst, none is the bug", + 1, sends); + } + + @Test + public void anIdleInputIsNeverRepeated() { + FreeFlightInput idle = FreeFlightInput.zero(); + for (long tick = 0; tick <= 4L * PilotInputCadence.REPEAT_TICKS; tick++) { + assertFalse("releasing everything must not become a heartbeat: losing \"no input\" costs " + + "nothing, because no input is what the server falls back to", + PilotInputCadence.shouldSend(idle, idle, tick, 0)); + } + } + + @Test + public void nullIsNeverSent() { + assertFalse(PilotInputCadence.shouldSend(null, null, 0L, 0)); + } + + /** + * Two seats must not repeat on the same tick. Pinned because the failure is invisible in single + * play and only appears as a periodic spike on a busy server — the shape a shared {@code % N} + * clock always has. + */ + @Test + public void twoSeatsRepeatOnDifferentTicks() { + FreeFlightInput input = held(); + int phaseA = PilotInputCadence.phaseOfSeat(100, 70, 100); + int phaseB = PilotInputCadence.phaseOfSeat(101, 70, 100); + assertNotEquals("a seat one block over must land on a different phase", phaseA, phaseB); + + long tickA = -1, tickB = -1; + for (long tick = 1; tick <= PilotInputCadence.REPEAT_TICKS; tick++) { + if (tickA < 0 && PilotInputCadence.shouldSend(input, input, tick, phaseA)) { + tickA = tick; + } + if (tickB < 0 && PilotInputCadence.shouldSend(input, input, tick, phaseB)) { + tickB = tick; + } + } + assertTrue("both seats must re-assert inside one interval", tickA > 0 && tickB > 0); + assertNotEquals("two pilots must not stack their keep-alives onto one tick", tickA, tickB); + } + + @Test + public void thePhaseStaysInsideTheInterval() { + for (int x = -40; x <= 40; x++) { + int phase = PilotInputCadence.phaseOfSeat(x, -x, 3 * x); + assertTrue("a phase outside the interval would silently disable the repeat: " + phase, + phase >= 0 && phase < PilotInputCadence.REPEAT_TICKS); + } + } +} 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..eff04f999 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetDerivationTest.java @@ -0,0 +1,601 @@ +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; + } + + /** + * The scan and the landing describe the same world. + * + *

    {@code BodyProfile}'s own javadoc states this contract and nothing pinned it. A derived + * temperature is what a telescope reports from across the system; the realized dimension then + * recomputes one from the star, the orbit, the atmosphere and the world's ALBEDO — and the + * derivation used to end on the neutral-albedo overload, so the two disagreed by + * {@code ((1 − a)/0.7)^¼} for every world whose type states an albedo of its own. Measured on the + * shipped table: a {@code greenhouse} world (a = 0.75) landed 22.7 % colder than it scanned and an + * {@code ice} world 13 % (ledger #289).

    + * + *

    What this pins is not the second pass but the AGREEMENT: whatever law either side uses, the + * number a profile carries has to be the number the dimension model produces from that profile's + * own inputs. It is asserted exactly, because "the same world" admits no tolerance.

    + */ + @Test + public void theTemperatureAScanReportsIsTheTemperatureTheWorldHas() { + int compared = 0; + Set albedosSeen = new HashSet<>(); + for (long c = 0; c < 400; c++) { + GalacticCoord anchor = cell(9000 + c, 0, 0); + StellarBody s = c % 2 == 0 ? sol() : star(45, 0.7f); + for (BodyProfile profile : system(SEED + c, anchor, s, 6)) { + double albedo = profile.preset() == null + ? zmaster587.advancedRocketry.util.AstronomicalBodyHelper.EARTH_ALBEDO + : profile.preset().albedo(); + albedosSeen.add(Double.toString(albedo)); + // Exactly the call DimensionProperties.recalculateTemperature makes on a world + // materialized from this profile: its star, its orbit, its air, its own albedo. + int asTheWorldWillReadIt = + zmaster587.advancedRocketry.util.AstronomicalBodyHelper.getAverageTemperature( + s, Math.max(1, profile.orbitalDistance()), profile.pressure(), albedo); + assertEquals("a " + profile.typeName() + " world (albedo " + albedo + ") scanned at " + + profile.temperatureKelvin() + " K must not land at another temperature", + profile.temperatureKelvin(), asTheWorldWillReadIt); + compared++; + } + } + assertTrue("the sweep must actually derive worlds", compared > 100); + assertTrue("and it must cross types whose albedo is NOT Earth's, or it proves nothing about " + + "the defect it exists for - saw " + albedosSeen, albedosSeen.size() > 2); + } + + /** + * 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 + 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 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 ────────────────────── + + @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, albedo -> 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, albedo -> 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..41a478c61 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetRealizationTest.java @@ -0,0 +1,357 @@ +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); + } + + /** 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(SPACING, 1.0d, GalaxyGenConfig.DEFAULT_GALAXY_SPACING, + GalaxyGenConfig.DEFAULT_GALAXY_DENSITY, null, null))); + reg.bindWorldSeed(SEED); + return reg; + } + + /** + * The seat of a system near the origin. + * + *

    Probed one TERRITORY 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 — and it is asked + * what the whole territory HOLDS, because a territory is divided uniformly and resolving its + * corner point would sample one seat in k-cubed and read a full galaxy as an empty one.

    + */ + private static GalacticCoord systemAnchor(UniverseRegistry reg) { + for (long i = 0; i <= 8; i++) { + for (GalacticCoord anchor : reg.anchorsInTerritory( + GalacticCoord.ofSectorLocal(i * SPACING, 0L, 0L, 0L, 0L, 0L), 64)) { + // A system with a STAR. A territory's seats include unbound worlds, which hold one + // world and no retinue - everything below is about a body that ORBITS something. + if (reg.starAt(anchor).isPresent()) { + return anchor; + } + } + } + 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) { + 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++) { + for (GalacticCoord seat : reg.anchorsInTerritory( + GalacticCoord.ofSectorLocal(i * SPACING, 0L, 0L, 0L, 0L, 0L), 64)) { + SystemBody parent = null; + for (SystemBody b : reg.systemBodiesAt(seat)) { + 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 + // 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()); + } + } + } + + /** + * 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[] 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); + + 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); + } + + /** + * 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[] 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); + + // 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(); + 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(SPACING / 2, 0.2d, GalaxyGenConfig.DEFAULT_GALAXY_SPACING, + GalaxyGenConfig.DEFAULT_GALAXY_DENSITY, null, 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 = 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); + 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/ShipTransitManagerTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/ShipTransitManagerTest.java index 6c97fa084..863b07f84 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/unit/ShipTransitManagerTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/ShipTransitManagerTest.java @@ -217,8 +217,11 @@ private static SpaceManager.Config never() { return new SpaceManager.Config(SpaceManager.GcPolicy.NEVER, 0, 0); } - // Speed >= the 4,000,000-block inter-cell distance so a single tick arrives; a small speed does not. - private static final long ARRIVE_IN_ONE_TICK = 5_000_000L; + // A speed that covers the inter-cell distance in ONE tick, so an arrival can be asserted without + // ticking a flight out. DERIVED from the cell edge (the same 1.25x margin it always carried), not + // written down: as a literal it silently became a speed that arrives in eight ticks the moment the + // cell grew, and eight of these tests then read as "the arrival never happened". + private static final long ARRIVE_IN_ONE_TICK = GalacticCoord.CELL * 5L / 4L; @Test public void departPutsShipInTransitAndAllocatesALane() { @@ -399,7 +402,11 @@ public void exportTransitsSnapshotsInFlightShips() { assertEquals("the origin is persisted: progress is meaningless without it", cell(1), r.origin); assertEquals(cell(2), r.target); assertEquals("nothing flown yet (not ticked)", 0L, r.travelledBlocks); - assertEquals("the flight is priced at depart, once", 4_000_000L, r.distanceBlocks); + // ONE cell apart, so the price IS the cell edge — bound to the constant, because this is the + // one distance here that is derived rather than chosen. The fixture distances passed to + // importTransit elsewhere in this file are NOT this number: they are magnitudes picked so a + // flight completes inside a test's tick budget, and they merely used to equal it. + assertEquals("the flight is priced at depart, once", GalacticCoord.CELL, r.distanceBlocks); assertEquals(7L, r.speed); assertTrue("no crew captured yet (option-A capture is the VS layer)", r.crew.isEmpty()); } @@ -712,7 +719,7 @@ public void aShipWhoseArrivalHasAlreadyLandedIsNotRecutFromHyperspace() { int originDim = space.materialize(cell(1)); mgr.beginTransit(UUID.randomUUID().toString(), cell(1), originDim, new BlockPos(0, 64, 0), - cell(2), 5_000_000L); + cell(2), ARRIVE_IN_ONE_TICK); assertEquals("control: while it is still parked, a re-cut is exactly what should happen", 1, mgr.refreshSnapshots()); diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/ShortJumpCrossesDirectlyTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/ShortJumpCrossesDirectlyTest.java new file mode 100644 index 000000000..1a4ce8c28 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/ShortJumpCrossesDirectlyTest.java @@ -0,0 +1,260 @@ +package zmaster587.advancedRocketry.test.unit; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import org.junit.Test; + +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.util.math.BlockPos; + +import zmaster587.advancedRocketry.space.CellFrames; +import zmaster587.advancedRocketry.space.GalacticCoord; +import zmaster587.advancedRocketry.space.HyperspaceTiles; +import zmaster587.advancedRocketry.space.ShipCrossingService; +import zmaster587.advancedRocketry.space.ShipLedger; +import zmaster587.advancedRocketry.space.ShipTransitManager; +import zmaster587.advancedRocketry.space.SlotBinder; +import zmaster587.advancedRocketry.space.SpaceManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * A jump short enough to be over before it presents itself is performed as ONE cell→cell crossing, + * not flown through hyperspace. + * + *

    What these pin is the DECISION and its consequences, counted rather than timed: how many crossings + * a jump costs, whether a lane is taken, whether a snapshot is cut, and what the ledger says while it + * happens. Timing would pin this machine.

    + */ +public class ShortJumpCrossesDirectlyTest { + + private static GalacticCoord cell(long s) { + return GalacticCoord.ofSectorLocal(s, 0L, 0L, 0L, 0L, 0L); + } + + private static final class FakeBinder implements SlotBinder { + final int[] dims; + FakeBinder(int... dims) { this.dims = dims; } + @Override public int[] slotDims() { return dims; } + @Override public void load(int dimId, String cellKey) { } + @Override public void unload(int dimId) { } + @Override public void discard(int dimId) { } + @Override public void deleteStore(String cellKey) { } + } + + /** Counts the hyperspace legs. A direct jump must not touch any of them. */ + private static final class CountingCrosser implements ShipTransitManager.Crosser { + int departs; + int sourceSnapshots; + + @Override + public ShipCrossingService.Crossed departToHyperspace(int srcSlotDim, BlockPos srcAnchor, + String shipId, HyperspaceTiles.Tile tile) { + departs++; + return new ShipCrossingService.Crossed(new BlockPos(0, 200, 0), UUID.randomUUID()); + } + + @Override + public ShipCrossingService.Crossed arriveFromHyperspace(String shipId, HyperspaceTiles.Tile tile, + BlockPos hyperAnchor, int targetSlotDim) { + return new ShipCrossingService.Crossed(new BlockPos(0, 200, 0), UUID.randomUUID()); + } + + @Override + public NBTTagCompound snapshotSource(int srcSlotDim, BlockPos srcAnchor) { + sourceSnapshots++; + return new NBTTagCompound(); + } + } + + /** Counts the direct crossings, and can refuse one. */ + private static final class CountingDirectCrosser implements ShipTransitManager.DirectCrosser { + final List crossings = new ArrayList<>(); + boolean refuse; + + @Override + public boolean crossDirect(String shipId, GalacticCoord origin, int originSlotDim, + BlockPos originAnchor, GalacticCoord target) { + crossings.add(shipId + " " + origin.cellKey() + "->" + target.cellKey()); + return !refuse; + } + } + + private static SpaceManager.Config never() { + return new SpaceManager.Config(SpaceManager.GcPolicy.NEVER, 0L, 0); + } + + /** How far the fixture's two cells are apart, read the way the departure reads it. */ + private static double fixtureDistance() { + return CellFrames.STATIC.distanceBetween(cell(1), cell(2), 0L); + } + + /** Fast enough that the whole leg fits inside the threshold — the direct case. */ + private static long directSpeed() { + return (long) Math.ceil(fixtureDistance() / ShipTransitManager.DIRECT_CROSSING_MAX_TICKS); + } + + /** Slow enough for a real flight: twice the threshold in ticks, so no rounding can reach it. */ + private static long flightSpeed() { + return Math.max(1L, + (long) (fixtureDistance() / (ShipTransitManager.DIRECT_CROSSING_MAX_TICKS * 2.0d))); + } + + /** + * The rule reads a DURATION, and its boundary is the point at which a flight stops having a middle. + * Stated in ticks with no geometry in the way: one block per tick makes distance and duration the + * same number. + */ + @Test + public void theRuleTurnsOverAtTheTickWhereAFlightStopsHavingACruise() { + long n = ShipTransitManager.DIRECT_CROSSING_MAX_TICKS; + + assertTrue("a leg of exactly N ticks has no cruise, so it is a crossing", + ShipTransitManager.isDirectCrossing(n, 1L)); + assertFalse("one tick more and there is a flight to fly", + ShipTransitManager.isDirectCrossing(n + 1, 1L)); + assertTrue("a fast enough drive makes a LONG leg short — the rule keys on duration, never " + + "on how far away the destination is", + ShipTransitManager.isDirectCrossing(n * 1_000_000.0d, 1_000_000L)); + } + + @Test + public void aShortJumpCostsExactlyOneCrossingAndTakesNoLane() { + SpaceManager space = new SpaceManager(new FakeBinder(10, 11), () -> 0L, never()); + HyperspaceTiles tiles = new HyperspaceTiles(); + CountingCrosser hyperspace = new CountingCrosser(); + CountingDirectCrosser direct = new CountingDirectCrosser(); + ShipTransitManager mgr = new ShipTransitManager(space, tiles, hyperspace); + mgr.setDirectCrosser(direct); + + int originDim = space.materialize(cell(1)); + boolean began = mgr.beginTransit("s", cell(1), originDim, new BlockPos(0, 64, 0), + cell(2), directSpeed()); + + assertTrue("the short jump was performed", began); + assertEquals("exactly one crossing", 1, direct.crossings.size()); + assertEquals("and none of them through hyperspace", 0, hyperspace.departs); + assertEquals("no hyperspace lane is taken by a jump that never enters hyperspace", + 0, tiles.inUseCount()); + assertEquals("nothing is in transit: there is no flight to be in the middle of", + 0, mgr.inTransitCount()); + assertFalse(mgr.isInTransit("s")); + } + + @Test + public void aShortJumpCutsNoSnapshotBecauseItHasNoMidFlightToRestore() { + SpaceManager space = new SpaceManager(new FakeBinder(10, 11), () -> 0L, never()); + CountingCrosser hyperspace = new CountingCrosser(); + ShipTransitManager mgr = new ShipTransitManager(space, new HyperspaceTiles(), hyperspace); + mgr.setDirectCrosser(new CountingDirectCrosser()); + + int originDim = space.materialize(cell(1)); + mgr.beginTransit("s", cell(1), originDim, new BlockPos(0, 64, 0), cell(2), directSpeed()); + + assertEquals("the depart-time floor cut exists to survive a restart mid-flight, and a crossing " + + "has no mid-flight; cutting one would persist a record of a jump nothing resumes", + 0, hyperspace.sourceSnapshots); + } + + /** + * The ledger is what a login reads. A row saying IN_TRANSIT resolves the player through the shared + * hyperspace world, so a direct crossing must never wear it — the ship's blocks are in a cell. + */ + @Test + public void aShortJumpNeverEntersTheInTransitState() { + SpaceManager space = new SpaceManager(new FakeBinder(10, 11), () -> 0L, never()); + ShipLedger ledger = new ShipLedger(); + ShipTransitManager mgr = new ShipTransitManager(space, new HyperspaceTiles(), + new CountingCrosser(), ledger, () -> 1000L); + mgr.setDirectCrosser(new CountingDirectCrosser()); + UUID ship = UUID.randomUUID(); + + int originDim = space.materialize(cell(1)); + assertTrue(mgr.beginTransit(ship.toString(), cell(1), originDim, new BlockPos(0, 64, 0), + cell(2), directSpeed())); + + ShipLedger.Entry e = ledger.get(ship); + // The crossing itself settles the row; this manager must not have written IN_TRANSIT over it. + assertTrue("the transit manager must not have put a direct crossing in transit", + e == null || e.state != ShipLedger.State.IN_TRANSIT); + } + + @Test + public void aLongJumpStillFliesThroughHyperspaceWithItsLaneAndItsSnapshot() { + SpaceManager space = new SpaceManager(new FakeBinder(10, 11), () -> 0L, never()); + HyperspaceTiles tiles = new HyperspaceTiles(); + CountingCrosser hyperspace = new CountingCrosser(); + CountingDirectCrosser direct = new CountingDirectCrosser(); + ShipTransitManager mgr = new ShipTransitManager(space, tiles, hyperspace); + mgr.setDirectCrosser(direct); + + int originDim = space.materialize(cell(1)); + boolean began = mgr.beginTransit("s", cell(1), originDim, new BlockPos(0, 64, 0), + cell(2), flightSpeed()); + + assertTrue(began); + assertEquals("a real flight is not a crossing", 0, direct.crossings.size()); + assertEquals("it departs into hyperspace", 1, hyperspace.departs); + assertEquals("holding a lane", 1, tiles.inUseCount()); + assertEquals("and carrying a snapshot, because it HAS a mid-flight to restore", + 1, hyperspace.sourceSnapshots); + assertTrue(mgr.isInTransit("s")); + } + + /** + * A refused crossing is a FAILED jump, not a jump by another route. The pilot has already paid the + * drive's burst against the mechanism he was quoted; quietly flying him through hyperspace instead + * would charge him for one flight and give him another, and would hide the refusal from the log. + */ + @Test + public void aRefusedShortJumpFailsRatherThanFallingBackToHyperspace() { + SpaceManager space = new SpaceManager(new FakeBinder(10, 11), () -> 0L, never()); + HyperspaceTiles tiles = new HyperspaceTiles(); + CountingCrosser hyperspace = new CountingCrosser(); + CountingDirectCrosser direct = new CountingDirectCrosser(); + direct.refuse = true; + ShipTransitManager mgr = new ShipTransitManager(space, tiles, hyperspace); + mgr.setDirectCrosser(direct); + + int originDim = space.materialize(cell(1)); + boolean began = mgr.beginTransit("s", cell(1), originDim, new BlockPos(0, 64, 0), + cell(2), directSpeed()); + + assertFalse("the jump failed", began); + assertEquals("it was attempted once", 1, direct.crossings.size()); + assertEquals("and not retried down the other path", 0, hyperspace.departs); + assertEquals("no lane was consumed by the failure", 0, tiles.inUseCount()); + assertEquals(0, mgr.inTransitCount()); + } + + /** + * The forecast and the flight must not be able to disagree. There is one predicate and both call + * it, so this pins the property that keeps them together rather than re-deriving the rule: the same + * (distance, speed) pair answers the same way however many times it is asked. + */ + @Test + public void theForecastAndTheDepartureCannotDisagreeBecauseThereIsOnlyOneRule() { + double distance = fixtureDistance(); + long speed = directSpeed(); + + boolean quoted = ShipTransitManager.isDirectCrossing(distance, speed); + + SpaceManager space = new SpaceManager(new FakeBinder(10, 11), () -> 0L, never()); + HyperspaceTiles tiles = new HyperspaceTiles(); + CountingDirectCrosser direct = new CountingDirectCrosser(); + ShipTransitManager mgr = new ShipTransitManager(space, tiles, new CountingCrosser()); + mgr.setDirectCrosser(direct); + int originDim = space.materialize(cell(1)); + mgr.beginTransit("s", cell(1), originDim, new BlockPos(0, 64, 0), cell(2), speed); + + boolean executed = !direct.crossings.isEmpty(); + assertEquals("what the console would quote is what the drive performed", quoted, executed); + assertNull("and the lane allocator was never asked for one", + tiles.inUseCount() == 0 ? null : "a lane was taken"); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/SkyNebulaeProducerTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/SkyNebulaeProducerTest.java new file mode 100644 index 000000000..946448666 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/SkyNebulaeProducerTest.java @@ -0,0 +1,170 @@ +package zmaster587.advancedRocketry.test.unit; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.junit.Test; + +import zmaster587.advancedRocketry.network.PacketSystemBodiesSync.RenderNebula; +import zmaster587.advancedRocketry.space.GalacticCoord; +import zmaster587.advancedRocketry.space.SkyNebulaeProducer; +import zmaster587.advancedRocketry.universe.IGalaxyGenerator; +import zmaster587.advancedRocketry.universe.Nebula; +import zmaster587.advancedRocketry.universe.PlanetarySystem; +import zmaster587.advancedRocketry.universe.UniverseLawsV0; +import zmaster587.advancedRocketry.universe.UniverseScale; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * What the sky is told about the clouds around a cell. + * + *

    These pin the promises a viewer can check: a cloud lies in the direction it really lies in, it + * LOOKS bigger from closer, a viewer inside one has it all around him, a cloud too small to be a + * landmark is left out rather than drawn as a speck, and a generator with no clusters produces an + * empty sky rather than a fabricated one. They do not pin the reach, the filter threshold or the cap + * — those are render tunables and moving them must not turn a test red.

    + */ +public class SkyNebulaeProducerTest { + + /** A cloud seated at a stated point, with a stated size. The cluster behind it is not read here. */ + private static Nebula cloudAt(double xLy, double yLy, double zLy, double radiusLy) { + return new Nebula(null, Nebula.Appearance.EMISSION, xLy, yLy, zLy, radiusLy, 0.8d, UniverseLawsV0.INSTANCE); + } + + /** A generator that answers with exactly these clouds, whatever is asked. */ + private static IGalaxyGenerator generatorOf(final List clouds) { + return new IGalaxyGenerator() { + @Override + public Optional systemAt(long seed, GalacticCoord coord) { + return Optional.empty(); + } + + @Override + public Map systemsInRegion(long seed, GalacticCoord min, + GalacticCoord max) { + return Collections.emptyMap(); + } + + @Override + public List nebulaeAround(long seed, GalacticCoord cell, double radiusLy) { + return new ArrayList<>(clouds); + } + }; + } + + /** The cell whose centre sits {@code ly} light years out along +X. */ + private static GalacticCoord cellAtLightYears(double ly) { + return GalacticCoord.ofSectorLocal(UniverseScale.cellsAt(ly), 0L, 0L, 0L, 0L, 0L); + } + + @Test + public void aCloudLiesInTheDirectionItReallyLies() { + // The one thing a landmark has to get right: look that way and it is there. + List sky = SkyNebulaeProducer.around( + generatorOf(Arrays.asList(cloudAt(0d, 0d, 200d, 40d))), 1L, GalacticCoord.ORIGIN); + + assertEquals("the one cloud in reach must be in the sky", 1, sky.size()); + RenderNebula drawn = sky.get(0); + assertEquals("a cloud straight along +Z must be drawn straight along +Z", 1.0F, drawn.dirZ, 1.0E-4F); + assertEquals(0.0F, drawn.dirX, 1.0E-4F); + assertEquals(0.0F, drawn.dirY, 1.0E-4F); + } + + @Test + public void aCloudLooksBiggerFromCloser() { + // What makes it a landmark rather than a decal: it opens as you close on it, so a pilot can + // tell whether he is approaching one. + // The cloud sits along +X because that is the axis the observer moves along; put it anywhere + // else and stepping "closer" walks past it, which is what the first version of this did. + List one = Arrays.asList(cloudAt(400d, 0d, 0d, 50d)); + float far = SkyNebulaeProducer.around(generatorOf(one), 1L, GalacticCoord.ORIGIN) + .get(0).angularRadius; + float near = SkyNebulaeProducer.around(generatorOf(one), 1L, cellAtLightYears(200d)) + .get(0).angularRadius; + + assertTrue("a cloud must subtend more from closer: far=" + far + " near=" + near, near > far); + } + + @Test + public void insideACloudItIsAllAroundYou() { + // The honest limit rather than an overflow: at zero distance the half-angle would diverge if + // it were computed on a plane, and a ship that flew into a cloud would see a NaN-sized hole. + RenderNebula inside = SkyNebulaeProducer.renderOf(cloudAt(0d, 0d, 10d, 100d), 0d, 0d, 0d); + + assertNotNull("a viewer inside a cloud still has a sky", inside); + assertEquals("and the cloud fills half of it", (float) (Math.PI / 2d), inside.angularRadius, + 1.0E-4F); + } + + @Test + public void aCloudTooSmallToBeALandmarkIsNotDrawn() { + // The LOD rule, stated as the thing it protects: a few pixels of haze is not a landmark, and + // drawing it costs a fan for something nobody can navigate by. + double farAway = 100_000d; + assertNull("a distant speck must be left out", + SkyNebulaeProducer.renderOf(cloudAt(0d, 0d, farAway, 1d), 0d, 0d, 0d)); + assertNotNull("while the same cloud near enough to see must not be", + SkyNebulaeProducer.renderOf(cloudAt(0d, 0d, 50d, 1d), 0d, 0d, 0d)); + } + + @Test + public void aGeneratorWithNoCloudsGivesAnEmptySkyAndNotAFabricatedOne() { + // The negative case the whole feed has to keep: a universe with no clusters has no gas, and + // an empty sky must stay empty rather than acquire a default cloud. + assertTrue("void must yield no clouds", + SkyNebulaeProducer.around(generatorOf(Collections.emptyList()), 1L, + GalacticCoord.ORIGIN).isEmpty()); + assertTrue("and so must no generator at all", + SkyNebulaeProducer.around(null, 1L, GalacticCoord.ORIGIN).isEmpty()); + } + + @Test + public void theSkyIsOrderedLargestFirstSoTheCapDropsTheLeastVisible() { + // The cap is a bound on work, and a bound on work must never decide WHICH landmark survives + // by accident of enumeration order. + List many = new ArrayList<>(); + for (int i = 1; i <= SkyNebulaeProducer.MAX_PER_CELL + 6; i++) { + many.add(cloudAt(0d, 0d, 100d * i, 30d * i * 0.5d)); + } + List sky = SkyNebulaeProducer.around(generatorOf(many), 1L, GalacticCoord.ORIGIN); + + assertTrue("the sky must be capped: " + sky.size(), sky.size() <= SkyNebulaeProducer.MAX_PER_CELL); + for (int i = 1; i < sky.size(); i++) { + assertTrue("clouds must be ordered largest first", + sky.get(i - 1).angularRadius >= sky.get(i).angularRadius); + } + } + + @Test + public void whatIsSeatedAndWhatIsDrawnAreSeparatelyReadable() { + // So a reader can tell a working LOD filter from a missing cloud — the distinction the probe + // reply reports and a test would otherwise have to guess at. + List mixed = Arrays.asList(cloudAt(0d, 0d, 200d, 40d), cloudAt(0d, 0d, 200_000d, 1d)); + IGalaxyGenerator gen = generatorOf(mixed); + + assertEquals("both are out there", 2, SkyNebulaeProducer.countAround(gen, 1L, GalacticCoord.ORIGIN)); + assertEquals("only one is worth drawing", 1, + SkyNebulaeProducer.around(gen, 1L, GalacticCoord.ORIGIN).size()); + } + + @Test + public void aCloudCarriesItsAppearanceAndItsThickness() { + // The two fields the renderer branches on: the age sequence decides the tint, and a dark + // cloud is the one that must be drawn OVER the stars rather than behind them. + Nebula dark = new Nebula(null, Nebula.Appearance.DARK, 0d, 0d, 150d, 40d, 0.6d, UniverseLawsV0.INSTANCE); + RenderNebula drawn = SkyNebulaeProducer.renderOf(dark, 0d, 0d, 0d); + + assertNotNull(drawn); + assertEquals("the appearance must survive the trip to the client", + Nebula.Appearance.DARK.ordinal(), drawn.appearanceOrdinal); + assertEquals("and so must how thick it is", 0.6F, drawn.opacity, 1.0E-4F); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/StarClusterTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/StarClusterTest.java new file mode 100644 index 000000000..c992dd173 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/StarClusterTest.java @@ -0,0 +1,307 @@ +package zmaster587.advancedRocketry.test.unit; + +import org.junit.Test; + +import java.util.Optional; + +import zmaster587.advancedRocketry.space.GalacticCoord; +import zmaster587.advancedRocketry.universe.ClusterField; +import zmaster587.advancedRocketry.universe.ClusteredGalaxyGenerator; +import zmaster587.advancedRocketry.universe.Galaxy; +import zmaster587.advancedRocketry.universe.GalaxyGenConfig; +import zmaster587.advancedRocketry.universe.StarCluster; +import zmaster587.advancedRocketry.universe.UniverseLawsV0; +import zmaster587.advancedRocketry.universe.UniverseScale; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Contract tests for star clusters — the seat one level BELOW the star lattice. + * + *

    What is pinned is the mechanism, not the richness: the fine lattice TILES the coarse cells it + * replaces exactly (no seam, no partial cell, no overlap), membership is a property of the COARSE cell + * so ownership stays one question with one answer, the separation floor follows the LOCAL lattice + * level rather than a global constant, and a cluster cannot refine a cell below what a system needs. + * The subdivisions and radii are balance knobs and are fed in as inputs.

    + */ +public class StarClusterTest { + + private static final long SEED = 0x51A25L; + + private static GalaxyGenConfig cfg() { + return GalaxyGenConfig.defaults(); + } + + private static GalaxyGenConfig.ClusterType type(int k) { + return new GalaxyGenConfig.ClusterType("Test", k, 5d, 15d, 0.5d, false, 1); + } + + // ─── The commensurate construction ───────────────────────────────────────── + + @Test + public void theFineLatticeTilesACoarseCellExactly() { + // The one word the whole mechanism rests on. If the sub-cells did not tile, every coarse cell + // would carry a partial cell at its top face and the boundary would need a rule of its own — + // which is exactly the cost a graded spacing was rejected for. + for (int k : new int[] {2, 3, 4, 7, 14, 25, 215}) { + for (long coarseEdge : new long[] {1_000L, 40_018_890L, 999_983L}) { + StarCluster c = new StarCluster(type(k), k, 0L, 0L, 0L, 3L); + long covered = 0L; + long previousHigh = 0L; + for (long i = 0; i < k; i++) { + long low = c.subCellLow(i, coarseEdge); + long edge = c.subCellEdge(i, coarseEdge); + assertEquals("sub-cell " + i + " must start where " + (i - 1) + " ended", + previousHigh, low); + previousHigh = low + edge; + covered += edge; + } + assertEquals("k=" + k + " over an edge of " + coarseEdge + " must tile it exactly", + coarseEdge, covered); + } + } + } + + @Test + public void everyOffsetLandsInExactlyOneSubCell() { + // The inverse of the tiling: a coordinate must resolve to one sub-cell, and that sub-cell must + // be the one whose bounds contain it. A mismatch here is a system addressed by a cell it does + // not sit in. + long coarseEdge = 40_018_890L; + StarCluster c = new StarCluster(type(25), 25, 0L, 0L, 0L, 3L); + for (long offset : new long[] {0L, 1L, coarseEdge / 3L, coarseEdge / 2L, coarseEdge - 1L}) { + long i = c.subCellIndex(offset, coarseEdge); + assertTrue("index " + i + " out of range for offset " + offset, i >= 0 && i < 25); + assertTrue("offset " + offset + " is not inside the sub-cell it resolved to", + offset >= c.subCellLow(i, coarseEdge) + && offset < c.subCellLow(i, coarseEdge) + c.subCellEdge(i, coarseEdge)); + } + } + + @Test + public void membershipIsAPropertyOfTheCoarseCell() { + // Snapped to coarse cell faces, which is what makes the fine lattice tile and what keeps + // "which lattice does this coordinate live on" an O(1) question with one answer. The shape + // stays a ball, because the test is on the super-cell INDEX rather than on a box. + StarCluster c = new StarCluster(type(4), 4, 10L, 10L, 10L, 3L); + assertTrue(c.containsSuperCell(10L, 10L, 10L)); + assertTrue(c.containsSuperCell(13L, 10L, 10L)); + assertFalse(c.containsSuperCell(14L, 10L, 10L)); + assertFalse("a ball, not a box: the corner is outside", c.containsSuperCell(13L, 13L, 13L)); + } + + // ─── Seating ─────────────────────────────────────────────────────────────── + + @Test + public void everyGalaxyHasANucleusAtItsOwnCentre() { + // Not a special case: the nucleus is a cluster like the others, drawn at a known place instead + // of a drawn one, and it is the richest of them. + ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(cfg()); + ClusterField clusters = gen.clusters(); + Galaxy home = gen.galaxies().home(SEED); + Optional nucleus = clusters.nucleusOf(SEED, home); + assertTrue(nucleus.isPresent()); + + long s = cfg().minSpacing; + assertTrue("the nucleus must cover the galaxy's own centre", + nucleus.get().containsSuperCell(Math.floorDiv(home.centre().sectorX(), s), + Math.floorDiv(home.centre().sectorY(), s), + Math.floorDiv(home.centre().sectorZ(), s))); + assertTrue("and it must be the richest cluster there is", + nucleus.get().subdivision() > cfg().clusterTypes.get(0).subdivision); + } + + @Test + public void aNUCLEUSscalesToItsOwnGalaxyWhileTheOtherClustersDoNot() { + // The distinction the table cannot hold in one number. An open cluster's and a globular's + // contrast is measured against the FIELD, whose density is real and the same everywhere — so it + // is a constant. A NUCLEUS's contrast is a statement about its own galaxy's POPULATION, and the + // table's figure is the real one for a REFERENCE-sized galaxy. + // + // Measured failure it replaces: at a flat k = 215 a 921-light-year dwarf — the size satellite + // galaxies routinely are — got ~4·10^7 stars inside a six-light-year core while holding ~10^7 + // altogether. A nucleus four times its own galaxy. Population goes as radius cubed and k cubed, + // so k has to go as the radius. + ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(cfg()); + ClusterField clusters = gen.clusters(); + + int atReference = clusters.nucleusOf(SEED, galaxyOfRadius( + UniverseScale.REFERENCE_GALAXY_RADIUS_LY)).get().subdivision(); + assertEquals("a reference-sized galaxy must get the table's own figure", + GalaxyGenConfig.NUCLEUS.subdivision, atReference); + + int atTenth = clusters.nucleusOf(SEED, galaxyOfRadius( + UniverseScale.REFERENCE_GALAXY_RADIUS_LY / 10d)).get().subdivision(); + assertTrue("a galaxy a tenth the size must get a proportionally thinner core, not the same one:" + + " " + atTenth + " vs " + atReference, + atTenth < atReference); + assertTrue("and never below one — a nucleus that refines nothing is still a place", + atTenth >= 1); + + // The bound that matters, said in the units of the defect: a nucleus may not hold more stars + // than the galaxy it is the centre of. Both counts are k^3 x volume against the same field, so + // the comparison needs no population model — just the two volumes. + double dwarfRadius = 921d; + Galaxy dwarf = galaxyOfRadius(dwarfRadius); + int k = clusters.nucleusOf(SEED, dwarf).get().subdivision(); + double coreLy = GalaxyGenConfig.NUCLEUS.maxRadiusLy; + double coreShare = Math.pow((double) k, 3d) * Math.pow(coreLy / dwarfRadius, 3d); + System.out.println(String.format( + "a %.0f ly galaxy gets nucleus k=%d; its core holds %.4f of the galaxy's own stars", + dwarfRadius, k, coreShare)); + assertTrue("a dwarf's nucleus holds " + String.format("%.2f", coreShare) + + " of its whole galaxy", coreShare < 0.5d); + } + + /** A galaxy of a stated radius, at the origin — the subject when the SIZE is what is under test. */ + private static Galaxy galaxyOfRadius(double radiusLy) { + return new Galaxy(0L, 0L, 0L, 0, GalacticCoord.ORIGIN, cfg().galaxyTypes.get(0), radiusLy, + 0d, 0d, Math.toRadians(20d), 0d, + zmaster587.advancedRocketry.universe.LightYearVector.ZERO, UniverseLawsV0.INSTANCE); + } + + @Test + public void aClusterNeverStraddlesItsOwnLatticeCell() { + // The same containment the galaxy tier needs, one level down and for the same reason: a + // cluster reaching into a neighbouring cluster cell would make ownership ambiguous. + ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(cfg()); + ClusterField clusters = gen.clusters(); + Galaxy home = gen.galaxies().home(SEED); + long spacing = clusters.spacingSuperCells(); + int checked = 0; + for (long cx = -3L; cx <= 3L; cx++) { + for (long cy = -2L; cy <= 2L; cy++) { + Optional c = clusters.clusterAtIndex(SEED, home, cx, cy, 0L); + if (!c.isPresent()) { + continue; + } + long r = c.get().radiusSuperCells(); + assertTrue("a cluster reaches past its cell's low face", + c.get().centreSuperX() - r >= cx * spacing); + assertTrue("a cluster reaches past its cell's high face", + c.get().centreSuperX() + r <= cx * spacing + spacing - 1L); + checked++; + } + } + assertTrue("the sweep must find clusters", checked > 3); + } + + @Test + public void clustersOnlyExistWhereTheirGalaxyHasStars() { + // One function, not a second rule: a cluster's occupancy is scaled by the same density profile + // that placed the systems, so clusters stop where the galaxy does. + ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(cfg()); + Galaxy home = gen.galaxies().home(SEED); + long farSuper = UniverseScale.cellsForLightYears(home.radiusLy() * 4d) / cfg().minSpacing; + long farCluster = farSuper / gen.clusters().spacingSuperCells() + 1L; + assertFalse("a cluster turned up outside its own galaxy", + gen.clusters().clusterAtIndex(SEED, home, farCluster, 0L, 0L).isPresent()); + } + + // ─── What the fine lattice does to the star field ────────────────────────── + + @Test + public void aClusterHoldsFarMoreStarsThanTheFieldAroundit() { + // The point of the whole tier: the stratified lattice caps density at about three times the + // mean, while a real cluster runs tens of times the field. Measured as seats found in the same + // volume, inside a cluster and beside it. + ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(cfg()); + Galaxy home = gen.galaxies().home(SEED); + ClusterField clusters = gen.clusters(); + long s = cfg().minSpacing; + + StarCluster found = null; + for (long cx = -6L; cx <= 6L && found == null; cx++) { + for (long cy = -4L; cy <= 4L && found == null; cy++) { + Optional c = clusters.clusterAtIndex(SEED, home, cx, cy, 0L); + if (c.isPresent() && c.get().subdivision() > 1) { + found = c.get(); + } + } + } + assertTrue("the sweep must find a cluster to measure", found != null); + + int inside = seatsInSuperCell(gen, found.centreSuperX(), found.centreSuperY(), + found.centreSuperZ(), s); + int outside = seatsInSuperCell(gen, found.centreSuperX() + 6L * found.radiusSuperCells(), + found.centreSuperY(), found.centreSuperZ(), s); + assertTrue("a cluster must be denser than the field beside it (" + inside + " vs " + outside + + ") for " + found, inside > outside); + // The field outside is no longer "one seat per coarse cell" — every territory is divided + // uniformly so that a free-floating population can be counted — so what is pinned is the + // CONTRAST that makes a cluster a cluster. A cluster subdivides k times further and is meant + // to be k-cubed times denser; requiring only a factor of k keeps this a tripwire against the + // contrast collapsing rather than a re-measurement of the draw's variance. + assertTrue("a cluster must out-hold the field beside it by at least its own subdivision (" + + inside + " vs " + outside + " at k=" + found.subdivision() + ")", + inside >= outside * found.subdivision()); + } + + @Test + public void aClusterCannotRefineACellBelowWhatASystemNeeds() { + // A cluster cannot conjure room its coarse cell never had. Refining below the smallest cell a + // system can be more than a lone star in would produce a field of bare stars, which is the + // opposite of a cluster — so a spacing too tight to refine simply is not refined, exactly as + // too tight a spacing already degenerates rather than erroring. + int tiny = 16; + ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(new GalaxyGenConfig(tiny, 0.9d, + GalaxyGenConfig.DEFAULT_GALAXY_SPACING, GalaxyGenConfig.DEFAULT_GALAXY_DENSITY, + null, null)); + assertTrue("a spacing of " + tiny + " cells is below the refinement floor", + tiny < UniverseScale.MIN_LATTICE_EDGE_CELLS * 2L); + + // At this spacing every seat must still attribute to its own COARSE super-cell, i.e. nothing + // was subdivided into cells too small to hold anything. + int checked = 0; + for (long sup = 0; sup < 40; sup++) { + Optional anchor = gen.anchorAt(SEED, + GalacticCoord.ofSectorLocal(sup * tiny, 0L, 0L, 0L, 0L, 0L)); + if (!anchor.isPresent()) { + continue; + } + assertEquals("a seat must stay in the coarse cell that was probed", sup, + Math.floorDiv(anchor.get().sectorX(), (long) tiny)); + checked++; + } + assertTrue(checked > 3); + } + + @Test + public void attributionNeverCrossesACoarseSuperCell() { + // The invariant that survives the refinement: whatever lattice is in force locally, a cell is + // attributed to a seat inside its OWN coarse super-cell. That is what keeps member attribution + // exact and two systems' neighbourhoods from interleaving. + ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(cfg()); + long s = cfg().minSpacing; + int checked = 0; + for (long sup = -3L; sup <= 3L; sup++) { + for (long offset : new long[] {0L, s / 4L, s / 2L, s - 1L}) { + GalacticCoord probe = GalacticCoord.ofSectorLocal(sup * s + offset, 0L, 0L, 0L, 0L, 0L); + Optional anchor = gen.anchorAt(SEED, probe); + if (!anchor.isPresent()) { + continue; + } + assertEquals("attribution crossed a coarse super-cell face", sup, + Math.floorDiv(anchor.get().sectorX(), s)); + checked++; + } + } + assertTrue(checked > 3); + } + + /** + * Every seat inside ONE coarse super-cell, enumerated through the region query so the sub-lattice + * is walked the way the generator itself walks it. Counting probes along a line would miss every + * sub-cell off that line, and would read a refined cell as ordinary field. + */ + private static int seatsInSuperCell(ClusteredGalaxyGenerator gen, long supX, long supY, long supZ, + long s) { + return gen.systemsInRegion(SEED, + GalacticCoord.ofSectorLocal(supX * s, supY * s, supZ * s, 0L, 0L, 0L), + GalacticCoord.ofSectorLocal(supX * s + s - 1L, supY * s + s - 1L, supZ * s + s - 1L, + 0L, 0L, 0L)).size(); + } + +} 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..36acb60ba --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/StellarHierarchyTest.java @@ -0,0 +1,248 @@ +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); + } + + // ─── the forms the model must be able to express ─────────────────────────── + + @Test + public void anSTypeWorldIsLitByBothStarsOfItsPair() { + // The form: a wide binary whose COMPANION carries the world. It is a planet in a binary, not + // a planet with one sun that happens to have a bright neighbour — so the light it receives + // must include the primary's, and must fall as the pair is drawn apart. + StellarBody primary = star("A", 1f); + primary.setId(3); + StellarBody companion = star("B", 1f); + companion.setId(4); + companion.setOrbitalDistance(200); // 2 AU: a close pair + primary.addSubStar(companion); + + double closePair = AstronomicalBodyHelper.getStellarBrightness(companion, 100); + double lone = AstronomicalBodyHelper.getStellarBrightness(star("C", 1f), 100); + assertTrue("a world of the companion must be lit by the primary too (" + closePair + + " vs a lone star's " + lone + ")", closePair > lone); + + companion.setOrbitalDistance(20_000); // 200 AU: a wide pair + double widePair = AstronomicalBodyHelper.getStellarBrightness(companion, 100); + assertTrue("drawing the pair apart must cost the world the primary's light (" + closePair + + " -> " + widePair + ")", widePair < closePair); + assertTrue("...but never below what its own star alone delivers", widePair >= lone * 0.999d); + } + + @Test + public void aCircumbinaryWorldIsLitByBothStarsOfItsPair() { + // The other form of the same pair: the world is bound to the PRIMARY and the companion is + // one of its suns. Neither arrangement may need a special case. + StellarBody primary = star("A", 1f); + StellarBody companion = star("B", 1f); + companion.setOrbitalDistance(50); + primary.addSubStar(companion); + + double both = AstronomicalBodyHelper.getStellarBrightness(primary, 100); + double alone = AstronomicalBodyHelper.getStellarBrightness(star("C", 1f), 100); + assertTrue("a circumbinary world must be warmed by both (" + both + " vs " + alone + ")", + both > alone); + } + + @Test + public void everyStarOfAThreeStarHierarchyContributesToTheLightAWorldGets() { + // Composition, not enumeration: adding a third star to the system must add its flux from + // wherever it hangs in the tree, or "hierarchical" is a storage claim and nothing more. + StellarBody primary = star("A", 1f); + StellarBody companion = star("B", 1f); + companion.setOrbitalDistance(100); + primary.addSubStar(companion); + double two = AstronomicalBodyHelper.getStellarBrightness(primary, 100); + + StellarBody third = star("C", 1f); + third.setOrbitalDistance(60); + companion.addSubStar(third); + double three = AstronomicalBodyHelper.getStellarBrightness(primary, 100); + + assertTrue("a third star must light the world too (" + two + " -> " + three + ")", + three > two); + } +} 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..9336b022f 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()); @@ -182,4 +182,56 @@ public void kindDescendCapability() { assertFalse(SystemBodyKind.ASTEROID_BELT.canDescend()); assertFalse(SystemBodyKind.STATION_SLOT.canDescend()); } + + @Test + public void aBodyCarriesItsOwnRadiusThroughNbt() { + // A body's SIZE travels with it: nothing downstream can recover it (a procedural world has no + // dimension until a descent mints one, and the render feed reaches a client with no registry). + SystemBody sized = SystemBody.fixedAt(GalacticCoord.ORIGIN, SystemBodyKind.GAS_GIANT, + Constants.INVALID_PLANET, 4).withRadius(11.2d); + NBTTagCompound tag = new NBTTagCompound(); + sized.writeToNBT(tag); + assertEquals(11.2d, SystemBody.readFromNBT(tag).radiusEarths(), 1e-9); + + // A body that is not a sphere says so, and says it the same way after a round trip — the + // renderer draws that as a marker rather than inventing a size for it. + SystemBody belt = SystemBody.fixedAt(GalacticCoord.ORIGIN, SystemBodyKind.ASTEROID_BELT, + Constants.INVALID_PLANET, 4); + NBTTagCompound beltTag = new NBTTagCompound(); + belt.writeToNBT(beltTag); + assertEquals(SystemBody.RADIUS_UNKNOWN, SystemBody.readFromNBT(beltTag).radiusEarths(), 0d); + assertFalse("an unstated radius writes no key at all", beltTag.hasKey("radiusEarths")); + } + + @Test + public void aGiantsMoonSystemSpansFromInsideItsOwnRadiusToBeyondTheCell() { + // The last form the model owes: a giant whose retinue runs from a moon skimming its surface + // out to one that no longer fits in the cell they share. Both must be EXPRESSIBLE, and the + // far one must not corrupt the address — a body outside its own cell would be a body in a + // different cell, so the offset saturates on the face instead (and, since 2026-08-16, says + // so in the log rather than flattening a whole moon system onto one point in silence). + GalacticCoord giantCell = GalacticCoord.ofSectorLocal(9, 0, -3, 0, 0, 0); + CellFrame frame = CellFrame.staticAt(giantCell); + + SystemBody inner = new SystemBody(giantCell, frame, orbit(2d, 100_000L), + SystemBodyKind.MOON, Constants.INVALID_PLANET, 1); + SystemBody outer = new SystemBody(giantCell, frame, + orbit(4d, GalacticCoord.HALF_CELL), SystemBodyKind.MOON, + Constants.INVALID_PLANET, 1); + + assertEquals("both moons share the giant's cell — they are one destination", + inner.name(), outer.name()); + assertNotEquals("and they are not in the same place inside it", + inner.inCellOffsetAt(0L), outer.inCellOffsetAt(0L)); + + for (long tick = 0L; tick < 1000L; tick += 137L) { + long dx = Math.abs(outer.inCellOffsetAt(tick).dx()); + long dy = Math.abs(outer.inCellOffsetAt(tick).dy()); + long dz = Math.abs(outer.inCellOffsetAt(tick).dz()); + assertTrue("an offset may never leave the cell that names it, got " + dx + "," + dy + + "," + dz + " against a half-cell of " + GalacticCoord.HALF_CELL, + dx <= GalacticCoord.HALF_CELL && dy <= GalacticCoord.HALF_CELL + && dz <= GalacticCoord.HALF_CELL); + } + } } 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..de6f92575 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/SystemRetinueTest.java @@ -0,0 +1,586 @@ +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.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.PlanetarySystem; +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.assertNotNull; +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); + } + + /** The shipped spacing: a system laid out here is the system the game ships. */ + private static final int SPACING = GalaxyGenConfig.DEFAULT_MIN_SPACING; + + /** + * 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(minSpacing, 0.9d, + GalaxyGenConfig.DEFAULT_GALAXY_SPACING, GalaxyGenConfig.DEFAULT_GALAXY_DENSITY, + null, null)); + } + + /** + * Every anchor in a sweep of super-cells that holds a system with a STAR. + * + *

    Starless systems are skipped, and the filter is the subject of this class rather than a + * convenience: a retinue is what orbits a star — a zone, a snow line, a belt at the outer edge of + * one — and a system with no star has none of those to get right. What a rogue keeps instead, and + * that it still honours one real body per cell, is {@code VoidContentTest}'s.

    + */ + 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++) { + // What the TERRITORY holds, not what its corner point resolves to. The lattice is + // divided uniformly, so a point probe samples one seat in k-cubed — a sweep built + // on one reads a populated field as an almost empty one. + for (GalacticCoord a : g.anchorsInTerritory(seed, + cell(sx * minSpacing, sy * minSpacing, sz * minSpacing), 64)) { + if (!seen.add(a.cellKey())) { + continue; + } + Optional sys = g.systemAt(seed, a); + if (sys.isPresent() && sys.get().star().isPresent()) { + out.add(a); + } + } + } + } + } + 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 = SPACING; + 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 = CRAMPED_SPACING; + 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); + } + + // ─── what a system loses when it does not fit ────────────────────────────── + + @Test + public void atTheShippedScaleASingleStarLosesNoBodyAtAll() { + // The clear-space 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. + // + // SINGLE stars only: a system with a companion loses worlds to the band around it, which is + // a different mechanism with its own test and must not be able to mask this one. + ClusteredGalaxyGenerator g = gen(SPACING); + int checked = 0; + for (GalacticCoord anchor : anchors(g, SEED, SPACING, 2)) { + if (!g.systemAt(SEED, anchor).get().star().get().getSubStars().isEmpty()) { + continue; + } + 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().get(); + 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); + } + + @Test + public void aCompanionCostsItsSystemTheWorldsItStandsAmong() { + // The other half of the same rule: where a star sits, worlds cannot. A multiple system is + // therefore allowed to hold fewer worlds than its retinue drew — and the test exists so that + // "fewer" stays a consequence of the companion rather than of something silently going wrong. + ClusteredGalaxyGenerator g = gen(SPACING); + int multiple = 0; + int lostSome = 0; + // A WIDER sweep than its neighbours (3 super-cells, not 2) because this test's thresholds are + // about a proportion — some systems lose worlds, not all of them — and a proportion needs a + // sample. At 2 the sweep returned 28 anchors of which 10 were multiple, i.e. the "more than + // ten multiple systems" arrangement sat exactly ON its own threshold and turned a re-rolled + // universe into a failure about nothing. The rate itself (10/28 = 36 %) is what the + // multiplicity contract says it should be. + for (GalacticCoord anchor : anchors(g, SEED, SPACING, 3)) { + if (g.systemAt(SEED, anchor).get().star().get().getSubStars().isEmpty()) { + continue; + } + multiple++; + int have = 0; + for (SystemBody b : g.bodiesFor(SEED, anchor)) { + if (b.kind() == SystemBodyKind.PLANET || b.kind() == SystemBodyKind.GAS_GIANT) { + have++; + } + } + if (have < ClusteredGalaxyGenerator.retinueSize(SEED, anchor)) { + lostSome++; + } + } + assertTrue("the sweep must find multiple systems, saw " + multiple + " of " + + anchors(g, SEED, SPACING, 3).size() + " anchors", multiple > 10); + assertTrue("a companion must cost its system something, or the band is not being applied", + lostSome > 0); + assertTrue("but it must not cost every system everything, saw " + lostSome + "/" + multiple, + lostSome < multiple); + } + + // ─── multiplicity ────────────────────────────────────────────────────────── + + @Test + public void someSystemsHoldMoreThanOneStarAndMostDoNot() { + // The generator had never produced a companion — its own javadoc said so — while about half + // of real stars are not alone. What is pinned is the SHAPE: multiple systems are common but + // not the rule, and a system never holds an unbounded pile of stars. + ClusteredGalaxyGenerator g = gen(SPACING); + int systems = 0; + int multiple = 0; + int mostStars = 0; + for (GalacticCoord anchor : anchors(g, SEED, SPACING, 2)) { + StellarBody star = g.systemAt(SEED, anchor).get().star().get(); + int stars = 1 + star.getSubStars().size(); + systems++; + if (stars > 1) { + multiple++; + } + mostStars = Math.max(mostStars, stars); + } + assertTrue("the sweep must find systems", systems > 20); + assertTrue("multiple systems must exist at all", multiple > 0); + assertTrue("and single ones must stay the majority, saw " + multiple + "/" + systems, + multiple * 2 < systems * 3); + assertTrue("a system must be able to hold three stars, saw at most " + mostStars, + mostStars >= 2); + } + + @Test + public void everyStarOfASystemHasAnIdOfItsOwn() { + // The defect that made a companion unaddressable: it was handed the primary's id, so no + // starId value could ever mean "I orbit the companion" and a companion could own no world. + ClusteredGalaxyGenerator g = gen(SPACING); + int checked = 0; + for (GalacticCoord anchor : anchors(g, SEED, SPACING, 2)) { + StellarBody star = g.systemAt(SEED, anchor).get().star().get(); + Set ids = new HashSet<>(); + assertTrue(ids.add(star.getId())); + for (StellarBody companion : star.getSubStars()) { + assertTrue("companion " + companion.getName() + " repeats an id of its own system", + ids.add(companion.getId())); + assertTrue("a procedural star id must stay synthetic (negative)", + companion.getId() < 0); + assertTrue("a companion is never larger than the star its system is named for", + companion.getSize() <= star.getSize()); + } + checked++; + } + assertTrue(checked > 20); + } + + @Test + public void aCompanionIsABodyOfItsSystemStandingAtItsOwnSeparation() { + // A companion that existed only on the star object would light the worlds here and appear at + // no address at all. It must be a body, in a cell of its own, exactly where its own elements + // put it — the star object and the body standing for it are one statement. + ClusteredGalaxyGenerator g = gen(SPACING); + int checkedCompanions = 0; + for (GalacticCoord anchor : anchors(g, SEED, SPACING, 2)) { + StellarBody star = g.systemAt(SEED, anchor).get().star().get(); + if (star.getSubStars().isEmpty()) { + continue; + } + Map starBodies = new HashMap<>(); + for (SystemBody b : g.bodiesFor(SEED, anchor)) { + if (b.kind() == SystemBodyKind.STAR) { + starBodies.put(b.starId(), b); + } + } + for (StellarBody companion : star.getSubStars()) { + SystemBody body = starBodies.get(companion.getId()); + assertNotNull("companion " + companion.getName() + " is in no body list", body); + assertFalse("a companion must hold a cell of its own, not the primary's", + body.name().sameCell(anchor)); + double placed = body.absoluteAt(0L).distanceTo( + zmaster587.advancedRocketry.space.AbsolutePos.ofCellName(anchor)); + double expected = (double) companion.getOrbitalDistance() + * zmaster587.advancedRocketry.util.AstronomicalBodyHelper.BLOCKS_PER_ORBIT_UNIT; + assertEquals("a companion stands at the separation its own elements state", + expected, placed, expected * 1e-6d + 2d); + checkedCompanions++; + } + } + assertTrue("the sweep must contain companions", checkedCompanions > 3); + } + + @Test + public void noWorldSitsWhereAnotherStarWouldTearItAway() { + // A planet between roughly a third of a companion's separation and three times it is on an + // orbit neither a circumbinary nor a satellite path can hold. The retinue accommodates the + // stars rather than the stars accommodating the retinue — which is also the only order that + // can be computed, because a star's zone follows the system's luminosity and the luminosity + // follows where its stars stand. + ClusteredGalaxyGenerator g = gen(SPACING); + int checked = 0; + for (GalacticCoord anchor : anchors(g, SEED, SPACING, 2)) { + StellarBody star = g.systemAt(SEED, anchor).get().star().get(); + if (star.getSubStars().isEmpty()) { + continue; + } + for (SystemBody b : g.bodiesFor(SEED, anchor)) { + if (b.kind() != SystemBodyKind.PLANET && b.kind() != SystemBodyKind.GAS_GIANT) { + continue; + } + for (StellarBody companion : star.getSubStars()) { + double sep = companion.getOrbitalDistance(); + assertTrue("a world at " + b.orbitalDistance() + " sits beside a star at " + sep + + ", where no orbit survives", + b.orbitalDistance() <= sep / 3d || b.orbitalDistance() >= sep * 3d); + checked++; + } + } + } + assertTrue("the sweep must contain multiple systems with worlds", checked > 10); + } + + // ─── 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 = SPACING; + 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 = SPACING; + 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 = SPACING; + 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 = SPACING; + 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 = SPACING; + 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 = SPACING; + 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; + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/TelescopeConeSurveyTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/TelescopeConeSurveyTest.java new file mode 100644 index 000000000..ddf5ca9d7 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/TelescopeConeSurveyTest.java @@ -0,0 +1,610 @@ +package zmaster587.advancedRocketry.test.unit; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.junit.After; +import org.junit.Test; + +import zmaster587.advancedRocketry.api.ARConfiguration; +import zmaster587.advancedRocketry.api.Constants; +import zmaster587.advancedRocketry.api.dimension.solar.StellarBody; +import zmaster587.advancedRocketry.navigation.CrystalMemory; +import zmaster587.advancedRocketry.space.GalacticCoord; +import zmaster587.advancedRocketry.universe.ClusteredGalaxyGenerator; +import zmaster587.advancedRocketry.universe.ConeWalk; +import zmaster587.advancedRocketry.universe.EmptyGalaxyGenerator; +import zmaster587.advancedRocketry.universe.GalaxyGenConfig; +import zmaster587.advancedRocketry.universe.RegionScan; +import zmaster587.advancedRocketry.universe.StellarMagnitude; +import zmaster587.advancedRocketry.universe.SystemBody; +import zmaster587.advancedRocketry.universe.SystemBodyKind; +import zmaster587.advancedRocketry.universe.TelescopeScan; +import zmaster587.advancedRocketry.universe.UniverseRegistry; +import zmaster587.advancedRocketry.universe.UniverseScale; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Contract tests for what a telescope LOOKS AT and what it can SEE. + * + *

    Two claims, and they are the whole of the redesign. A survey is a cone with its apex at + * the instrument rather than a box of coordinates with no observer; and what it finds is bounded by + * brightness rather than by a configured horizon, so its reach is derived from its aperture + * and is a different distance for a red dwarf than for a blue giant.

    + * + *

    These pin player-facing promises: a better aperture reaches farther, dust costs reach the same + * way distance does, a starless world is not something a telescope finds, a look reports its whole + * territory rather than a sample of it, and the detection stage is genuinely cheaper than the + * characterisation stage. They do not pin the sweep order, the tick formula or the storage shape.

    + */ +public class TelescopeConeSurveyTest { + + private static final GalacticCoord HOME = GalacticCoord.ofSectorLocal(0, 0, 0, 0, 0, 0); + private static final long SEED = 0xC0FFEEL; + private static final long STEP = GalaxyGenConfig.DEFAULT_MIN_SPACING; + + private double previousMargin; + + @org.junit.Before + public void armResolveMargin() { + previousMargin = ARConfiguration.getCurrentConfig().telescopeResolveMarginMagnitudes; + // STATED, so nothing here depends on the shipped default staying put - except the one test + // that is explicitly about what the shipped default costs, which sets it again itself. + ARConfiguration.getCurrentConfig().telescopeResolveMarginMagnitudes = + ARConfiguration.DEFAULT_TELESCOPE_RESOLVE_MARGIN_MAGNITUDES; + } + + @After + public void resetSeams() { + ARConfiguration.getCurrentConfig().telescopeResolveMarginMagnitudes = previousMargin; + UniverseRegistry.setGenerator(null); + UniverseRegistry.setStarLookup(null); + } + + private static GalacticCoord cell(long x, long y, long z) { + return GalacticCoord.ofSectorLocal(x, y, z, 0L, 0L, 0L); + } + + /** A star of a stated bulk — the only two numbers its brightness is made of. */ + private static StellarBody starOf(int id, float sizeSuns, int temperatureUnits) { + StellarBody s = new StellarBody(); + s.setId(id); + s.setName("Star-" + id); + s.setSize(sizeSuns); + s.setTemperature(temperatureUnits); + return s; + } + + private static List archetypes() { + return GalaxyGenConfig.defaults().starTypes; + } + + // ── the photometry ──────────────────────────────────────────────────────── + + @Test + public void aStarsBrightnessIsMadeOfItsSizeAndItsTemperature() { + // The Stefan-Boltzmann law, and the fourth power is the whole reason the sky's brightness is + // so unlike its population: a blue star is 0.13 % of the stars and outshines a red dwarf by + // nearly four orders. Both stock archetypes, against the figures the design was sized from. + double redDwarf = StellarMagnitude.luminositySuns(0.8d, 40); + double blueGiant = StellarMagnitude.luminositySuns(2.0d, 220); + + assertEquals("a mid-band red dwarf is about a sixtieth of a Sun", 0.0164d, redDwarf, 0.001d); + assertEquals("a mid-band blue giant is about ninety Suns", 93.7d, blueGiant, 0.5d); + assertEquals("and the Sun is one Sun", 1d, + StellarMagnitude.luminositySuns(1d, StellarMagnitude.SOLAR_TEMPERATURE_UNITS), 1e-9d); + } + + @Test + public void howFarAStarCanBeSeenIsTheThingAnApertureDecides() { + // The claim a configured horizon could not make: ONE instrument reaches eighty times farther + // for a blue giant than for a red dwarf, so no single number of light years describes it. + double redDwarf = StellarMagnitude.luminositySuns(0.8d, 40); + double blueGiant = StellarMagnitude.luminositySuns(2.0d, 220); + + double dwarfReach = StellarMagnitude.detectionRangeLightYears(redDwarf, 10d); + double giantReach = StellarMagnitude.detectionRangeLightYears(blueGiant, 10d); + + assertEquals("a red dwarf at the tenth magnitude reaches ~45 ly", 45d, dwarfReach, 2d); + assertEquals("a blue giant at the same limit reaches ~3 400 ly", 3414d, giantReach, 50d); + + // Five magnitudes is a factor of a hundred in flux, hence ten in distance. That is the ladder + // a better instrument climbs, and it is derived rather than configured. + assertEquals("five magnitudes of aperture must be ten times the reach", + 10d * dwarfReach, StellarMagnitude.detectionRangeLightYears(redDwarf, 15d), 1d); + } + + @Test + public void dustCostsReachExactlyTheWayDistanceDoes() { + // Why a magnitude limit is the right bound: extinction is measured in the same unit, so dust + // and distance are two terms of ONE sum instead of two mechanics that have to be reconciled. + double sunLike = StellarMagnitude.luminositySuns(1.15d, 100); + double absolute = StellarMagnitude.absoluteMagnitude(sunLike); + + double clear = StellarMagnitude.apparentMagnitude(absolute, 200d, 0d); + double dusty = StellarMagnitude.apparentMagnitude(absolute, 200d, 2.5d); + + assertEquals("two and a half magnitudes of dust dim it by two and a half magnitudes", + clear + 2.5d, dusty, 1e-9d); + // Measured, not asserted round: a 1.15-Sun star at 200 ly stands at magnitude 8.47 in clear + // sky and 10.97 behind this cloud, so a tenth-magnitude instrument sees the one and not + // the other. The bracket is what makes the sum above a MECHANIC rather than arithmetic. + assertTrue("a star inside the aperture in clear sky must be outside it behind a cloud: " + + clear + " -> " + dusty, + clear < 10d && dusty > 10d); + } + + @Test + public void aStarDescribedOnlyByItsSizeIsReadAtTheSunsTemperature() { + // A pack may state a star's size and say nothing about its temperature, and zero raised to + // the fourth power is a star that emits nothing — so the pack would have authored an + // invisible sun and found out by pointing a telescope at empty sky. Zero means UNSTATED. + StellarBody unstated = new StellarBody(); + unstated.setSize(1f); + + assertEquals("a Sun-sized star with no stated temperature is a Sun", 1d, + StellarMagnitude.luminositySuns(unstated), 1e-9d); + + // And a thing that really is dark says so, rather than being inferred from a missing number. + StellarBody hole = new StellarBody(); + hole.setBlackHole(true); + assertEquals("a black hole emits nothing a survey in the visible could catch", 0d, + StellarMagnitude.luminositySuns(hole), 0d); + } + + // ── the shape ───────────────────────────────────────────────────────────── + + @Test + public void aPointingIsAConeAndEveryLookLiesInsideIt() { + // The shape itself: everything the survey looks at is within the half-angle of the axis, and + // within the reach. A box could not state either sentence, because it has no apex. + double halfAngle = Math.toRadians(15d); + ConeWalk cone = ConeWalk.aimed(HOME, 1, 0, 0, halfAngle, 40 * STEP, STEP); + + assertTrue("a pointing worth walking must hold more than its axis", cone.totalLooks() > 40); + for (int i = 0; i < cone.totalLooks(); i++) { + GalacticCoord look = cone.lookAt(i); + double axial = look.sectorX(); + double across = Math.hypot(look.sectorY(), look.sectorZ()); + assertTrue("a look must lie in front of the instrument, not behind it: " + look.cellKey(), + axial > 0d); + // One stride of slack: a look sits on a lattice, so the cell it rounds to can be half a + // stride outside the mathematical cone without the pointing having widened. + assertTrue("a look must lie inside the cone: " + look.cellKey() + " is " + + Math.toDegrees(Math.atan2(across, axial)) + " degrees off axis", + across <= axial * Math.tan(halfAngle) + STEP); + assertTrue("and inside the reach", axial <= 40 * STEP); + } + } + + @Test + public void aWiderPatchOfSkyIsMoreSurveyAndTheGrowthIsTheSquareOfTheAngle() { + // What an operator is trading when he opens the aperture up. Stated because it is the number + // that decides whether a configuration is playable: doubling the opening quadruples the work. + long reach = 200 * STEP; + int narrow = ConeWalk.aimed(HOME, 0, 0, 1, Math.toRadians(5d), reach, STEP).totalLooks(); + int wide = ConeWalk.aimed(HOME, 0, 0, 1, Math.toRadians(10d), reach, STEP).totalLooks(); + + System.out.println("a 200-territory pointing holds " + narrow + " looks at 5 degrees and " + + wide + " at 10"); + assertTrue("twice the opening must be about four times the survey: " + narrow + " -> " + wide, + wide > narrow * 3 && wide < narrow * 5); + } + + @Test + public void aPointingIsWalkedOutwardsSoAnAbortedSurveyIsAShorterCone() { + // Not cosmetic: a survey may be stopped at any point, and what a half-finished one has + // covered must be the NEAR sky rather than a scatter through the far. + ConeWalk cone = ConeWalk.aimed(HOME, 0, 1, 0, Math.toRadians(20d), 30 * STEP, STEP); + + long deepestSoFar = 0; + for (int i = 0; i < cone.totalLooks(); i++) { + long depth = cone.lookAt(i).sectorY(); + assertTrue("the walk must never step back towards the instrument: " + depth + + " after " + deepestSoFar, depth >= deepestSoFar); + deepestSoFar = depth; + } + assertTrue("the walk must reach the pointing's own depth", deepestSoFar >= 29 * STEP); + } + + @Test + public void aPointingSurvivesTheChunkItStartedIn() { + // The save contract: a pointing is an apex, a direction and an opening, and all three come + // back — a survey that reloaded aimed somewhere else would quietly resume over other sky. + ConeWalk cone = ConeWalk.aimed(cell(11, -3, 7), 2, -5, 1, Math.toRadians(3d), 60 * STEP, STEP); + net.minecraft.nbt.NBTTagCompound nbt = new net.minecraft.nbt.NBTTagCompound(); + cone.writeToNBT(nbt); + + ConeWalk back = ConeWalk.readFromNBT(nbt); + assertNotNull("a saved pointing must come back", back); + assertEquals("aimed from the same place", cone.apex().cellKey(), back.apex().cellKey()); + assertEquals("at the same opening", cone.halfAngleRadians(), back.halfAngleRadians(), 1e-12d); + assertEquals("over the same sky", cone.totalLooks(), back.totalLooks()); + assertEquals("and every look must land where it landed before", + cone.lookAt(cone.totalLooks() / 2).cellKey(), + back.lookAt(back.totalLooks() / 2).cellKey()); + } + + // ── what a look registers ───────────────────────────────────────────────── + + /** A registry holding one star of a stated bulk, seated {@code lightYears} away along +X. */ + private static UniverseRegistry oneStarAt(double lightYears, float sizeSuns, int temperature) { + UniverseRegistry.setGenerator(new EmptyGalaxyGenerator()); + UniverseRegistry.setStarLookup(id -> starOf(id, sizeSuns, temperature)); + + UniverseRegistry registry = new UniverseRegistry(); + GalacticCoord seat = cell(UniverseScale.cellsForLightYears(lightYears), 0, 0); + registry.place(seat, 7); + registry.addPoi(SystemBody.fixedAt(seat, SystemBodyKind.STAR, Constants.INVALID_PLANET, 7)); + registry.addPoi(SystemBody.fixedAt(seat, SystemBodyKind.PLANET, 701, 7)); + return registry; + } + + private static GalacticCoord seatAt(double lightYears) { + return cell(UniverseScale.cellsForLightYears(lightYears), 0, 0); + } + + @Test + public void aStarInsideTheApertureRegistersAndOneBeyondItDoesNot() { + // THE mechanic. The same star, the same direction, the same instrument — and the only thing + // that decides whether the survey knows it is there is how far away it is. + double sunLike = StellarMagnitude.luminositySuns(1.15d, 100); + double reach = StellarMagnitude.detectionRangeLightYears(sunLike, 8d); + assertTrue("arrangement: a sun-like star at the shipped aperture must reach a useful way", + reach > 100d); + + UniverseRegistry near = oneStarAt(reach * 0.5d, 1.15f, 100); + assertEquals("a star well inside the aperture must register", 1, + TelescopeScan.detect(near, seatAt(reach * 0.5d), HOME, 8d).size()); + + UniverseRegistry far = oneStarAt(reach * 2d, 1.15f, 100); + assertEquals("and the same star twice its reach away must not", 0, + TelescopeScan.detect(far, seatAt(reach * 2d), HOME, 8d).size()); + } + + @Test + public void aBetterApertureFindsWhatAWorseOneCannot() { + // The progression axis the design replaces a config horizon with: the instrument improves, + // and the sky it can reach improves with it — without a number anywhere being raised. + double sunLike = StellarMagnitude.luminositySuns(1.15d, 100); + double justOutOfReach = StellarMagnitude.detectionRangeLightYears(sunLike, 8d) * 1.5d; + UniverseRegistry registry = oneStarAt(justOutOfReach, 1.15f, 100); + GalacticCoord seat = seatAt(justOutOfReach); + + assertTrue("arrangement: the star must be out of the shipped aperture's reach", + TelescopeScan.detect(registry, seat, HOME, 8d).isEmpty()); + assertFalse("a better aperture must find it without anything else changing", + TelescopeScan.detect(registry, seat, HOME, 13d).isEmpty()); + } + + @Test + public void aDetectionCarriesHowFarAwayAndHowBrightItLooked() { + // A detection is a fact about a LOOK and not about a system: the same star is a different + // detection from somewhere else, and the second stage needs both numbers to decide how much + // of it can be made out. + UniverseRegistry registry = oneStarAt(300d, 1.15f, 100); + List hits = TelescopeScan.detect(registry, seatAt(300d), HOME, 25d); + + assertEquals("arrangement: exactly one star to describe", 1, hits.size()); + TelescopeScan.Detection hit = hits.get(0); + assertEquals("it must know how far away it is", 300d, hit.distanceLightYears(), 5d); + assertEquals("and how bright it looked, which is the two together", + StellarMagnitude.apparentMagnitude( + StellarMagnitude.absoluteMagnitude(StellarMagnitude.luminositySuns(1.15d, 100)), + hit.distanceLightYears(), 0d), + hit.apparentMagnitude(), 1e-6d); + } + + @Test + public void aStarlessWorldIsNotSomethingATelescopeFinds() { + // Physics the mechanic inherits rather than a rule someone wrote: an unbound world emits + // nothing, so no aperture registers one. Finding a rogue planet is a thing you do by GOING + // there, and that is what makes the void worth flying into rather than surveying from home. + UniverseRegistry.setGenerator(new EmptyGalaxyGenerator()); + UniverseRegistry.setStarLookup(id -> null); + UniverseRegistry registry = new UniverseRegistry(); + GalacticCoord seat = cell(UniverseScale.cellsForLightYears(20d), 0, 0); + registry.place(seat, 3); + registry.addPoi(SystemBody.fixedAt(seat, SystemBodyKind.ROGUE_PLANET, 301, 3)); + + assertTrue("a starless world must never register, at any aperture", + TelescopeScan.detect(registry, seat, HOME, 40d).isEmpty()); + + // And the discriminator: what is unreachable by LIGHT is still reachable by being there. + CrystalMemory crystal = new CrystalMemory(); + assertTrue("an instrument standing in it must still be able to name it", + TelescopeScan.resolveCell(registry, seat, crystal, 1_000L, id -> "Body-" + id) > 0); + } + + @Test + public void theOperatorChoosesWhetherADetectionIsFollowedToTheBodies() { + // The instrument's own control, and the reason it is a control rather than a config key: over + // known sky an operator wants every body named, and into sky nobody has visited he wants a + // list of places worth flying to. A deep pointing on FULL fills a crystal many times faster. + // + // Both halves are asserted against the SAME look, because the claim is that the choice is + // what differs — a fixture that only checked the cheap side would pass against an instrument + // that had quietly stopped resolving anything at all. + // Close enough that the instrument could certainly make the system out, so what the test + // measures is the OPERATOR's choice and not the aperture's reach - those are two different + // reasons for a bare row and a fixture near the gate would confuse them. + UniverseRegistry registry = oneStarAt(10d, 1.15f, 100); + GalacticCoord seat = seatAt(10d); + List hits = TelescopeScan.detect(registry, seat, HOME, 12d); + assertTrue("arrangement: the aperture must not be what limits this look", + hits.get(0).resolvable()); + assertEquals("arrangement: exactly one system to follow up", 1, hits.size()); + + CrystalMemory coordsOnly = new CrystalMemory(); + TelescopeScan.characterise(registry, hits.get(0), coordsOnly, 1_000L, id -> "Body-" + id, + false); + assertNull("recording positions only must name no body", coordsOnly.forBody(701)); + assertEquals("but it must still write the address, or the look taught the operator nothing", + 1, coordsOnly.size()); + + CrystalMemory full = new CrystalMemory(); + TelescopeScan.characterise(registry, hits.get(0), full, 1_000L, id -> "Body-" + id, true); + assertNotNull("and the full setting must name the system's bodies", full.forBody(701)); + assertTrue("which is strictly more than the address alone: " + full.size() + " vs " + + coordsOnly.size(), full.size() > coordsOnly.size()); + } + + @Test + public void seeingThatAStarIsThereAndMakingOutWhatOrbitsItAreDifferentObservations() { + // The mechanic the resolve margin buys, and the reason it is not a second aperture: ONE + // instrument, ONE star, and the only thing that differs is how far away it is. Near, the + // survey names the planet; far, it registers a point of light and writes the address. + double sunLike = StellarMagnitude.luminositySuns(1.15d, 100); + double detectAt = 12d; + double resolveAt = detectAt - ARConfiguration.DEFAULT_TELESCOPE_RESOLVE_MARGIN_MAGNITUDES; + double detectReach = StellarMagnitude.detectionRangeLightYears(sunLike, detectAt); + double resolveReach = StellarMagnitude.detectionRangeLightYears(sunLike, resolveAt); + assertTrue("arrangement: resolving must be the harder of the two, by a wide margin: " + + resolveReach + " vs " + detectReach, + resolveReach * 4d < detectReach); + + // Far: inside the aperture, outside what it can make out. + double far = (detectReach + resolveReach) / 2d; + UniverseRegistry farSky = oneStarAt(far, 1.15f, 100); + List farHits = + TelescopeScan.detect(farSky, seatAt(far), HOME, detectAt); + assertEquals("arrangement: it must still REGISTER at this distance", 1, farHits.size()); + assertFalse("but it must not be resolvable", farHits.get(0).resolvable()); + + CrystalMemory distant = new CrystalMemory(); + TelescopeScan.characterise(farSky, farHits.get(0), distant, 1_000L, id -> "Body-" + id, true); + assertNull("so a survey must not name its planet", distant.forBody(701)); + assertEquals("and must still write the address down", 1, distant.size()); + + // Near: the same star, the same instrument, the same request. + double near = resolveReach / 2d; + UniverseRegistry nearSky = oneStarAt(near, 1.15f, 100); + List nearHits = + TelescopeScan.detect(nearSky, seatAt(near), HOME, detectAt); + assertEquals("arrangement: one star to make out", 1, nearHits.size()); + assertTrue("this one must be resolvable", nearHits.get(0).resolvable()); + + CrystalMemory close = new CrystalMemory(); + TelescopeScan.characterise(nearSky, nearHits.get(0), close, 1_000L, id -> "Body-" + id, true); + assertNotNull("and its planet must be named", close.forBody(701)); + } + + @Test + public void theMarginIsTheDifferenceBetweenSeeingAndMEASURING() { + // Where 6.5 comes from, stated as arithmetic so a retune has to argue with the derivation + // rather than with a taste: detection is called at a signal-to-noise of about 5, a usable + // spectrum wants about 100, and signal-to-noise grows as the square root of the photons — + // so the flux ratio is (100/5)^2 = 400, which is 2.5*log10(400) magnitudes. + double fluxRatio = (100d / 5d) * (100d / 5d); + assertEquals("the margin must be the SNR ratio and not a number someone liked", + 2.5d * Math.log10(fluxRatio), + ARConfiguration.DEFAULT_TELESCOPE_RESOLVE_MARGIN_MAGNITUDES, 0.01d); + + // And zero must genuinely turn it off, which is what "disable the flag" has to mean. + ARConfiguration.getCurrentConfig().telescopeResolveMarginMagnitudes = 0d; + assertEquals("a margin of zero makes anything detectable also resolvable", + TelescopeScan.limitMagnitude(), TelescopeScan.resolveLimitMagnitude(), 1e-9d); + } + + // ── detection is not characterisation ───────────────────────────────────── + + /** The real generator, counting the two questions separately. */ + private static final class SplitCountingGenerator + implements zmaster587.advancedRocketry.universe.IGalaxyGenerator { + + private final ClusteredGalaxyGenerator real; + int territoryQueries; + int bodyQueries; + + SplitCountingGenerator(GalaxyGenConfig config) { + this.real = new ClusteredGalaxyGenerator(config); + } + + @Override + public Optional systemAt( + long seed, GalacticCoord coord) { + return real.systemAt(seed, coord); + } + + @Override + public java.util.Map + systemsInRegion(long seed, GalacticCoord min, GalacticCoord max) { + return real.systemsInRegion(seed, min, max); + } + + @Override + public Optional anchorAt(long seed, GalacticCoord cell) { + return real.anchorAt(seed, cell); + } + + @Override + public List anchorsInTerritory(long seed, GalacticCoord cell, int limit) { + territoryQueries++; + return real.anchorsInTerritory(seed, cell, limit); + } + + @Override + public List bodiesFor(long seed, GalacticCoord systemCoord) { + bodyQueries++; + return real.bodiesFor(seed, systemCoord); + } + + @Override + public int minSpacingCells() { + return real.minSpacingCells(); + } + + @Override + public Optional tuning() { + return real.tuning(); + } + } + + @Test + public void detectionAsksTheCheapQuestionWithoutPayingForTheExpensiveOne() { + // The split the whole redesign turns on. These were one call, so the cheap question ("is + // anything there") could never be asked without building every body of every system it + // found. A survey spends its looks on the first stage, so the first stage must not touch + // the second — and the only way to state that is to count. + GalaxyGenConfig config = GalaxyGenConfig.defaults(); + SplitCountingGenerator counting = new SplitCountingGenerator(config); + UniverseRegistry.setGenerator(counting); + UniverseRegistry.setStarLookup(id -> starOf(id, 1f, 100)); + UniverseRegistry registry = new UniverseRegistry(); + registry.bindWorldSeed(SEED); + + int found = 0; + for (int i = 1; i <= 40; i++) { + found += TelescopeScan.detect(registry, cell((long) i * STEP, 0, 0), HOME, 12d).size(); + } + + System.out.println("40 detection looks found " + found + " systems, asking " + + counting.territoryQueries + " territory questions and " + counting.bodyQueries + + " body questions"); + assertTrue("arrangement: the sweep must have found something to describe", found > 0); + assertEquals("detection must never derive a single body", 0, counting.bodyQueries); + } + + @Test + public void oneLookOwesItsWholeTerritoryAndNotOneSeatOfIt() { + // The property that lets a survey stride by the territory while the field is divided more + // finely than that. Without it a sweep reports one seat in k-cubed and calls it the sky: at + // the shipped division that is 1.3 % of what is out there, reported as all of it. + GalaxyGenConfig config = GalaxyGenConfig.defaults(); + ClusteredGalaxyGenerator gen = new ClusteredGalaxyGenerator(config); + + int byTerritory = 0; + int byPoint = 0; + for (long i = -6; i <= 6; i++) { + for (long j = -6; j <= 6; j++) { + GalacticCoord corner = cell(i * STEP, j * STEP, 0); + byTerritory += gen.anchorsInTerritory(SEED, corner, 64).size(); + byPoint += gen.anchorAt(SEED, corner).isPresent() ? 1 : 0; + } + } + + System.out.println("169 territories hold " + byTerritory + " systems; resolving their corner " + + "points alone would have reported " + byPoint); + assertTrue("arrangement: the field must hold something", byTerritory > 0); + assertTrue("asking the territory must find strictly more than sampling one point of it: " + + byTerritory + " vs " + byPoint, + byTerritory > byPoint); + } + + // ── what the shipped instrument costs ───────────────────────────────────── + + @Test + public void theShippedApertureIsAffordableAndItsFindingsFitOnACrystal() { + // THE acceptance measurement, and the numbers are stated in the units of the goal rather + // than in whatever the work happened to produce: a full-depth pointing at the shipped + // aperture must hold under 200 000 looks, register a number of systems a crystal can carry, + // and cost well under a second of CPU spread over its steps. + GalaxyGenConfig config = GalaxyGenConfig.defaults(); + UniverseRegistry.setGenerator(new ClusteredGalaxyGenerator(config)); + UniverseRegistry.setStarLookup(id -> starOf(id, 1f, 100)); + UniverseRegistry registry = new UniverseRegistry(); + registry.bindWorldSeed(SEED); + + RegionScan.Tuning shipped = new RegionScan.Tuning( + ARConfiguration.DEFAULT_TELESCOPE_LIMITING_MAGNITUDE, archetypes(), + Math.toRadians(ARConfiguration.DEFAULT_TELESCOPE_CONE_HALF_ANGLE_DEGREES), + ARConfiguration.DEFAULT_TELESCOPE_SCAN_MAX_CELLS, + ARConfiguration.DEFAULT_TELESCOPE_SCAN_BASE_TICKS, + ARConfiguration.DEFAULT_TELESCOPE_SCAN_CELLS_PER_STEP, + config.minSpacing); + + RegionScan scan = RegionScan.directed(HOME, 1, 0, 0, shipped.maxRangeSteps(), 0L, shipped); + int looks = scan.totalCells(); + + long startedAt = System.nanoTime(); + int detections = 0; + for (int i = 0; i < looks; i++) { + detections += TelescopeScan.detect(registry, scan.cellAt(i), HOME, + shipped.limitMagnitude()).size(); + } + long elapsedMs = (System.nanoTime() - startedAt) / 1_000_000L; + int resolvable = 0; + for (int i = 0; i < looks; i++) { + for (TelescopeScan.Detection hit : TelescopeScan.detect(registry, scan.cellAt(i), HOME, + shipped.limitMagnitude())) { + if (hit.resolvable()) { + resolvable++; + } + } + } + long steps = (looks + shipped.cellsPerStep() - 1) / shipped.cellsPerStep(); + + System.out.println("the shipped instrument reaches " + + String.format("%.0f", shipped.maxRangeLightYears()) + " ly (" + + shipped.maxRangeSteps() + " territories); a full pointing is " + looks + + " looks in " + steps + " steps (" + (steps * shipped.baseTicks() / 20L) + + " s of clear night), registered " + detections + " systems, walked in " + + elapsedMs + " ms, of which " + resolvable + " were close enough to make out"); + + assertTrue("a full pointing must stay under the walk ceiling: " + looks, + looks <= ARConfiguration.DEFAULT_TELESCOPE_SCAN_MAX_CELLS); + assertTrue("and must be a real survey rather than a token one: " + looks, looks > 1_000); + assertTrue("what it registers must fit on a crystal: " + detections + " systems", + detections <= 1_500); + assertTrue("arrangement: a pointing that finds nothing would pass every bound above", + detections > 0); + assertTrue("and the walk must cost well under a second of CPU: " + elapsedMs + " ms", + elapsedMs < 2_000L); + } + + @Test + public void anApertureTooGoodForTheWalkBudgetShortensTheReachRatherThanRefusingToLook() { + // UNREASONABLE IS NOT IMPOSSIBLE. An operator who configures an aperture that would hold more + // looks than the ceiling affords gets a shallower pointing, not an instrument that will not + // point — he sees the near sky and can point again. + RegionScan.Tuning greedy = new RegionScan.Tuning(25d, archetypes(), Math.toRadians(5d), + 5_000, 20, 100, STEP); + assertTrue("arrangement: this aperture must reach absurdly far", + greedy.maxRangeLightYears() > 100_000d); + + RegionScan scan = RegionScan.directed(HOME, 1, 0, 0, greedy.maxRangeSteps(), 0L, greedy); + + assertTrue("the survey must fit under the ceiling: " + scan.totalCells(), + scan.totalCells() <= 5_000); + assertTrue("and must still be a survey rather than a single look", scan.totalCells() > 1); + assertTrue("its reach must have been SHORTENED, which is what a budget can do to a horizon", + scan.distanceCells() < greedy.maxRangeSteps() * STEP); + } + + @Test + public void aGeneratorWithNoStarsGivesAnInstrumentNothingToReach() { + // The honest zero. An empty universe has nothing to see, so a survey of it is instantly + // complete rather than long and fruitless — and the reach says so rather than inventing one. + RegionScan.Tuning empty = new RegionScan.Tuning(20d, new ArrayList<>(), Math.toRadians(1d), + 1_000, 20, 10, STEP); + assertEquals("an aperture pointed at a sky with no star types reaches nothing", 0d, + empty.maxRangeLightYears(), 0d); + assertEquals("which is still a pointing, of one territory", 1, empty.maxRangeSteps()); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/TelescopeRegionScanTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/TelescopeRegionScanTest.java index 9237dda6a..8cb3f0b28 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/unit/TelescopeRegionScanTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/TelescopeRegionScanTest.java @@ -9,16 +9,20 @@ import zmaster587.advancedRocketry.navigation.CrystalEntry; import zmaster587.advancedRocketry.navigation.CrystalMemory; import zmaster587.advancedRocketry.space.GalacticCoord; +import zmaster587.advancedRocketry.universe.ClusteredGalaxyGenerator; import zmaster587.advancedRocketry.universe.EmptyGalaxyGenerator; +import zmaster587.advancedRocketry.universe.GalaxyGenConfig; import zmaster587.advancedRocketry.universe.InfoTier; import zmaster587.advancedRocketry.universe.RegionScan; import zmaster587.advancedRocketry.universe.SystemBody; +import zmaster587.advancedRocketry.universe.StellarMagnitude; import zmaster587.advancedRocketry.universe.SystemBodyKind; import zmaster587.advancedRocketry.universe.TelescopeScan; import zmaster587.advancedRocketry.universe.UniverseRegistry; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @@ -30,19 +34,59 @@ * writes onto a crystal. Pure-JUnit — no MC bootstrap; the registry's generator and star lookup are * the injectable seams. * - *

    These pin player-facing promises — a far survey costs more than a near one, the horizon is the - * configured reach, one step never enumerates the sky, an unknown system is discoverable, and what a - * telescope writes is a BODY at the coarsest grade, dated — plus the save contract that a sweep - * outlives the chunk it started in and resumes where it stood. They do not pin the time formula, the - * sweep order or the storage shape.

    + *

    These pin player-facing promises — a far survey costs more than a near one, the horizon is a + * LENGTH a telescope could have, a look finds the system that OWNS the cell rather than only a star + * seated on it, empty sky stays empty, one step never enumerates the sky, an unknown system is + * discoverable, and what a telescope writes is a BODY at the coarsest grade, dated — plus the save + * contract that a sweep outlives the chunk it started in and resumes where it stood. They do not pin + * the time formula, the sweep order or the storage shape.

    */ public class TelescopeRegionScanTest { private static final GalacticCoord HOME = GalacticCoord.ofSectorLocal(0, 0, 0, 0, 0, 0); - /** Reach 10 sectors, a 3×3×3 region, room for it, 100 ticks a step plus 50 per sector, 2 cells a step. */ + /** + * One survey STEP: the edge of the cube that holds at most one system, which is what the sweep + * strides by and what an operator's aim is counted in. Taken from the generator rather than + * invented — the registry attributes a member cell to its system by the same number. + */ + private static final long STEP = GalaxyGenConfig.DEFAULT_MIN_SPACING; + + /** The star types the sky can produce — what an aperture's reach is derived against. */ + private static java.util.List archetypes() { + return GalaxyGenConfig.defaults().starTypes; + } + + /** + * The aperture that reaches exactly {@code lightYears} — the magnitude law run BACKWARDS against + * the brightest star the stock sky holds. + * + *

    Written this way so a fixture can still say "a survey that reaches fifty light years" while + * the instrument is configured by what it can SEE. Inverting the production formula rather than + * hard-coding a magnitude also means these fixtures follow the star table: retune the sky's + * brightest archetype and the fixture still reaches fifty light years.

    + */ + private static double apertureReaching(double lightYears) { + double brightest = 0d; + for (GalaxyGenConfig.StarType type : archetypes()) { + brightest = Math.max(brightest, + StellarMagnitude.luminositySuns(type.maxSize, type.temperature)); + } + double parsecs = lightYears / StellarMagnitude.LIGHT_YEARS_PER_PARSEC; + return StellarMagnitude.absoluteMagnitude(brightest) + 5d * Math.log10(parsecs / 10d); + } + + /** + * A pointing that reaches 50 light years, one degree wide, 100 ticks a step, two looks a step. + * + *

    One degree is narrower than the lattice is coarse for the first fifty-odd territories, so + * every look of a shallow pointing lands exactly on the axis. That is the geometry and not a + * simplification — a cone IS a line until it is wider than the spacing of what it walks — and it + * makes a fixture's looks predictable without pinning the sweep order.

    + */ private static RegionScan.Tuning tuning() { - return new RegionScan.Tuning(10, 1, 512, 100, 50, 2); + return new RegionScan.Tuning(apertureReaching(50d), archetypes(), Math.toRadians(1d), + 512, 100, 2, STEP); } private static StellarBody star(int id) { @@ -56,6 +100,11 @@ private static GalacticCoord cell(long x, long y, long z) { return GalacticCoord.ofSectorLocal(x, y, z, 0L, 0L, 0L); } + /** The cell {@code steps} territories out along +X — where an aim of {@code steps} lands. */ + private static GalacticCoord stepsOut(long steps) { + return cell(steps * STEP, 0, 0); + } + @After public void resetSeams() { UniverseRegistry.setGenerator(null); @@ -75,15 +124,38 @@ public void aFartherRegionIsALongerSurvey() { far.estimatedTicks() > near.estimatedTicks()); } + @Test + public void theReachIsALengthAndTheAimIsCountedInStars() { + // The defect this replaced: a reach stated in cells read as 0.16 AU — a fifth of the way to + // Mercury — and no aim inside it could ever leave the solar system. A horizon is a LENGTH, + // and what it buys is a number of star territories, so both must be recognisable. + RegionScan.Tuning tuning = tuning(); + + assertTrue("a telescope's horizon must be quoted in light years: " + tuning.maxRangeLightYears(), + tuning.maxRangeLightYears() >= 1d); + assertTrue("and must reach at least the nearest few stars, or nothing is discoverable: " + + tuning.maxRangeSteps() + " steps", + tuning.maxRangeSteps() >= 3); + + RegionScan aimed = RegionScan.directed(HOME, 1, 0, 0, 3, 0L, tuning); + assertEquals("an aim of three stars must land three territories out, not three cells", + stepsOut(3).cellKey(), + cell(aimed.distanceCells(), 0, 0).cellKey()); + assertTrue("and that distance, read as a length, must be interstellar: " + + aimed.distanceLightYears() + " ly", + aimed.distanceLightYears() >= 3d); + } + @Test public void theHorizonIsTheConfiguredReach() { // "You cannot see beyond your own cluster": an aim past the reach is answered at the reach, // and costs exactly what looking at the reach costs — not more. - RegionScan reached = RegionScan.directed(HOME, 0, 0, 1, 10, 0L, tuning()); + int horizon = tuning().maxRangeSteps(); + RegionScan reached = RegionScan.directed(HOME, 0, 0, 1, horizon, 0L, tuning()); RegionScan overreached = RegionScan.directed(HOME, 0, 0, 1, 9999, 0L, tuning()); assertEquals("an aim past the horizon must be answered at the horizon", - reached.distanceSectors(), overreached.distanceSectors()); + reached.distanceCells(), overreached.distanceCells()); assertEquals("and must cost what the horizon costs", reached.estimatedTicks(), overreached.estimatedTicks()); assertEquals("the region itself must be the one at the horizon", @@ -94,8 +166,9 @@ public void theHorizonIsTheConfiguredReach() { public void oneStepNeverResolvesMoreThanItsCellBudget() { // The structural guard against reading an endless procedural universe off one instrument: // a survey may cover a large region, but never in one step. - RegionScan.Tuning wide = new RegionScan.Tuning(10, 2, 1000, 100, 50, 3); - RegionScan scan = RegionScan.directed(HOME, 1, 1, 0, 3, 0L, wide); + RegionScan.Tuning wide = new RegionScan.Tuning(apertureReaching(200d), archetypes(), + Math.toRadians(20d), 1000, 100, 3, STEP); + RegionScan scan = RegionScan.directed(HOME, 1, 1, 0, wide.maxRangeSteps(), 0L, wide); assertTrue("the fixture must be a region worth sweeping", scan.totalCells() > 3); assertEquals("a step may never resolve more cells than its budget", @@ -105,8 +178,9 @@ public void oneStepNeverResolvesMoreThanItsCellBudget() { @Test public void aRegionNeverExceedsItsCeiling() { // Ask for a 9×9×9 region with room for 27 cells and the ceiling wins. - RegionScan.Tuning greedy = new RegionScan.Tuning(10, 4, 27, 100, 50, 2); - RegionScan scan = RegionScan.directed(HOME, 1, 0, 0, 3, 0L, greedy); + RegionScan.Tuning greedy = new RegionScan.Tuning(apertureReaching(200d), archetypes(), + Math.toRadians(20d), 27, 100, 2, STEP); + RegionScan scan = RegionScan.directed(HOME, 1, 0, 0, greedy.maxRangeSteps(), 0L, greedy); assertTrue("a survey may never cover more than its ceiling: " + scan.totalCells(), scan.totalCells() <= 27); @@ -115,7 +189,7 @@ public void aRegionNeverExceedsItsCeiling() { @Test public void aSweepWorksThroughItsRegionAndFinishes() { // The automation the instrument exists for: one aim, then it works through the patch. - RegionScan scan = RegionScan.directed(HOME, 1, 0, 0, 2, 0L, tuning()); + RegionScan scan = RegionScan.directed(HOME, 1, 0, 0, 8, 0L, tuning()); int total = scan.totalCells(); assertTrue("the fixture must need more than one step", total > scan.cellsPerStep()); @@ -143,7 +217,7 @@ public void nothingIsResolvedBeforeTheStepIsDue() { @Test public void everyCellOfTheRegionIsVisitedExactlyOnce() { - RegionScan scan = RegionScan.directed(HOME, 1, 0, 0, 2, 0L, tuning()); + RegionScan scan = RegionScan.directed(HOME, 1, 0, 0, 8, 0L, tuning()); java.util.Set seen = new java.util.HashSet<>(); for (int i = 0; i < scan.totalCells(); i++) { assertTrue("the sweep order must not repeat a cell: " + scan.cellAt(i).cellKey(), @@ -152,6 +226,68 @@ public void everyCellOfTheRegionIsVisitedExactlyOnce() { assertEquals("and must cover the whole region", scan.totalCells(), seen.size()); } + @Test + public void aSweepStridesByOneStarsTerritoryRatherThanByCells() { + // What makes a sweep worth its time: every look is a different candidate system. Walking + // cell by cell would spend a whole survey inside one system's own neighbourhood, since + // every cell of that neighbourhood answers with the same system. + RegionScan scan = RegionScan.directed(HOME, 1, 0, 0, 4, 0L, tuning()); + + assertEquals("a directed survey strides by one star's territory", STEP, scan.strideCells()); + assertTrue("the fixture must span more than one look along X", scan.totalCells() > 1); + + long first = scan.cellAt(0).sectorX(); + long second = scan.cellAt(1).sectorX(); + assertEquals("two consecutive looks must be a whole territory apart, not a cell", + STEP, Math.abs(second - first)); + } + + @Test + public void theLocalRadarWatchesTheNeighbouringTerritories() { + // The passive half. It used to walk CELL BY CELL, on the ground that near home the cells are + // the granularity — which bought nothing, because one look already yields every body of the + // system that owns it, and no radius a cell-strided box could afford ever reached a + // NEIGHBOUR. Two cells was a fifth of the way to the innermost planet of the system the + // instrument was already standing in. A neighbourhood is measured in neighbours. + RegionScan radar = RegionScan.local(HOME, 1, 0L, tuning()); + + assertEquals("the local radar walks by star territories", STEP, radar.strideCells()); + assertEquals("a radius of one territory is the 27 around and including home", + 27, radar.totalCells()); + + boolean looksAtHome = false; + boolean reachesANeighbour = false; + for (int i = 0; i < radar.totalCells(); i++) { + GalacticCoord look = radar.cellAt(i); + looksAtHome |= look.cellKey().equals(HOME.cellKey()); + reachesANeighbour |= Math.abs(look.sectorX()) >= STEP; + } + assertTrue("it must look at the cell the instrument is standing in", looksAtHome); + assertTrue("and it must actually reach a neighbouring territory, which is the whole point", + reachesANeighbour); + } + + @Test + public void aRegionWithMoreLooksThanCanBeWalkedIsREFUSEDratherThanClamped() { + // A survey is walked by an int cursor, and its look count used to be CLAMPED to fit one. A + // clamped count does not make the sweep long — it makes it report itself complete at 2·10⁹ + // looks with the rest of the region never visited, and progress read 100 % while the sky was + // untouched. The local radar is the reachable route: its radius is a config number and the + // box cubes it, so ~1 300 of radius is already past an int. + try { + RegionScan.local(HOME, 2_000, 0L, tuning()); + fail("a region of (2*2000+1)^3 looks cannot be walked and must be refused, not clamped"); + } catch (IllegalArgumentException expected) { + assertTrue("the refusal must name what it could not do: " + expected.getMessage(), + expected.getMessage().contains("cannot be walked")); + } + + // And the boundary is not a cliff into silence: one that DOES fit is accepted and counted. + RegionScan fits = RegionScan.local(HOME, 100, 0L, tuning()); + assertEquals("a region that fits must be counted exactly, never rounded", + 201 * 201 * 201, fits.totalCells()); + } + @Test public void aSurveyWithNoDirectionIsRefused() { try { @@ -189,26 +325,40 @@ public void nothingIsStoredForAnObservatoryThatIsNotLooking() { // ── what a survey discovers ─────────────────────────────────────────────── - /** A registry holding two systems with bodies, plus one well outside the surveyed region. */ + /** + * A registry holding two systems inside the surveyed patch and one well outside it. + * + *

    Not one of the three stars is seated on a cell the sweep looks at, and that is the + * fixture's whole point. A star is one cell of a territory millions of cells wide, so a survey + * that could only see a system by landing on its star's own address would find nothing here — + * which is exactly what the instrument used to do. Each seat is offset from the look that must + * find it, by a distance that is inside its own neighbourhood and nowhere near the next.

    + */ private UniverseRegistry threeSystems() { UniverseRegistry.setGenerator(new EmptyGalaxyGenerator()); UniverseRegistry.setStarLookup(TelescopeRegionScanTest::star); 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.place(cell(5, 1, 0), 5); - registry.addPoi(new SystemBody(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)); + GalacticCoord inner = cell(4 * STEP - 20, 0, 0); // found by the look at 4 steps out + registry.place(inner, 4); + registry.addPoi(SystemBody.fixedAt(inner, SystemBodyKind.STAR, Constants.INVALID_PLANET, 4)); + registry.addPoi(SystemBody.fixedAt(inner, SystemBodyKind.PLANET, 401, 4)); + + // Two territories out, not three: the registry indexes ONE stored anchor per super-cell, so + // a second seat in the same territory as `inner` would silently displace it and this fixture + // would be testing a system it had already dropped. + GalacticCoord edge = cell(2 * STEP + 7, 0, 0); // found by the look two shells in + registry.place(edge, 5); + registry.addPoi(SystemBody.fixedAt(edge, SystemBodyKind.PLANET, 501, 5)); + + GalacticCoord beyond = cell(9 * STEP, 0, 0); // far outside the patch + registry.place(beyond, 9); + registry.addPoi(SystemBody.fixedAt(beyond, SystemBodyKind.PLANET, 901, 9)); return registry; } - /** The survey the fixture is built around: 4 sectors out along +X, one sector wide. */ - private RegionScan boxAroundFourthSector() { + /** The survey the fixture is built around: a pointing four territories deep along +X. */ + private RegionScan pointingFourTerritoriesOut() { return RegionScan.directed(HOME, 1, 0, 0, 4, 0L, tuning()); } @@ -223,7 +373,7 @@ public void whatIsInTheRegionIsLearnedAndWhatIsOutsideItIsNot() { UniverseRegistry registry = threeSystems(); CrystalMemory crystal = new CrystalMemory(); - surveyAll(registry, boxAroundFourthSector(), crystal, 7_000L); + surveyAll(registry, pointingFourTerritoriesOut(), crystal, 7_000L); assertNotNull("the body the instrument was pointed at must be learned", crystal.forBody(401)); assertNotNull("so must the one at the edge of the same region", crystal.forBody(501)); @@ -237,7 +387,7 @@ public void aSurveyWritesTheBODIESItResolved() { UniverseRegistry registry = threeSystems(); CrystalMemory crystal = new CrystalMemory(); - surveyAll(registry, boxAroundFourthSector(), crystal, 7_000L); + surveyAll(registry, pointingFourTerritoriesOut(), crystal, 7_000L); CrystalEntry planet = crystal.forBody(401); assertNotNull("the region's planet must have its own address", planet); @@ -245,6 +395,280 @@ public void aSurveyWritesTheBODIESItResolved() { assertEquals("named the way every other screen names it", "Body-401", planet.name()); } + @Test + public void aSystemIsFoundFromAnyCellItOWNS_notOnlyFromItsStarsSeat() { + // THE defect. A system is a neighbourhood: its star holds one cell of it and its planets hold + // others. Asking "is a star seated exactly here" makes discovery a lottery whose odds are one + // cell in a territory millions wide — so a survey found nothing and reported an empty sky. + // Asking "which system owns this cell" is the same question a telescope asks of the light. + UniverseRegistry registry = threeSystems(); + CrystalMemory crystal = new CrystalMemory(); + + GalacticCoord look = stepsOut(4); + assertFalse("the fixture is worthless unless the look is NOT the star's own seat", + registry.starIdForCoord(look).isPresent()); + + TelescopeScan.resolveCell(registry, look, crystal, 7_000L, dimId -> "Body-" + dimId); + + assertNotNull("a survey must discover the system that OWNS the cell it looked at", + crystal.forBody(401)); + } + + /** + * The real generator, counting every question the survey asks it. + * + *

    A wrapper rather than a mock, because the claim under test is about the REAL galaxy: the one + * that now holds of the order of 10¹¹ systems. A test against an empty generator would pass by + * having nothing to enumerate.

    + */ + private static final class CountingGenerator implements zmaster587.advancedRocketry.universe.IGalaxyGenerator { + + private final ClusteredGalaxyGenerator real; + int queries; + + CountingGenerator(GalaxyGenConfig config) { + this.real = new ClusteredGalaxyGenerator(config); + } + + @Override + public java.util.Optional systemAt( + long seed, GalacticCoord coord) { + queries++; + return real.systemAt(seed, coord); + } + + @Override + public java.util.Map + systemsInRegion(long seed, GalacticCoord min, GalacticCoord max) { + queries++; + return real.systemsInRegion(seed, min, max); + } + + @Override + public java.util.Optional anchorAt(long seed, GalacticCoord cell) { + queries++; + return real.anchorAt(seed, cell); + } + + @Override + public java.util.List anchorsInTerritory(long seed, GalacticCoord cell, + int limit) { + // Delegated rather than inherited ON PURPOSE. The default would answer with the single + // anchor at the point, so a wrapper that merely counted would have measured a survey + // that never enumerated a territory - the cheap answer to a question nobody asked. + queries++; + return real.anchorsInTerritory(seed, cell, limit); + } + + @Override + public java.util.List bodiesFor(long seed, GalacticCoord systemCoord) { + queries++; + return real.bodiesFor(seed, systemCoord); + } + + @Override + public int minSpacingCells() { + return real.minSpacingCells(); + } + } + + @Test + public void aSurveyResolvesPerLookAndNeverWalksTheGalaxy() { + // The claim the scale change rests on: a galaxy holding 10^11 systems is affordable ONLY + // because nothing ever enumerates one. A survey asks a bounded number of questions — a + // constant per look — and that number is a property of the INSTRUMENT, not of how much sky + // there is. A full-galaxy walk introduced anywhere on this path would blow the bound by nine + // orders, and this test would not merely fail: it would never return. + GalaxyGenConfig config = GalaxyGenConfig.defaults(); + CountingGenerator counting = new CountingGenerator(config); + UniverseRegistry.setGenerator(counting); + UniverseRegistry.setStarLookup(TelescopeRegionScanTest::star); + + UniverseRegistry registry = new UniverseRegistry(); + registry.bindWorldSeed(0xC0FFEEL); + + // Aimed at the real reach, through the real config's own stride. + RegionScan.Tuning live = new RegionScan.Tuning(apertureReaching(100d), archetypes(), + Math.toRadians(1d), 512, 100, 4, config.minSpacing); + RegionScan scan = RegionScan.directed(HOME, 1, 0, 0, live.maxRangeSteps(), 0L, live); + int looks = scan.totalCells(); + assertTrue("the fixture must be a real sweep", looks >= 27); + + CrystalMemory crystal = new CrystalMemory(); + TelescopeScan.resolveBatch(registry, scan, 0, looks, crystal, 7_000L, dimId -> "Body-" + dimId); + + // A handful of questions per look: what this territory holds, and what each of those + // systems is. The budget is per LOOK and not per system, so it has to allow for a territory + // that is divided - the bound that matters is that it does not grow with the GALAXY. + int budget = looks * 8 * TelescopeScan.MAX_SEATS_PER_LOOK; + System.out.println("survey of " + looks + " looks asked the generator " + counting.queries + + " questions (budget " + budget + ")"); + assertTrue("a survey asked the generator " + counting.queries + " questions for " + looks + + " looks — something on this path is enumerating rather than resolving", + counting.queries <= budget); + } + + // ── a look is a touch ───────────────────────────────────────────────────── + + /** How a system reads to a test: what it is, and where each of its bodies stands. */ + private static String describe(UniverseRegistry registry, GalacticCoord anchor) { + StringBuilder sb = new StringBuilder(); + // Asked through systemForCoord, which answers pinned OR derived. starIdForCoord reads the + // override store alone, so it would report the PIN rather than the system and turn "this system + // did not move" into "this system is now in the store", which is a different claim. + sb.append(registry.systemForCoord(anchor) + .map(s -> s.systemId() + "/" + s.primaryKind() + "/" + s.name()) + .orElse("none")); + java.util.List bodies = new java.util.ArrayList<>(); + for (SystemBody b : registry.systemBodiesAt(anchor)) { + bodies.add(b.name().cellKey() + ':' + b.kind() + ':' + b.radiusEarths()); + } + java.util.Collections.sort(bodies); + return sb.append(bodies).toString(); + } + + /** The same universe with one knob moved — everything untouched is derived differently under it. */ + private static GalaxyGenConfig retuned() { + return new GalaxyGenConfig(GalaxyGenConfig.DEFAULT_MIN_SPACING, 0.9d, + GalaxyGenConfig.DEFAULT_GALAXY_SPACING, GalaxyGenConfig.DEFAULT_GALAXY_DENSITY, + null, null); + } + + @Test + public void aSystemAScanReportedIsFrozenAgainstALaterRetune() { + // The promise the whole schema-versioning rests on: what the player has SEEN stops moving. + // A survey answers out of the derivation, so without a pin the system on his crystal is a + // function of the world's parameters — and he finds that out by flying there. + // + // The "different universe" is a different SEED rather than a retuned config, and that choice is + // the point. Two earlier forms of this test used a config retune and both went vacuous without + // saying so: the first picked the ORIGIN, whose territory carries lattice index (0,0,0) whatever + // the edge is, and the second picked an anchor the retune happened not to move. A seed change + // re-derives everything by construction, so the discriminator below cannot quietly stop + // discriminating. + GalaxyGenConfig config = GalaxyGenConfig.defaults(); + UniverseRegistry.setGenerator(new ClusteredGalaxyGenerator(config)); + UniverseRegistry.setStarLookup(TelescopeRegionScanTest::star); + UniverseRegistry registry = new UniverseRegistry(); + registry.bindWorldSeed(0xC0FFEEL); + + // SEARCHED rather than hardcoded. A territory is divided uniformly, so whether any given + // coordinate holds a system is a draw at one seat in k-cubed — a fixed cell was a fixture + // that happened to be occupied under one partition and is empty under the next. + GalacticCoord looked = null; + GalacticCoord anchor = null; + for (long i = 1; i <= 12 && anchor == null; i++) { + GalacticCoord probe = cell(i * STEP, 3 * STEP, -5 * STEP); + for (GalacticCoord found : registry.anchorsInTerritory(probe, 64)) { + looked = found; + anchor = found; + break; + } + } + assertNotNull("arrangement: the sweep must find a system to look at", anchor); + String before = describe(registry, anchor); + + CrystalMemory crystal = new CrystalMemory(); + assertTrue("arrangement: the look must report something", + TelescopeScan.resolveCell(registry, looked, crystal, 7_000L, dimId -> "Body-" + dimId) > 0); + + // A different universe under the same registry. + registry.bindWorldSeed(0xDEADBEEFL); + + assertEquals("a system a telescope reported must survive a change to the universe it was " + + "derived from", before, describe(registry, anchor)); + + // The discriminator: the new universe must genuinely describe something else at that anchor, + // or the assertion above would hold with no pin at all. + String derivedNow = new ClusteredGalaxyGenerator(config).systemAt(0xDEADBEEFL, anchor) + .map(sys -> sys.systemId() + "/" + sys.primaryKind() + "/" + sys.name()) + .orElse("none"); + assertNotEquals("arrangement: the new seed must derive something else at this anchor, or the " + + "freeze is untested", before.substring(0, before.indexOf('[')), derivedNow); + } + + @Test + public void aLookIntoTheVoidFreezesNothing() { + // The pin must follow the REPORT, not the look: freezing empty sky would fill the save with + // snapshots of nothing and take space out of the pack author's hands for no promise made. + UniverseRegistry registry = threeSystems(); + CrystalMemory crystal = new CrystalMemory(); + + TelescopeScan.resolveCell(registry, cell(400 * STEP, 0, 0), crystal, 7_000L, + dimId -> "Body-" + dimId); + + assertEquals("a look at nothing must write no snapshot into the save", 0, pinnedCount(registry)); + } + + @Test + public void whatASurveyFreezesIsMeasuredNotAssumed() { + // A pin snapshots a whole system, so a wide sweep is a write. The cost is stated here as a + // NUMBER rather than asserted to be small: the bound below is a tripwire against an order of + // magnitude, and the printed figures are what a decision about survey width is made from. + GalaxyGenConfig config = GalaxyGenConfig.defaults(); + UniverseRegistry.setGenerator(new ClusteredGalaxyGenerator(config)); + UniverseRegistry.setStarLookup(TelescopeRegionScanTest::star); + UniverseRegistry registry = new UniverseRegistry(); + registry.bindWorldSeed(0xC0FFEEL); + + RegionScan.Tuning live = new RegionScan.Tuning(apertureReaching(100d), archetypes(), + Math.toRadians(1d), 512, 100, 4, config.minSpacing); + RegionScan scan = RegionScan.directed(HOME, 1, 0, 0, live.maxRangeSteps(), 0L, live); + int looks = scan.totalCells(); + CrystalMemory crystal = new CrystalMemory(); + + long startedAt = System.nanoTime(); + TelescopeScan.resolveBatch(registry, scan, 0, looks, crystal, 7_000L, dimId -> "Body-" + dimId); + long elapsedMs = (System.nanoTime() - startedAt) / 1_000_000L; + + NBTTagCompound tag = new NBTTagCompound(); + registry.writeToNBT(tag); + int pins = pinnedCount(registry); + int bytes = tag.toString().length(); + System.out.println("survey of " + looks + " looks froze " + pins + " systems in " + elapsedMs + + " ms; the universe save renders as " + bytes + " chars"); + + assertTrue("a survey must not freeze more systems than its looks could have found (" + pins + + " pins for " + looks + " looks)", + pins <= (long) looks * TelescopeScan.MAX_SEATS_PER_LOOK); + assertTrue("arrangement: the sweep must have frozen something", pins > 0); + } + + private static int pinnedCount(UniverseRegistry registry) { + NBTTagCompound tag = new NBTTagCompound(); + registry.writeToNBT(tag); + return tag.getTagList("pinnedSystems", 10).tagCount(); + } + + @Test + public void aLookIntoTheVoidDiscoversNothing() { + // The gate exists so that empty sky does not manufacture addresses — and the fix must not + // trade one failure for its opposite by attributing every cell to some system. + UniverseRegistry registry = threeSystems(); + CrystalMemory crystal = new CrystalMemory(); + + int written = TelescopeScan.resolveCell(registry, cell(400 * STEP, 0, 0), crystal, 7_000L, + dimId -> "Body-" + dimId); + + assertEquals("interstellar void must yield no addresses at all", 0, written); + assertEquals("and must write nothing onto the crystal", 0, crystal.size()); + } + + @Test + public void anInstrumentInsideASystemResolvesTheSystemItStandsIn() { + // An observatory does not stand on its own star: it stands on a planet, in one of its + // system's member cells. Under the old gate that cell reported empty, so the machine could + // not name the system it was sitting in — which is also what the local radar is for. + UniverseRegistry registry = threeSystems(); + CrystalMemory crystal = new CrystalMemory(); + GalacticCoord standingOn = cell(4 * STEP - 20 + 5_000, 3_000, 0); // a member cell, not the seat + + TelescopeScan.resolveCell(registry, standingOn, crystal, 7_000L, dimId -> "Body-" + dimId); + + assertNotNull("an instrument inside a system must be able to name that system", + crystal.forBody(401)); + } + @Test public void aSystemTheCrystalNeverHeardOfIsStillDiscovered() { // The discriminator against the tempting wrong shape — reporting only what is already known. @@ -252,11 +676,11 @@ public void aSystemTheCrystalNeverHeardOfIsStillDiscovered() { // new: a knowledge gate anywhere on this path leaves it missing and this test red. UniverseRegistry registry = threeSystems(); CrystalMemory crystal = new CrystalMemory(); - crystal.record(new CrystalEntry(cell(4, 0, 0), "Body-401", SystemBodyKind.PLANET, + crystal.record(new CrystalEntry(stepsOut(4), "Body-401", SystemBodyKind.PLANET, InfoTier.TELESCOPE, 1_000L, 401)); assertNull("the fixture must start ignorant of the body under test", crystal.forBody(501)); - surveyAll(registry, boxAroundFourthSector(), crystal, 7_000L); + surveyAll(registry, pointingFourTerritoriesOut(), crystal, 7_000L); assertNotNull("a telescope discovers what nobody knew, or it discovers nothing", crystal.forBody(501)); @@ -269,7 +693,7 @@ public void whatATelescopeWritesIsCoarseAndDated() { UniverseRegistry registry = threeSystems(); CrystalMemory crystal = new CrystalMemory(); - surveyAll(registry, boxAroundFourthSector(), crystal, 7_000L); + surveyAll(registry, pointingFourTerritoriesOut(), crystal, 7_000L); CrystalEntry learned = crystal.forBody(401); assertNotNull(learned); @@ -284,11 +708,11 @@ public void aSweepWritesOnlyTheCellsItHasReached() { // instrument works, not all at the end. UniverseRegistry registry = threeSystems(); CrystalMemory crystal = new CrystalMemory(); - RegionScan scan = boxAroundFourthSector(); + RegionScan scan = pointingFourTerritoriesOut(); int firstCellWithContent = -1; for (int i = 0; i < scan.totalCells(); i++) { - if (scan.cellAt(i).cellKey().equals(cell(4, 0, 0).cellKey())) { + if (scan.cellAt(i).cellKey().equals(stepsOut(4).cellKey())) { firstCellWithContent = i; break; } diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/UniverseRegistryTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/UniverseRegistryTest.java index 67e9ab131..55993ffb9 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/unit/UniverseRegistryTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/UniverseRegistryTest.java @@ -16,19 +16,27 @@ import zmaster587.advancedRocketry.space.GalacticCoord; import zmaster587.advancedRocketry.util.AstronomicalBodyHelper; import zmaster587.advancedRocketry.universe.ClusteredGalaxyGenerator; +import zmaster587.advancedRocketry.universe.GalacticAnchor; import zmaster587.advancedRocketry.universe.EmptyGalaxyGenerator; import zmaster587.advancedRocketry.universe.GalaxyGenConfig; import zmaster587.advancedRocketry.universe.IGalaxyGenerator; -import zmaster587.advancedRocketry.universe.StarSystem; +import zmaster587.advancedRocketry.universe.PlanetarySystem; import zmaster587.advancedRocketry.universe.SystemBody; import zmaster587.advancedRocketry.universe.SystemBodyKind; +import zmaster587.advancedRocketry.universe.IUniverseLaws; +import zmaster587.advancedRocketry.universe.UniverseLawsV0; import zmaster587.advancedRocketry.universe.UniverseRegistry; +import zmaster587.advancedRocketry.universe.UniverseSchema; +import zmaster587.advancedRocketry.universe.UniverseSchemaMismatchException; +import zmaster587.advancedRocketry.universe.UniverseSchemas; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; /** * Contract tests for the Layer-1 universe registry: the cell-keyed coord↔system placement @@ -41,6 +49,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); @@ -250,22 +265,22 @@ public void systemForCoordPrefersStoredOverGenerator() { GalacticCoord placedCell = GalacticCoord.ofSectorLocal(5, 5, 5, 0, 0, 0); reg.place(placedCell, 42); - Optional atPlaced = reg.systemForCoord(placedCell); + Optional atPlaced = reg.systemForCoord(placedCell); assertTrue(atPlaced.isPresent()); - assertSame("stored placement must win over the generator", stored, atPlaced.get().star()); - assertEquals(42, atPlaced.get().starId()); + assertSame("stored placement must win over the generator", stored, atPlaced.get().star().get()); + assertEquals(42, atPlaced.get().systemId()); // A member cell of the stored anchor's super-cell attributes to the STORED system, not the // generator: an authored anchor owns every cell of its super-cell. - Optional nearStored = reg.systemForCoord(GalacticCoord.ofSectorLocal(6, 6, 6, 0, 0, 0)); + Optional nearStored = reg.systemForCoord(GalacticCoord.ofSectorLocal(6, 6, 6, 0, 0, 0)); assertTrue(nearStored.isPresent()); - assertEquals(42, nearStored.get().starId()); + assertEquals(42, nearStored.get().systemId()); // 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)); + Optional farAway = reg.systemForCoord( + GalacticCoord.ofSectorLocal(ANOTHER_SUPER_CELL, ANOTHER_SUPER_CELL, ANOTHER_SUPER_CELL, 0, 0, 0)); assertTrue(farAway.isPresent()); - assertEquals(777, farAway.get().starId()); + assertEquals(777, farAway.get().systemId()); } @Test @@ -275,17 +290,18 @@ public void memberCellResolvesToItsOwningProceduralSystem() { // system; the zone read returns exactly that cell's body. UniverseRegistry reg = new UniverseRegistry(); reg.bindWorldSeed(0xBEEF); - GalaxyGenConfig cfg = new GalaxyGenConfig(0.9d, 16, 8, 0.0d, null); + GalaxyGenConfig cfg = new GalaxyGenConfig(16, 0.9d, GalaxyGenConfig.DEFAULT_GALAXY_SPACING, + GalaxyGenConfig.DEFAULT_GALAXY_DENSITY, null, null); UniverseRegistry.setGenerator(new ClusteredGalaxyGenerator(cfg)); // Find an occupied super-cell and a non-star body of its system. GalacticCoord anchor = null; SystemBody planet = null; for (long sup = 0; sup < 8 && planet == null; sup++) { - Optional sys = reg.systemForCoord( + Optional sys = reg.systemForCoord( GalacticCoord.ofSectorLocal(sup * cfg.minSpacing, 0, 0, 0, 0, 0)); - if (!sys.isPresent()) { - continue; + if (!sys.isPresent() || !sys.get().star().isPresent()) { + continue; // a starless system has no star body to be the anchor of this comparison } for (SystemBody b : reg.systemBodiesAt( GalacticCoord.ofSectorLocal(sup * cfg.minSpacing, 0, 0, 0, 0, 0))) { @@ -301,9 +317,9 @@ public void memberCellResolvesToItsOwningProceduralSystem() { assertFalse("the sampled body must sit in its OWN cell", planet.name().sameCell(anchor)); // The body's cell resolves to the same system (member attribution). - Optional atBody = reg.systemForCoord(planet.name()); + Optional atBody = reg.systemForCoord(planet.name()); assertTrue(atBody.isPresent()); - assertEquals(planet.starId(), atBody.get().starId()); + assertEquals(planet.starId(), atBody.get().systemId()); // Zone read at the body's cell returns the body; at the anchor it returns the star, not the body. List zone = reg.bodiesAt(planet.name()); @@ -326,7 +342,8 @@ public void memberCellResolvesToItsOwningProceduralSystem() { public void pinOnTouchSnapshotsAProceduralSystemAgainstSeedChange() { UniverseRegistry reg = new UniverseRegistry(); reg.bindWorldSeed(1234L); - GalaxyGenConfig cfg = new GalaxyGenConfig(0.9d, 8, 8, 0.0d, null); + GalaxyGenConfig cfg = new GalaxyGenConfig(8, 0.9d, GalaxyGenConfig.DEFAULT_GALAXY_SPACING, + GalaxyGenConfig.DEFAULT_GALAXY_DENSITY, null, null); UniverseRegistry.setGenerator(new ClusteredGalaxyGenerator(cfg)); GalacticCoord anchor = null; @@ -339,7 +356,7 @@ public void pinOnTouchSnapshotsAProceduralSystemAgainstSeedChange() { } assertNotNull("need an occupied procedural super-cell", anchor); - int starIdBefore = reg.systemForCoord(anchor).get().starId(); + int starIdBefore = reg.systemForCoord(anchor).get().systemId(); List bodiesBefore = reg.systemBodiesAt(anchor); // TOUCH: pin the system (addPoi would do the same implicitly). @@ -349,7 +366,7 @@ public void pinOnTouchSnapshotsAProceduralSystemAgainstSeedChange() { // A config/seed change (the drift scenario) must NOT move or reshape the pinned system… reg.bindWorldSeed(999_999L); assertEquals("pinned system survives a seed change", starIdBefore, - reg.systemForCoord(anchor).get().starId()); + reg.systemForCoord(anchor).get().systemId()); assertEquals("pinned bodies survive a seed change", bodiesBefore, reg.systemBodiesAt(anchor)); // …and the pin round-trips through NBT (reads from the save, not the generator or catalogue). @@ -359,7 +376,7 @@ public void pinOnTouchSnapshotsAProceduralSystemAgainstSeedChange() { round.readFromNBT(tag); round.bindWorldSeed(999_999L); assertTrue(round.systemForCoord(anchor).isPresent()); - assertEquals(starIdBefore, round.systemForCoord(anchor).get().starId()); + assertEquals(starIdBefore, round.systemForCoord(anchor).get().systemId()); assertEquals(bodiesBefore, round.systemBodiesAt(anchor)); } @@ -373,9 +390,9 @@ public void systemForCoordIsEmptyOnVoidCellWithDefaultGenerator() { public void systemsAreLocationAgnostic() { // The coordinate is obtainable ONLY from the registry; the system handle exposes no coordinate. StellarBody body = star(9); - StarSystem sys = new StarSystem(body); - assertEquals(9, sys.starId()); - assertSame(body, sys.star()); + PlanetarySystem sys = PlanetarySystem.ofStar(body); + assertEquals(9, sys.systemId()); + assertSame(body, sys.star().get()); UniverseRegistry reg = new UniverseRegistry(); assertFalse("an unregistered system has no coord", reg.coordForStar(body).isPresent()); @@ -386,17 +403,17 @@ public void systemsAreLocationAgnostic() { @Test public void anchorsDrainOnceThenPersistedStoreWins() { UniverseRegistry reg = new UniverseRegistry(); - Map anchors = new HashMap<>(); - anchors.put(1, GalacticCoord.ofSectorLocal(1, 0, 0, 0, 0, 0)); - anchors.put(2, GalacticCoord.ofSectorLocal(2, 0, 0, 0, 0, 0)); + Map anchors = new HashMap<>(); + anchors.put(1, GalacticAnchor.inHome(GalacticCoord.ofSectorLocal(1, 0, 0, 0, 0, 0))); + anchors.put(2, GalacticAnchor.inHome(GalacticCoord.ofSectorLocal(2, 0, 0, 0, 0, 0))); reg.applyAnchors(anchors, false); assertEquals(Optional.of(GalacticCoord.ofSectorLocal(1, 0, 0, 0, 0, 0)), reg.coordForSystem(1)); assertEquals(Optional.of(GalacticCoord.ofSectorLocal(2, 0, 0, 0, 0, 0)), reg.coordForSystem(2)); // Second drain with DIFFERENT anchors is a no-op (already seeded) unless a reset is forced. - Map moved = new HashMap<>(); - moved.put(1, GalacticCoord.ofSectorLocal(50, 0, 0, 0, 0, 0)); + Map moved = new HashMap<>(); + moved.put(1, GalacticAnchor.inHome(GalacticCoord.ofSectorLocal(50, 0, 0, 0, 0, 0))); reg.applyAnchors(moved, false); assertEquals("re-draining without reset must not move the anchor", Optional.of(GalacticCoord.ofSectorLocal(1, 0, 0, 0, 0, 0)), reg.coordForSystem(1)); @@ -417,8 +434,8 @@ public void anchorsSeededLatchPersistsThroughNbt() { round.readFromNBT(tag); // The latch survived, so a fresh anchor drain is ignored (persisted store wins across restarts). - Map anchors = new HashMap<>(); - anchors.put(3, GalacticCoord.ofSectorLocal(3, 0, 0, 0, 0, 0)); + Map anchors = new HashMap<>(); + anchors.put(3, GalacticAnchor.inHome(GalacticCoord.ofSectorLocal(3, 0, 0, 0, 0, 0))); round.applyAnchors(anchors, false); assertFalse("a restart must not re-seed anchors over the persisted store", round.coordForSystem(3).isPresent()); @@ -472,9 +489,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()); @@ -499,7 +516,9 @@ public void bodiesAtIsEmptyOnVoidCellWithDefaultGenerator() { public void bodiesAtMergesProceduralBodiesAndPois() { UniverseRegistry reg = new UniverseRegistry(); reg.bindWorldSeed(0xABCDEFL); - UniverseRegistry.setGenerator(new ClusteredGalaxyGenerator(new GalaxyGenConfig(0.9d, 1, 8, 0.0d, null))); + UniverseRegistry.setGenerator(new ClusteredGalaxyGenerator(new GalaxyGenConfig(1, 0.9d, + GalaxyGenConfig.DEFAULT_GALAXY_SPACING, GalaxyGenConfig.DEFAULT_GALAXY_DENSITY, + null, null))); GalacticCoord found = null; for (long x = 0; x < 300 && found == null; x++) { @@ -514,7 +533,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); @@ -557,7 +576,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 +594,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 +634,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); @@ -704,7 +723,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); @@ -734,12 +753,412 @@ 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()); } + // ── the world-model stamp ───────────────────────────────────────────────── + + /** The configuration a pack states, and a retuned one — one knob apart. */ + private static GalaxyGenConfig packConfig() { + return GalaxyGenConfig.defaults(); + } + + private static GalaxyGenConfig retunedConfig() { + return GalaxyGenConfig.defaults().withRogueTuning( + new GalaxyGenConfig.RogueTuning(7d, 0.012d, 3d, GalaxyGenConfig.defaultRogueTypes())); + } + + @Test + public void aFreshWorldTakesTheCurrentModelAndRecordsIt() { + // Nothing to reconcile against: a new world is generated under whatever this build ships, and + // that fact is written down so the NEXT load has something to check. + UniverseRegistry reg = new UniverseRegistry(); + assertEquals("a world with no history carries no stamp", UniverseRegistry.UNSTAMPED, + reg.schemaVersion()); + + UniverseSchema schema = reg.reconcileSchema(packConfig()); + + assertEquals("a fresh world is generated under the current model", + UniverseSchemas.CURRENT, schema.version()); + assertEquals("and the model it was generated under is recorded", + UniverseSchemas.CURRENT, reg.schemaVersion()); + assertEquals("along with the configuration that produced it", + packConfig().fingerprint(), reg.configFingerprint()); + } + + @Test + public void theModelAWorldWasGeneratedUnderSurvivesASave() { + UniverseRegistry source = new UniverseRegistry(); + source.reconcileSchema(packConfig()); + + NBTTagCompound tag = new NBTTagCompound(); + source.writeToNBT(tag); + UniverseRegistry round = new UniverseRegistry(); + round.readFromNBT(tag); + + assertEquals("the schema version must outlive the session", source.schemaVersion(), + round.schemaVersion()); + assertEquals("and so must the configuration it was generated under", + source.configFingerprint(), round.configFingerprint()); + } + + @Test + public void theSameConfigurationOpensTheWorldUnchanged() { + // The ordinary case, and the one that must never cost the player anything: same pack, same + // build, second boot. + UniverseRegistry reg = new UniverseRegistry(); + reg.reconcileSchema(packConfig()); + + NBTTagCompound tag = new NBTTagCompound(); + reg.writeToNBT(tag); + UniverseRegistry reopened = new UniverseRegistry(); + reopened.readFromNBT(tag); + + UniverseSchema schema = reopened.reconcileSchema(packConfig()); + assertEquals("an unchanged world opens under the model it was made with", + UniverseSchemas.CURRENT, schema.version()); + } + + @Test + public void aRetunedConfigurationIsRefusedRatherThanSubstituted() { + // The defect this whole stamp exists for: a pack edit silently re-deriving every system a + // player has not visited. It must stop the load, not warn into a log nobody reads. + UniverseRegistry reg = new UniverseRegistry(); + reg.reconcileSchema(packConfig()); + NBTTagCompound tag = new NBTTagCompound(); + reg.writeToNBT(tag); + UniverseRegistry reopened = new UniverseRegistry(); + reopened.readFromNBT(tag); + + try { + reopened.reconcileSchema(retunedConfig()); + fail("a world whose has been retuned must not load silently"); + } catch (UniverseSchemaMismatchException expected) { + assertTrue("the refusal must name the configuration the world was made under: " + + expected.getMessage(), + expected.getMessage().contains(packConfig().fingerprint())); + assertTrue("and the one the pack now states: " + expected.getMessage(), + expected.getMessage().contains(retunedConfig().fingerprint())); + } + } + + @Test + public void aWorldFromAModelThisBuildDoesNotCarryIsRefused() { + // A save from a newer jar. There is no honest way to open it: this build cannot reproduce the + // universe it describes, and deriving a different one under the same save is the silent + // corruption the refusal exists to prevent. + UniverseRegistry reg = new UniverseRegistry(); + NBTTagCompound tag = new NBTTagCompound(); + reg.writeToNBT(tag); + tag.setInteger("schemaVersion", 9999); + tag.setString("galaxyConfigFingerprint", packConfig().fingerprint()); + UniverseRegistry fromTheFuture = new UniverseRegistry(); + fromTheFuture.readFromNBT(tag); + + try { + fromTheFuture.reconcileSchema(packConfig()); + fail("a world from an unknown schema version must not load"); + } catch (UniverseSchemaMismatchException expected) { + assertTrue("the refusal must name the version the world needs: " + expected.getMessage(), + expected.getMessage().contains("9999")); + } + } + + @Test + public void anUpgradeAcceptsTheNewConfigurationDeliberately() { + // The door out of the refusal above: the player asks for it, and afterwards the world opens + // under what the pack now says. + UniverseRegistry reg = new UniverseRegistry(); + reg.reconcileSchema(packConfig()); + + reg.adoptSchema(retunedConfig()); + + assertEquals("an upgrade records the configuration it accepted", + retunedConfig().fingerprint(), reg.configFingerprint()); + assertEquals("under the current model", UniverseSchemas.CURRENT, reg.schemaVersion()); + assertEquals("and the world then opens without complaint", UniverseSchemas.CURRENT, + reg.reconcileSchema(retunedConfig()).version()); + } + + @Test + public void theLawsAWorldWasGeneratedUnderAreRecordedTheSameWay() { + // The metric and the expansion are stamped, not versioned by implementation: a changed metric + // means every address denotes a different distance, which no existing world can be RUN under. + UniverseRegistry reg = new UniverseRegistry(); + reg.reconcileSchema(packConfig()); + + assertEquals("a fresh world records the laws it was generated under", + UniverseRegistry.currentLawsFingerprint(), reg.lawsFingerprint()); + + NBTTagCompound tag = new NBTTagCompound(); + reg.writeToNBT(tag); + UniverseRegistry round = new UniverseRegistry(); + round.readFromNBT(tag); + assertEquals("and they outlive the session", reg.lawsFingerprint(), round.lawsFingerprint()); + } + + @Test + public void theShippedModelIsTheAlphaAndSaysSo() { + // The leading zero is the whole statement: this model may be REPLACED rather than extended, and + // a player is told so on any world that uses it. + UniverseSchema current = UniverseSchemas.current(); + + assertEquals("the first released model is version 0", 0, current.version()); + assertEquals("and its human label carries the zero", "0.1", current.label()); + assertFalse("a 0.x label is not a stable release", current.isStable()); + } + + @Test + public void anAbsentStampIsNotReadAsVersionZero() { + // The trap that version 0 creates: NBT answers 0 for an absent integer, and 0 is now a real + // version. Reading the value instead of asking whether the key exists would report every + // stampless save as "generated by the alpha" and skip the adoption a fresh world is owed. + NBTTagCompound bare = new NBTTagCompound(); + assertEquals("arrangement: NBT must indeed default an absent integer to zero", + 0, bare.getInteger("schemaVersion")); + + UniverseRegistry reg = new UniverseRegistry(); + reg.readFromNBT(bare); + + assertEquals("a save with no stamp must read as UNSTAMPED, not as the alpha", + UniverseRegistry.UNSTAMPED, reg.schemaVersion()); + assertTrue("and UNSTAMPED must be a value no version can take", + UniverseRegistry.UNSTAMPED < 0); + } + + @Test + public void anAlphaWorldIsRecognisedAsStampedAfterAReload() { + // The other half of the same trap: a world genuinely generated under version 0 must come back + // as version 0, not as "never stamped". + UniverseRegistry reg = new UniverseRegistry(); + reg.reconcileSchema(packConfig()); + assertEquals("arrangement: the fresh world takes the alpha", 0, reg.schemaVersion()); + + NBTTagCompound tag = new NBTTagCompound(); + reg.writeToNBT(tag); + UniverseRegistry reopened = new UniverseRegistry(); + reopened.readFromNBT(tag); + + assertEquals("an alpha world must reload as the alpha", 0, reopened.schemaVersion()); + assertEquals("and its configuration must not have been re-adopted", + reg.configFingerprint(), reopened.configFingerprint()); + } + + @Test + public void aVersionsLawsTravelWithIt() { + // The point of the whole exercise: selecting a version selects the metric too, so a build that + // ships a new one does not re-measure the worlds already made under the old. + UniverseSchema v1 = UniverseSchemas.current(); + + assertSame("the generator a schema builds must measure by that schema's laws", + v1.laws(), v1.generator(packConfig()).laws()); + assertEquals("and the stamp is that schema's laws, measured", + UniverseRegistry.lawsFingerprintOf(v1.laws()), + UniverseRegistry.currentLawsFingerprint()); + } + + @Test + public void theLawsFingerprintMeasuresBehaviourNotDeclarations() { + // Taken by RUNNING the conversions, so an implementation whose internal constant moved is caught + // even though it publishes the same list of names. + IUniverseLaws shifted = new ShiftedLaws(); + + assertNotEquals("one cell of difference in one conversion must change the identity", + UniverseRegistry.lawsFingerprintOf(UniverseLawsV0.INSTANCE), + UniverseRegistry.lawsFingerprintOf(shifted)); + } + + /** Version 1's laws with a single conversion moved — a stand-in for a version that measures anew. */ + private static final class ShiftedLaws implements IUniverseLaws { + private final IUniverseLaws base = UniverseLawsV0.INSTANCE; + + @Override + public long cellsForLightYears(double lightYears) { + return base.cellsForLightYears(lightYears) + 1L; + } + + @Override + public long cellsAt(double lightYears) { + return base.cellsAt(lightYears); + } + + @Override + public double lightYearsForCells(double cells) { + return base.lightYearsForCells(cells); + } + + @Override + public double lightYearsPerTick(double kilometresPerSecond) { + return base.lightYearsPerTick(kilometresPerSecond); + } + + @Override + public long cellsForOrbitUnits(double orbitUnits) { + return base.cellsForOrbitUnits(orbitUnits); + } + + @Override + public double orbitUnitsForCells(long cells) { + return base.orbitUnitsForCells(cells); + } + + @Override + public long seatMarginCells(long spacingCells) { + return base.seatMarginCells(spacingCells); + } + + @Override + public double retinueReachLy(double primaryRadiusLy) { + return base.retinueReachLy(primaryRadiusLy); + } + + @Override + public double scaleFactorAt(long tick) { + return base.scaleFactorAt(tick); + } + + @Override + public long driftHorizonTicks() { + return base.driftHorizonTicks(); + } + } + + @Test + public void aReleasedVersionWhoseLawsWereEditedInPlaceIsRefused() { + // A released version's laws may never move: a changed metric ships as a NEW version, which old + // worlds simply do not use. So a mismatch here is not a player's situation at all — it says this + // jar's schema 1 is not the schema 1 that made the world, and nobody downstream can accept that + // away. + UniverseRegistry reg = new UniverseRegistry(); + reg.reconcileSchema(packConfig()); + NBTTagCompound tag = new NBTTagCompound(); + reg.writeToNBT(tag); + tag.setString("universeLawsFingerprint", "0000deadbeef0000"); + UniverseRegistry otherLaws = new UniverseRegistry(); + otherLaws.readFromNBT(tag); + + try { + otherLaws.reconcileSchema(packConfig()); + fail("a world generated under different laws must not load silently"); + } catch (UniverseSchemaMismatchException expected) { + assertTrue("the refusal must name the laws the world was made under: " + + expected.getMessage(), expected.getMessage().contains("0000deadbeef0000")); + assertTrue("and what this build's schema 1 measures: " + expected.getMessage(), + expected.getMessage().contains(UniverseRegistry.currentLawsFingerprint())); + assertFalse("it must not blame the pack's configuration, which has not moved: " + + expected.getMessage(), expected.getMessage().contains(" configuration")); + } + } + + @Test + public void anUpgradeMayNotAcceptEditedLaws() { + // The one door that must NOT open. A configuration is the pack author's to change and an + // operator may accept it; a released version's laws moving is a broken build, and accepting it + // would silently re-measure everything the world already holds. + UniverseRegistry reg = new UniverseRegistry(); + reg.reconcileSchema(packConfig()); + reg.armUpgrade(); + NBTTagCompound tag = new NBTTagCompound(); + reg.writeToNBT(tag); + tag.setString("universeLawsFingerprint", "0000deadbeef0000"); + UniverseRegistry brokenBuild = new UniverseRegistry(); + brokenBuild.readFromNBT(tag); + + try { + brokenBuild.reconcileSchema(packConfig()); + fail("an armed upgrade must not accept a released version's laws having moved"); + } catch (UniverseSchemaMismatchException expected) { + assertTrue("the permission must still be standing, unspent", brokenBuild.isUpgradeArmed()); + } + } + + @Test + public void anArmedUpgradeIsSpentOnceAndOnlyOnce() { + // The remedy has to outlive the session that authorised it: a changed stops the + // load, so the permission is given while the world still opens and spent at the boot after. + // Once — a second edit is a second decision. + UniverseRegistry reg = new UniverseRegistry(); + reg.reconcileSchema(packConfig()); + reg.armUpgrade(); + + NBTTagCompound tag = new NBTTagCompound(); + reg.writeToNBT(tag); + UniverseRegistry nextBoot = new UniverseRegistry(); + nextBoot.readFromNBT(tag); + assertTrue("the permission must survive the restart it exists to cross", + nextBoot.isUpgradeArmed()); + + nextBoot.reconcileSchema(retunedConfig()); + assertEquals("the armed load accepts the new configuration", + retunedConfig().fingerprint(), nextBoot.configFingerprint()); + assertFalse("and the permission is spent", nextBoot.isUpgradeArmed()); + + GalaxyGenConfig retunedAgain = GalaxyGenConfig.defaults().withRogueTuning( + new GalaxyGenConfig.RogueTuning(3d, 0.012d, 3d, GalaxyGenConfig.defaultRogueTypes())); + try { + nextBoot.reconcileSchema(retunedAgain); + fail("a second configuration change must be refused like any other"); + } catch (UniverseSchemaMismatchException expected) { + assertTrue("and the refusal must still say how to accept it deliberately: " + + expected.getMessage(), expected.getMessage().contains("upgrade confirm")); + } + } + + @Test + public void anArmedWorldWhoseConfigurationDidNotChangeKeepsItsPermission() { + // Arming is not a countdown: a world that boots unchanged has spent nothing, and the operator + // who armed it can still make the edit he armed it for. + UniverseRegistry reg = new UniverseRegistry(); + reg.reconcileSchema(packConfig()); + reg.armUpgrade(); + + reg.reconcileSchema(packConfig()); + + assertTrue("an unchanged load must not consume the permission", reg.isUpgradeArmed()); + } + + @Test + public void anAuthoredOnlyUniverseHasAModelOfItsOwn() { + // No is a legitimate world, not a missing configuration — and it is a DIFFERENT + // world from one that declares a generator, so the two must not share a fingerprint. + UniverseRegistry reg = new UniverseRegistry(); + reg.reconcileSchema(null); + + assertEquals("an authored-anchors-only world is stamped like any other", + UniverseSchemas.CURRENT, reg.schemaVersion()); + assertNotEquals("declaring no generator is not the same universe as declaring one", + packConfig().fingerprint(), reg.configFingerprint()); + assertEquals("and it reopens unchanged", UniverseSchemas.CURRENT, + reg.reconcileSchema(null).version()); + } + + @Test + public void aConfigurationsFingerprintIsAboutTheUniverseItDescribes() { + // Two configurations that describe the same universe must agree, or every load is a false + // alarm; two that describe different ones must differ, or the check sees nothing. + assertEquals("the same knobs must fingerprint the same, run after run", + GalaxyGenConfig.defaults().fingerprint(), GalaxyGenConfig.defaults().fingerprint()); + assertNotEquals("a retuned knob is a different universe", + GalaxyGenConfig.defaults().fingerprint(), retunedConfig().fingerprint()); + } + + @Test + public void reservingAGalaxyKeepsWhatThePackSaidAboutRogues() { + // Authored anchors are folded in AFTER is read, so the fold must not quietly drop + // the rest of what the pack stated — the stamp would then record a universe nobody authored. + GalaxyGenConfig authored = retunedConfig(); + GalaxyGenConfig withGalaxy = authored.withReservedGalaxies( + java.util.Collections.singletonList(zmaster587.advancedRocketry.universe.GalaxyKey.of(1L, 0L, 0L))); + + assertEquals("the authored rogue abundance must survive reserving a galaxy", + authored.rogue.abundance, withGalaxy.rogue.abundance, 0d); + assertEquals("and so must the rest of the tuning", authored.rogue.giantFraction, + withGalaxy.rogue.giantFraction, 0d); + } + /** A test generator that claims every cell with one fixed system — to prove stored placements win. */ private static final class AllClaimingGenerator implements IGalaxyGenerator { private final StellarBody body; @@ -749,14 +1168,14 @@ private static final class AllClaimingGenerator implements IGalaxyGenerator { } @Override - public Optional systemAt(long seed, GalacticCoord coord) { - return Optional.of(new StarSystem(body)); + public Optional systemAt(long seed, GalacticCoord coord) { + return Optional.of(PlanetarySystem.ofStar(body)); } @Override - public Map systemsInRegion(long seed, GalacticCoord min, GalacticCoord max) { - Map m = new HashMap<>(); - m.put(min.cellCentre(), new StarSystem(body)); + public Map systemsInRegion(long seed, GalacticCoord min, GalacticCoord max) { + Map m = new HashMap<>(); + m.put(min.cellCentre(), PlanetarySystem.ofStar(body)); return m; } } diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/VoidContentTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/VoidContentTest.java new file mode 100644 index 000000000..09f9f802b --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/VoidContentTest.java @@ -0,0 +1,357 @@ +package zmaster587.advancedRocketry.test.unit; + +import org.junit.Test; + +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import zmaster587.advancedRocketry.space.GalacticCoord; +import zmaster587.advancedRocketry.universe.BodyProfile; +import zmaster587.advancedRocketry.universe.ClusteredGalaxyGenerator; +import zmaster587.advancedRocketry.universe.Galaxy; +import zmaster587.advancedRocketry.universe.GalaxyField; +import zmaster587.advancedRocketry.universe.GalaxyGenConfig; +import zmaster587.advancedRocketry.universe.PlanetDerivation; +import zmaster587.advancedRocketry.universe.PlanetarySystem; +import zmaster587.advancedRocketry.universe.StarCluster; +import zmaster587.advancedRocketry.universe.SystemBody; +import zmaster587.advancedRocketry.universe.SystemBodyKind; +import zmaster587.advancedRocketry.universe.UniverseScale; + +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 what is out there in the INTERGALACTIC VOID: rogue worlds, rogue stars, and the + * globulars that were thrown clear of a galaxy. Pure JUnit; no MC bootstrap. + * + *

    The void's content is not a second placement rule. It is the SAME star lattice, drawn a second + * time against the SAME material function — the galaxy's own profile where there is a galaxy, and its + * ejecta halo where there is not. So the contracts here are about that one function's shape and about + * what a starless system is, never about the numbers either of them happens to be tuned to.

    + * + *

    Sampling is by SUPER-CELL and the sweeps are large. Out past a galaxy's edge the occupancy + * is percent-scale, so a sweep of a few dozen cubes finds nothing whatever the model says. Where a + * count would need thousands of samples to be stable, the test reads the PROFILE instead, which is + * exact and is the thing the contract is actually about.

    + */ +public class VoidContentTest { + + private static final long SEED = 0x5EEDF00DL; + private static final int SPACING = GalaxyGenConfig.DEFAULT_MIN_SPACING; + + /** Every cube occupied at the galaxy's densest point, so a void sweep is not fighting the draw too. */ + private static ClusteredGalaxyGenerator gen() { + return new ClusteredGalaxyGenerator(new GalaxyGenConfig(SPACING, 1.0d, + GalaxyGenConfig.DEFAULT_GALAXY_SPACING, GalaxyGenConfig.DEFAULT_GALAXY_DENSITY, + null, null)); + } + + private static GalacticCoord cell(long sx, long sy, long sz) { + return GalacticCoord.ofSectorLocal(sx, sy, sz, 0L, 0L, 0L); + } + + /** + * A sector on the +X axis, {@code radii} of the home galaxy's radius out from its CENTRE. + * + *

    Measured from the centre and in units of the radius, because the radius is drawn per seed: a + * fixed light-year distance would be inside the galaxy on one seed and deep in the void on the + * next, and the test would be pinning that draw rather than the model.

    + */ + private static long xAt(Galaxy home, double radii) { + return home.centre().sectorX() + UniverseScale.cellsForLightYears(home.radiusLy() * radii); + } + + // ─── The material function: what the void is made of ─────────────────────── + + @Test + public void pastAGalaxysEdgeTheMaterialIsUnboundAndOnlyUnbound() { + // The split IS the model: inside a galaxy, material a star can condense out of; outside it, + // material that was thrown out of one and can only be arrived at. + ClusteredGalaxyGenerator gen = gen(); + Galaxy home = gen.galaxies().home(SEED); + GalaxyField field = gen.galaxies(); + + GalaxyField.Material inside = field.materialAtSector(SEED, home.centre().sectorX(), + home.centre().sectorY(), home.centre().sectorZ()); + assertTrue("a galaxy's own centre must hold bound material", inside.bound > 0d); + assertEquals("and none of it is unbound: the profile already counts every body there", + 0d, inside.unbound, 0d); + + // 1.5 radii out is outside the primary and out of every satellite's reach as well: a satellite + // is seated at least one full DIAMETER out and is at most 0.3 R across, so the nearest surface + // any of them can present is 1.7 R. + GalaxyField.Material outside = field.materialAtSector(SEED, xAt(home, 1.5d), + home.centre().sectorY(), home.centre().sectorZ()); + assertEquals("nothing FORMS past the declared radius", 0d, outside.bound, 0d); + assertTrue("but the void is not empty: the galaxy's ejecta reaches into it", + outside.unbound > 0d); + } + + @Test + public void theEjectaHaloThinsWithDistanceFromItsGalaxy() { + // A power law anchored at the edge, so the void has a GRADIENT: a ship stepping out of a galaxy + // meets worlds often and meets them less and less the further out it goes. A flat floor would + // have made a galaxy's doorstep and the middle of nowhere read identically. + ClusteredGalaxyGenerator gen = gen(); + Galaxy home = gen.galaxies().home(SEED); + GalaxyField field = gen.galaxies(); + + double previous = Double.MAX_VALUE; + for (double radii : new double[] {1.5d, 3d, 6d, 12d, 24d}) { + double here = field.materialAtSector(SEED, xAt(home, radii), home.centre().sectorY(), + home.centre().sectorZ()).unbound; + assertTrue("the halo must be present at " + radii + " radii", here > 0d); + assertTrue("the halo must thin outwards: " + here + " at " + radii + + " radii is not below " + previous, here < previous); + previous = here; + } + } + + @Test + public void aGalaxyCubeWithNoGalaxyInItIsCompletelyEmpty() { + // The deepest void, and it is genuinely nothing: the population out here is what the cube's own + // galaxies threw out, so a cube that never held one has thrown out nothing. That is what makes + // half the universe a place only a galactic drive can cross, rather than a uniform fog. + ClusteredGalaxyGenerator gen = gen(); + GalaxyField field = gen.galaxies(); + long spacing = GalaxyGenConfig.DEFAULT_GALAXY_SPACING; + + boolean checkedAny = false; + for (long g = 1; g <= 40 && !checkedAny; g++) { + if (field.galaxyAtIndex(SEED, g, 0L, 0L).isPresent()) { + continue; + } + checkedAny = true; + long sector = g * spacing + spacing / 2L; + GalaxyField.Material material = field.materialAtSector(SEED, sector, 0L, 0L); + assertEquals("an empty galaxy cube holds no bound material", 0d, material.bound, 0d); + assertEquals("nor any ejecta: nothing was ever here to throw it", 0d, material.unbound, 0d); + assertFalse("and therefore no system at all", + gen.anchorAt(SEED, cell(sector, 0L, 0L)).isPresent()); + } + assertTrue("the sweep must find a galaxy cube that is empty", checkedAny); + } + + // ─── What the second draw actually seats ─────────────────────────────────── + + @Test + public void theVoidHoldsSystemsAndTheyAreMostlyStarless() { + // The whole point of the feature: out past the edge a ship meets things, and what it meets is + // overwhelmingly a world with no sun. A rogue STAR is drawn from the same table at a small + // weight, which is what makes finding a whole lit system out here an event rather than routine. + ClusteredGalaxyGenerator gen = gen(); + Galaxy home = gen.galaxies().home(SEED); + long x0 = xAt(home, 1.5d); + + int starless = 0; + int lit = 0; + Set seen = new HashSet<>(); + for (long i = -6; i <= 6; i++) { + for (long j = -6; j <= 6; j++) { + for (long k = -6; k <= 6; k++) { + GalacticCoord probe = cell(x0 + i * SPACING, + home.centre().sectorY() + j * SPACING, + home.centre().sectorZ() + k * SPACING); + Optional anchor = gen.anchorAt(SEED, probe); + if (!anchor.isPresent() || !seen.add(anchor.get().cellKey())) { + continue; + } + if (gen.systemAt(SEED, anchor.get()).get().star().isPresent()) { + lit++; + } else { + starless++; + } + } + } + } + assertTrue("the void just outside a galaxy must hold systems (found none in 13³ cubes)", + starless + lit > 0); + assertTrue("what it holds must be mostly starless (starless " + starless + ", lit " + lit + ")", + starless > lit); + } + + @Test + public void aStarlessSystemNamesItselfAndIsFoundLikeAnyOther() { + // Registered as an anchor is the whole of "discoverable": a survey resolves a look through the + // system that OWNS the cell, so being registered IS being findable, and a rogue needed no + // discovery mechanism of its own. + ClusteredGalaxyGenerator gen = gen(); + GalacticCoord anchor = aRogueAnchor(gen); + PlanetarySystem system = gen.systemAt(SEED, anchor).get(); + + assertEquals("its primary is a starless world", SystemBodyKind.ROGUE_PLANET, + system.primaryKind()); + assertFalse("and it has no star to be asked for", system.star().isPresent()); + assertFalse("it carries a designation of its own", system.name().isEmpty()); + assertTrue("its id is synthetic, so it can never collide with a catalogued star or a dim", + system.systemId() < 0); + + // Member attribution works exactly as it does for a star: an ordinary cell beside the seat + // resolves back to it, which is what lets a ship arrive anywhere near one and know where it is. + Optional viaMember = gen.anchorAt(SEED, + anchor.plusLocal(GalacticCoord.CELL, 0L, 0L)); + assertTrue("a member cell must attribute to the rogue's anchor", viaMember.isPresent()); + assertTrue(viaMember.get().sameCell(anchor)); + } + + @Test + public void aStarlessSystemIsTheWorldItsMoonsAndNothingElse() { + // No belt and no companion, and neither is an omission: a belt is material that never accreted + // in a star's own well, and a companion is another star. What survives being thrown out of a + // system is the world and whatever was held tightly enough to come with it. + ClusteredGalaxyGenerator gen = gen(); + GalacticCoord anchor = aRogueAnchor(gen); + List bodies = gen.bodiesFor(SEED, anchor); + + assertFalse("a rogue system must have bodies", bodies.isEmpty()); + assertEquals("the first is the rogue itself, at the anchor", SystemBodyKind.ROGUE_PLANET, + bodies.get(0).kind()); + assertTrue(bodies.get(0).name().sameCell(anchor)); + + int framesDefined = 0; + for (SystemBody body : bodies) { + assertTrue("everything a rogue keeps shares its one cell", body.name().sameCell(anchor)); + assertTrue("nothing here is a star or a belt", + body.kind() == SystemBodyKind.ROGUE_PLANET || body.kind() == SystemBodyKind.MOON); + assertFalse("a rogue is not a descend target yet, so neither is anything in its system", + body.isDescendTarget()); + if (body.definesFrame()) { + framesDefined++; + } + } + assertEquals("AT MOST ONE REAL BODY PER CELL holds for a rogue too, moons excepted", + 1, framesDefined); + assertEquals("and it is deterministic", bodies, gen.bodiesFor(SEED, anchor)); + } + + // ─── What a starless world IS ────────────────────────────────────────────── + + @Test + public void aRogueIsWarmedByItselfAndByNothingElse() { + // Its temperature is leftover formation heat leaking out through its own surface, so it is a + // function of the body and of nothing external — which is the design opportunity in having no + // star, rather than a gap where the insolation used to be. + ClusteredGalaxyGenerator gen = gen(); + GalacticCoord anchor = aRogueAnchor(gen); + BodyProfile profile = PlanetDerivation.deriveRogue(SEED, anchor, 0, GalaxyGenConfig.RogueTuning.physical().giantFraction); + + assertEquals(SystemBodyKind.ROGUE_PLANET, profile.kind()); + assertTrue("a starless world is colder than anything a star lights: " + profile.temperatureKelvin() + + " K", profile.temperatureKelvin() < 200); + assertTrue("but it is not at absolute zero either", profile.temperatureKelvin() > 0); + assertFalse("free oxygen is biology AND a gas; a world whose air is ice on the ground has neither", + profile.hasOxygen()); + assertFalse("there is nothing for it to be tidally locked TO", profile.tidallyLocked()); + assertEquals("and no orbit of its own", SystemBody.ORBIT_UNKNOWN, profile.orbitalDistance()); + assertEquals("deterministic, like every other derived body", + profile.temperatureKelvin(), + PlanetDerivation.deriveRogue(SEED, anchor, 0, GalaxyGenConfig.RogueTuning.physical().giantFraction).temperatureKelvin()); + } + + @Test + public void aHeavierRogueRunsWarmerThanALighterOne() { + // The law and not the draw: heat leaks out in proportion to the mass behind each square metre + // of surface, which is the same M/R² this derivation already calls gravity. A test that pinned + // the constant would be pinning a balance number; what is a contract is the DIRECTION. + int earthLike = PlanetDerivation.residualTemperature(1d, 1d); + int heavy = PlanetDerivation.residualTemperature(10d, 1.5d); + int feather = PlanetDerivation.residualTemperature(0.05d, 0.5d); + + assertTrue("a heavier world holds more of its own heat: " + heavy + " K vs " + earthLike + " K", + heavy > earthLike); + assertTrue("and a small light one has almost none left: " + feather + " K vs " + earthLike + " K", + feather < earthLike); + } + + // ─── Clusters that were thrown clear of a galaxy ──────────────────────────── + + @Test + public void anIntergalacticClusterIsSeatedAndItIsSelfBound() { + // Seating one outside a galaxy used to be refused by construction, on the reasoning that there + // would be no stars out there to gather. A cluster does not gather the field — it arrived with + // its own — so the refusal is lifted, and what is lifted with it is only the types that could + // actually survive the crossing. + ClusteredGalaxyGenerator gen = gen(); + Galaxy home = gen.galaxies().home(SEED); + long clusterSpacing = gen.clusters().spacingSuperCells(); + long baseIndex = Math.floorDiv(Math.floorDiv(xAt(home, 1.5d), (long) SPACING), clusterSpacing); + + StarCluster found = null; + for (long i = 0; i < 400 && found == null; i++) { + Optional cluster = gen.clusters().clusterAtIndex(SEED, null, + baseIndex + i, 0L, 0L); + if (cluster.isPresent()) { + found = cluster.get(); + } + } + assertNotNull("the void must be able to hold a cluster at all", found); + assertTrue("and only a SELF-BOUND one: an open cluster or a cloud would have dispersed on the " + + "way out. Got " + found.type().name, found.type().selfBound); + } + + @Test + public void aClusterOutsideAGalaxyStillHoldsItsStars() { + // The reason the type filter is not the whole story. A cluster's density is expressed as a + // CONTRAST against what surrounds it, and out here what surrounds it is nearly nothing — so + // k³ times nearly nothing would have produced a globular that was named, addressable and + // completely empty. It brings its own field, so it holds what a globular holds. + ClusteredGalaxyGenerator gen = gen(); + Galaxy home = gen.galaxies().home(SEED); + long clusterSpacing = gen.clusters().spacingSuperCells(); + long baseIndex = Math.floorDiv(Math.floorDiv(xAt(home, 1.5d), (long) SPACING), clusterSpacing); + + StarCluster cluster = null; + for (long i = 0; i < 400 && cluster == null; i++) { + Optional c = gen.clusters().clusterAtIndex(SEED, null, baseIndex + i, 0L, 0L); + if (c.isPresent()) { + cluster = c.get(); + } + } + assertNotNull(cluster); + + // Probe the coarse super-cell the cluster's own core sits in, against one well outside it. + long inX = cluster.centreSuperX(); + long inY = cluster.centreSuperY(); + long inZ = cluster.centreSuperZ(); + int inside = 0; + for (long d = 0; d < 4; d++) { + if (gen.anchorAt(SEED, cell((inX + d) * SPACING, inY * SPACING, inZ * SPACING)).isPresent()) { + inside++; + } + } + assertTrue("a cluster out in the void must still be full of systems (found " + inside + + " in 4 probes at its core)", inside > 0); + } + + /** + * The anchor of the first STARLESS system found just outside the home galaxy. + * + *

    Swept rather than named: which cube holds one is a draw, so a fixture that insisted on one + * particular cube would be testing the draw. It fails loudly if the sweep comes up dry, because a + * silent skip here would make every test that uses it vacuous.

    + */ + private static GalacticCoord aRogueAnchor(ClusteredGalaxyGenerator gen) { + Galaxy home = gen.galaxies().home(SEED); + long x0 = xAt(home, 1.5d); + for (long i = -6; i <= 6; i++) { + for (long j = -6; j <= 6; j++) { + for (long k = -6; k <= 6; k++) { + Optional anchor = gen.anchorAt(SEED, + cell(x0 + i * SPACING, home.centre().sectorY() + j * SPACING, + home.centre().sectorZ() + k * SPACING)); + if (anchor.isPresent() + && !gen.systemAt(SEED, anchor.get()).get().star().isPresent()) { + return anchor.get(); + } + } + } + } + throw new AssertionError("no starless system anywhere in 13³ super-cells just outside the home " + + "galaxy - the void draw is not producing anything"); + } +} diff --git a/src/test/resources/universe/golden-corpus-v1.txt b/src/test/resources/universe/golden-corpus-v1.txt new file mode 100644 index 000000000..6348f3fb8 --- /dev/null +++ b/src/test/resources/universe/golden-corpus-v1.txt @@ -0,0 +1,207 @@ +# universe golden corpus - schema 0 +config 54856f457186f8ee +scale spacingCells=3525313 galaxySpacingCells=2956478272682 seatMarginCells=93499 +scale ly=0.1 cells=118260 backLy=0.10000073490540135 +scale ly=1.0 cells=1182592 backLy=1.0000005842486757 +scale ly=4.23 cells=5002362 backLy=4.230000644874457 +scale ly=100.0 cells=118259131 backLy=100.00000007842154 +scale ly=50000.0 cells=59129565454 backLy=50000.000000313135 +cosmology tick=0 scaleFactor=1.0 +cosmology tick=24000 scaleFactor=1.0000000000014915 +cosmology tick=24000000 scaleFactor=1.0000000014914552 +seed 1 systems=10 + body -2557100_423979_3637445 -2557100_423979_3637445 kind=MOON orbit=0 radius=1.8447368918446045 starId=-1134337153 frame=false at=-76183,0,-351233 + body -2557100_423979_3637445 -2557100_423979_3637445 kind=MOON orbit=0 radius=2.414101865417722 starId=-1134337153 frame=false at=-259103,0,-152795 + body -2557100_423979_3637445 -2557100_423979_3637445 kind=ROGUE_PLANET orbit=0 radius=1.4814447373908066 starId=-1134337153 frame=true at=0,0,0 + body -2655791_3860139_4556832 -2655791_3860139_4556832 kind=ROGUE_PLANET orbit=0 radius=1.9480617919050078 starId=-157245369 frame=true at=0,0,0 + body -2673416_-3299924_-2542369 -2673416_-3299924_-2542369 kind=ROGUE_PLANET orbit=0 radius=0.5140134458397283 starId=-457888649 frame=true at=0,0,0 + body -3057060_4504688_903368 -3057060_4504688_903368 kind=MOON orbit=0 radius=0.6738095421217645 starId=-392700849 frame=false at=-216285,0,98841 + body -3057060_4504688_903368 -3057060_4504688_903368 kind=MOON orbit=0 radius=1.9863890836269211 starId=-392700849 frame=false at=86332,0,76878 + body -3057060_4504688_903368 -3057060_4504688_903368 kind=ROGUE_PLANET orbit=0 radius=0.9265150238967861 starId=-392700849 frame=true at=0,0,0 + body 1017499_549209_-2760817 1017499_549209_-2760817 kind=MOON orbit=0 radius=1.2312835780207372 starId=-268045669 frame=false at=211577,0,124718 + body 1017499_549209_-2760817 1017499_549209_-2760817 kind=ROGUE_PLANET orbit=0 radius=1.6175224369918324 starId=-268045669 frame=true at=0,0,0 + body 198207_-2510563_-2496053 198207_-2510563_-2496053 kind=ROGUE_PLANET orbit=0 radius=1.1591832445693329 starId=-914263305 frame=true at=0,0,0 + body 3639726_-2990703_-2920715 3639726_-2990703_-2920715 kind=MOON orbit=0 radius=1.5678731162225399 starId=-640125417 frame=false at=-160108,0,-85272 + body 3639726_-2990703_-2920715 3639726_-2990703_-2920715 kind=ROGUE_PLANET orbit=0 radius=1.1642707970044215 starId=-640125417 frame=true at=0,0,0 + body 3823355_3745237_320026 3823355_3745237_320026 kind=ROGUE_PLANET orbit=0 radius=1.2113628889117705 starId=-1337270441 frame=true at=0,0,0 + body 4187637_602139_-2869399 4187637_602139_-2869399 kind=ROGUE_PLANET orbit=0 radius=1.7454789147745489 starId=-636290765 frame=true at=0,0,0 + body 976378_3636775_4086471 976378_3636775_4086471 kind=ROGUE_PLANET orbit=0 radius=0.6852144335756171 starId=-1484690657 frame=true at=0,0,0 + derived -2557100_423979_3637445 -2557100_423979_3637445 type=superearth mass=4.944099558821902 radius=1.4814447373908066 gravity=225 pressure=0 tempK=43 oxygen=false locked=false rings=false rotation=14161 metallicity=0.7675045838421682 terrain=TerrainOption[NATIVE genType=0 w=1] + derived -2655791_3860139_4556832 -2655791_3860139_4556832 type=ice mass=8.953986377182925 radius=1.9480617919050078 gravity=236 pressure=0 tempK=43 oxygen=false locked=false rings=false rotation=14966 metallicity=1.023458230313111 terrain=TerrainOption[NATIVE genType=0 w=1] + derived -2673416_-3299924_-2542369 -2673416_-3299924_-2542369 type=barren mass=0.08742513278618706 radius=0.5140134458397283 gravity=33 pressure=0 tempK=27 oxygen=false locked=false rings=false rotation=37319 metallicity=0.5432850188533043 terrain=TerrainOption[NATIVE genType=0 w=1] + derived -3057060_4504688_903368 -3057060_4504688_903368 type=ice mass=0.6907407385519565 radius=0.9265150238967861 gravity=80 pressure=0 tempK=33 oxygen=false locked=false rings=false rotation=24780 metallicity=1.4771347200268488 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 1017499_549209_-2760817 1017499_549209_-2760817 type=superearth mass=6.720000591465987 radius=1.6175224369918324 gravity=257 pressure=0 tempK=44 oxygen=false locked=false rings=false rotation=25973 metallicity=1.5348057666632275 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 198207_-2510563_-2496053 198207_-2510563_-2496053 type=ice mass=1.8782374156749306 radius=1.1591832445693329 gravity=140 pressure=0 tempK=38 oxygen=false locked=false rings=false rotation=81388 metallicity=1.5252855058290513 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 3639726_-2990703_-2920715 3639726_-2990703_-2920715 type=ice mass=1.4281056559244625 radius=1.1642707970044215 gravity=105 pressure=0 tempK=35 oxygen=false locked=false rings=false rotation=30927 metallicity=0.43414167394403486 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 3823355_3745237_320026 3823355_3745237_320026 type=ice mass=2.3245028760180175 radius=1.2113628889117705 gravity=158 pressure=0 tempK=39 oxygen=false locked=false rings=false rotation=34397 metallicity=1.4633582267369367 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 4187637_602139_-2869399 4187637_602139_-2869399 type=ice mass=8.033586448333969 radius=1.7454789147745489 gravity=264 pressure=0 tempK=45 oxygen=false locked=false rings=false rotation=12742 metallicity=0.8463677000016181 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 976378_3636775_4086471 976378_3636775_4086471 type=ice mass=0.23914383202211043 radius=0.6852144335756171 gravity=51 pressure=0 tempK=30 oxygen=false locked=false rings=false rotation=25305 metallicity=0.4173546565671391 terrain=TerrainOption[NATIVE genType=0 w=1] + system -2557100_423979_3637445 id=-1134337153 kind=ROGUE_PLANET name=PGR--3525313.0.3525313 starless + system -2655791_3860139_4556832 id=-157245369 kind=ROGUE_PLANET name=PGR--3525313.3525313.3525313 starless + system -2673416_-3299924_-2542369 id=-457888649 kind=ROGUE_PLANET name=PGR--3525313.-3525313.-3525313 starless + system -3057060_4504688_903368 id=-392700849 kind=ROGUE_PLANET name=PGR--3525313.3525313.0 starless + system 1017499_549209_-2760817 id=-268045669 kind=ROGUE_PLANET name=PGR-0.0.-3525313 starless + system 198207_-2510563_-2496053 id=-914263305 kind=ROGUE_PLANET name=PGR-0.-3525313.-3525313 starless + system 3639726_-2990703_-2920715 id=-640125417 kind=ROGUE_PLANET name=PGR-3525313.-3525313.-3525313 starless + system 3823355_3745237_320026 id=-1337270441 kind=ROGUE_PLANET name=PGR-3525313.3525313.0 starless + system 4187637_602139_-2869399 id=-636290765 kind=ROGUE_PLANET name=PGR-3525313.0.-3525313 starless + system 976378_3636775_4086471 id=-1484690657 kind=ROGUE_PLANET name=PGR-0.3525313.3525313 starless +seed 42 systems=5 + body -2447011_3990067_1015768 -2447011_3990067_1015768 kind=ROGUE_PLANET orbit=0 radius=1.989945249401805 starId=-303078433 frame=true at=0,0,0 + body -3084373_3721958_-2836759 -3084373_3721958_-2836759 kind=MOON orbit=0 radius=1.2391946808283263 starId=-179570697 frame=false at=150517,0,136807 + body -3084373_3721958_-2836759 -3084373_3721958_-2836759 kind=ROGUE_PLANET orbit=0 radius=0.6685266599064654 starId=-179570697 frame=true at=0,0,0 + body 1035243_295080_-2679253 1035243_295080_-2679253 kind=ROGUE_PLANET orbit=0 radius=1.3473679621970385 starId=-1652144877 frame=true at=0,0,0 + body 190640_4091718_-3216585 190640_4091718_-3216585 kind=ROGUE_PLANET orbit=0 radius=1.538054696275251 starId=-502853333 frame=true at=0,0,0 + body 3842895_743694_4157756 3842895_743694_4157756 kind=MOON orbit=0 radius=0.6284105220560577 starId=-1165534741 frame=false at=-112346,0,-34758 + body 3842895_743694_4157756 3842895_743694_4157756 kind=ROGUE_PLANET orbit=0 radius=0.5198799197186048 starId=-1165534741 frame=true at=0,0,0 + derived -2447011_3990067_1015768 -2447011_3990067_1015768 type=superearth mass=10.088778310024445 radius=1.989945249401805 gravity=255 pressure=0 tempK=44 oxygen=false locked=false rings=false rotation=79920 metallicity=1.5777697067277474 terrain=TerrainOption[NATIVE genType=0 w=1] + derived -3084373_3721958_-2836759 -3084373_3721958_-2836759 type=barren mass=0.24881424393731585 radius=0.6685266599064654 gravity=56 pressure=0 tempK=30 oxygen=false locked=false rings=false rotation=47156 metallicity=0.871880601372064 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 1035243_295080_-2679253 1035243_295080_-2679253 type=ice mass=2.4573812328128533 radius=1.3473679621970385 gravity=135 pressure=0 tempK=38 oxygen=false locked=false rings=false rotation=40099 metallicity=1.5785510692143445 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 190640_4091718_-3216585 190640_4091718_-3216585 type=superearth mass=4.8180484411980276 radius=1.538054696275251 gravity=204 pressure=0 tempK=42 oxygen=false locked=false rings=false rotation=19264 metallicity=0.7186063878043278 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 3842895_743694_4157756 3842895_743694_4157756 type=barren mass=0.08318382388923339 radius=0.5198799197186048 gravity=31 pressure=0 tempK=26 oxygen=false locked=false rings=false rotation=70969 metallicity=0.6776328297933261 terrain=TerrainOption[NATIVE genType=0 w=1] + system -2447011_3990067_1015768 id=-303078433 kind=ROGUE_PLANET name=PGR--3525313.3525313.0 starless + system -3084373_3721958_-2836759 id=-179570697 kind=ROGUE_PLANET name=PGR--3525313.3525313.-3525313 starless + system 1035243_295080_-2679253 id=-1652144877 kind=ROGUE_PLANET name=PGR-0.0.-3525313 starless + system 190640_4091718_-3216585 id=-502853333 kind=ROGUE_PLANET name=PGR-0.3525313.-3525313 starless + system 3842895_743694_4157756 id=-1165534741 kind=ROGUE_PLANET name=PGR-3525313.0.3525313 starless +seed 1337 systems=3 + body 276785_671277_3636639 276785_671277_3636639 kind=MOON orbit=0 radius=0.2956238533945368 starId=-1715483833 frame=false at=-372883,0,19642 + body 276785_671277_3636639 276785_671277_3636639 kind=MOON orbit=0 radius=0.30796799570160793 starId=-1715483833 frame=false at=175063,0,31076 + body 276785_671277_3636639 276785_671277_3636639 kind=ROGUE_PLANET orbit=0 radius=1.3493157557301982 starId=-1715483833 frame=true at=0,0,0 + body 4120657_566754_604545 4120657_566754_604545 kind=ROGUE_PLANET orbit=0 radius=0.31963922617475793 starId=-847248597 frame=true at=0,0,0 + body 4597503_3784885_-3111897 4597503_3784885_-3111897 kind=ROGUE_PLANET orbit=0 radius=1.0444056015522982 starId=-1405460665 frame=true at=0,0,0 + derived 276785_671277_3636639 276785_671277_3636639 type=superearth mass=3.437098370037941 radius=1.3493157557301982 gravity=189 pressure=0 tempK=41 oxygen=false locked=false rings=false rotation=15830 metallicity=0.7860854234824994 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 4120657_566754_604545 4120657_566754_604545 type=barren mass=0.015365480193628647 radius=0.31963922617475793 gravity=15 pressure=0 tempK=22 oxygen=false locked=false rings=false rotation=32523 metallicity=0.7510293998351121 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 4597503_3784885_-3111897 4597503_3784885_-3111897 type=ice mass=1.0972317554809465 radius=1.0444056015522982 gravity=101 pressure=0 tempK=35 oxygen=false locked=false rings=false rotation=36365 metallicity=1.0545794786678782 terrain=TerrainOption[NATIVE genType=0 w=1] + system 276785_671277_3636639 id=-1715483833 kind=ROGUE_PLANET name=PGR-0.0.3525313 starless + system 4120657_566754_604545 id=-847248597 kind=ROGUE_PLANET name=PGR-3525313.0.0 starless + system 4597503_3784885_-3111897 id=-1405460665 kind=ROGUE_PLANET name=PGR-3525313.3525313.-3525313 starless +seed 8675309 systems=2 + body 1046546_-2903040_725654 1046546_-2903040_725654 kind=ROGUE_PLANET orbit=0 radius=0.6470070511607549 starId=-1628626657 frame=true at=0,0,0 + body 1063728_-2564249_-3016086 1063728_-2564249_-3016086 kind=ROGUE_PLANET orbit=0 radius=1.935067436969281 starId=-193170121 frame=true at=0,0,0 + derived 1046546_-2903040_725654 1046546_-2903040_725654 type=barren mass=0.15646924852335253 radius=0.6470070511607549 gravity=37 pressure=0 tempK=27 oxygen=false locked=false rings=false rotation=27503 metallicity=0.9774911484366974 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 1063728_-2564249_-3016086 1063728_-2564249_-3016086 type=ice mass=9.74459618037882 radius=1.935067436969281 gravity=260 pressure=0 tempK=44 oxygen=false locked=false rings=false rotation=7049 metallicity=1.075249972161227 terrain=TerrainOption[NATIVE genType=0 w=1] + system 1046546_-2903040_725654 id=-1628626657 kind=ROGUE_PLANET name=PGR-0.-3525313.0 starless + system 1063728_-2564249_-3016086 id=-193170121 kind=ROGUE_PLANET name=PGR-0.-3525313.-3525313 starless +seed -1 systems=8 + body -2782949_-3335311_-3172048 -2782949_-3335311_-3172048 kind=MOON orbit=0 radius=0.3811324069014488 starId=-304935909 frame=false at=-222003,0,-267730 + body -2782949_-3335311_-3172048 -2782949_-3335311_-3172048 kind=MOON orbit=0 radius=1.7827041269401591 starId=-304935909 frame=false at=-186393,0,-19400 + body -2782949_-3335311_-3172048 -2782949_-3335311_-3172048 kind=ROGUE_PLANET orbit=0 radius=1.2158739257305007 starId=-304935909 frame=true at=0,0,0 + body -3287314_3807297_3941148 -3287314_3807297_3941148 kind=ROGUE_PLANET orbit=0 radius=0.33955082250013896 starId=-1160817649 frame=true at=0,0,0 + body 3664834_3776565_4532932 3664834_3776565_4532932 kind=MOON orbit=0 radius=0.5519257469023342 starId=-284738901 frame=false at=56749,0,-135821 + body 3664834_3776565_4532932 3664834_3776565_4532932 kind=MOON orbit=0 radius=2.3292049800084142 starId=-284738901 frame=false at=79151,0,113533 + body 3664834_3776565_4532932 3664834_3776565_4532932 kind=ROGUE_PLANET orbit=0 radius=0.6082699126970779 starId=-284738901 frame=true at=0,0,0 + body 3853702_543117_1039757 3853702_543117_1039757 kind=ROGUE_PLANET orbit=0 radius=0.9898277293392106 starId=-589964249 frame=true at=0,0,0 + body 4483478_-2850483_3653003 4483478_-2850483_3653003 kind=MOON orbit=0 radius=1.7411223799459588 starId=-1321358033 frame=false at=382344,0,106874 + body 4483478_-2850483_3653003 4483478_-2850483_3653003 kind=ROGUE_PLANET orbit=0 radius=1.435001513340223 starId=-1321358033 frame=true at=0,0,0 + body 743192_-3039588_157466 743192_-3039588_157466 kind=MOON orbit=0 radius=1.8375370381187128 starId=-1078102009 frame=false at=-34099,0,10903 + body 743192_-3039588_157466 743192_-3039588_157466 kind=ROGUE_PLANET orbit=0 radius=0.4112854086051495 starId=-1078102009 frame=true at=0,0,0 + body 878581_-3017002_4553300 872959_-3017002_4556800 kind=STAR orbit=35417 radius=75.04819331288338 starId=-1897301650 frame=true at=0,0,0 + body 878581_-3017002_4553300 878553_-3017004_4553343 kind=PLANET orbit=276 radius=1.5157966496043178 starId=-1897301649 frame=true at=0,0,0 + body 878581_-3017002_4553300 878566_-3017002_4553315 kind=MOON orbit=116 radius=0.20652907168649953 starId=-1897301649 frame=false at=339405,0,282827 + body 878581_-3017002_4553300 878566_-3017002_4553315 kind=MOON orbit=116 radius=0.5437577907459678 starId=-1897301649 frame=false at=511462,0,-30996 + body 878581_-3017002_4553300 878566_-3017002_4553315 kind=PLANET orbit=116 radius=1.9962282827789826 starId=-1897301649 frame=true at=0,0,0 + body 878581_-3017002_4553300 878572_-3017003_4553306 kind=PLANET orbit=59 radius=0.3484083052264197 starId=-1897301649 frame=true at=0,0,0 + body 878581_-3017002_4553300 878575_-3017002_4553288 kind=GAS_GIANT orbit=70 radius=9.106319267554383 starId=-1897301649 frame=true at=0,0,0 + body 878581_-3017002_4553300 878578_-3017002_4553300 kind=ASTEROID_BELT orbit=15 radius=0.0 starId=-1897301649 frame=true at=0,0,0 + body 878581_-3017002_4553300 878580_-3017002_4553299 kind=PLANET orbit=9 radius=0.7705187789339893 starId=-1897301649 frame=true at=0,0,0 + body 878581_-3017002_4553300 878580_-3017002_4553302 kind=PLANET orbit=15 radius=0.7882028558055201 starId=-1897301649 frame=true at=0,0,0 + body 878581_-3017002_4553300 878581_-3017002_4553299 kind=MOON orbit=6 radius=0.3285151555828062 starId=-1897301649 frame=false at=-7978,0,19857 + body 878581_-3017002_4553300 878581_-3017002_4553299 kind=PLANET orbit=6 radius=0.21883050688087927 starId=-1897301649 frame=true at=0,0,0 + body 878581_-3017002_4553300 878581_-3017002_4553300 kind=STAR orbit=0 radius=0.0 starId=-1897301649 frame=true at=0,0,0 + body 878581_-3017002_4553300 878584_-3017002_4553302 kind=MOON orbit=19 radius=0.2091041602077615 starId=-1897301649 frame=false at=-57606,0,22378 + body 878581_-3017002_4553300 878584_-3017002_4553302 kind=PLANET orbit=19 radius=0.6020719857479297 starId=-1897301649 frame=true at=0,0,0 + body 878581_-3017002_4553300 878586_-3017002_4553298 kind=GAS_GIANT orbit=27 radius=4.402691577407306 starId=-1897301649 frame=true at=0,0,0 + body 878581_-3017002_4553300 878588_-3017002_4553300 kind=MOON orbit=39 radius=0.3351591521971312 starId=-1897301649 frame=false at=93945,0,15410 + body 878581_-3017002_4553300 878588_-3017002_4553300 kind=MOON orbit=39 radius=0.6094488945358825 starId=-1897301649 frame=false at=62749,0,-33221 + body 878581_-3017002_4553300 878588_-3017002_4553300 kind=PLANET orbit=39 radius=0.3946638194579618 starId=-1897301649 frame=true at=0,0,0 + body 878581_-3017002_4553300 878597_-3017003_4553325 kind=GAS_GIANT orbit=160 radius=6.614858175508893 starId=-1897301649 frame=true at=0,0,0 + body 878581_-3017002_4553300 878597_-3017003_4553325 kind=MOON orbit=160 radius=0.6525910316854073 starId=-1897301649 frame=false at=-1396614,0,1139032 + body 878581_-3017002_4553300 878612_-3017003_4553330 kind=GAS_GIANT orbit=230 radius=3.2383609634026165 starId=-1897301649 frame=true at=0,0,0 + body 878581_-3017002_4553300 878644_-3017001_4553348 kind=MOON orbit=424 radius=0.23115588832734993 starId=-1897301649 frame=false at=-57990,0,-77757 + body 878581_-3017002_4553300 878644_-3017001_4553348 kind=PLANET orbit=424 radius=0.3567889137917456 starId=-1897301649 frame=true at=0,0,0 + body 878581_-3017002_4553300 878680_-3017002_4553221 kind=ASTEROID_BELT orbit=678 radius=0.0 starId=-1897301649 frame=true at=0,0,0 + body 905332_3909756_4056715 905332_3909756_4056715 kind=ROGUE_PLANET orbit=0 radius=1.6232022956018095 starId=-1020620017 frame=true at=0,0,0 + derived -2782949_-3335311_-3172048 -2782949_-3335311_-3172048 type=ice mass=2.1148586853111633 radius=1.2158739257305007 gravity=143 pressure=0 tempK=38 oxygen=false locked=false rings=false rotation=20246 metallicity=0.38296507979539846 terrain=TerrainOption[NATIVE genType=0 w=1] + derived -3287314_3807297_3941148 -3287314_3807297_3941148 type=barren mass=0.022449370251015673 radius=0.33955082250013896 gravity=19 pressure=0 tempK=23 oxygen=false locked=false rings=false rotation=10256 metallicity=0.5725117772717905 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 3664834_3776565_4532932 3664834_3776565_4532932 type=barren mass=0.1517947540551749 radius=0.6082699126970779 gravity=41 pressure=0 tempK=28 oxygen=false locked=false rings=false rotation=6065 metallicity=0.5186924118210909 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 3853702_543117_1039757 3853702_543117_1039757 type=barren mass=0.8458563337067997 radius=0.9898277293392106 gravity=86 pressure=0 tempK=34 oxygen=false locked=false rings=false rotation=18539 metallicity=1.0208698050614735 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 4483478_-2850483_3653003 4483478_-2850483_3653003 type=ice mass=3.0464390892959123 radius=1.435001513340223 gravity=148 pressure=0 tempK=39 oxygen=false locked=false rings=false rotation=13841 metallicity=1.3324770895609348 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 743192_-3039588_157466 743192_-3039588_157466 type=barren mass=0.033909515805476215 radius=0.4112854086051495 gravity=20 pressure=0 tempK=23 oxygen=false locked=false rings=false rotation=26070 metallicity=1.586073172708923 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 878581_-3017002_4553300 872959_-3017002_4556800 type=superearth mass=3.75245081793383 radius=1.3770899672024957 gravity=198 pressure=1600 tempK=11 oxygen=false locked=false rings=false rotation=19857 metallicity=0.8668625908386669 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 878581_-3017002_4553300 878553_-3017004_4553343 type=ice mass=5.754990119461963 radius=1.5157966496043178 gravity=250 pressure=1600 tempK=101 oxygen=false locked=false rings=false rotation=79184 metallicity=0.8668625908386669 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 878581_-3017002_4553300 878566_-3017002_4553315 type=ice mass=9.764724238485714 radius=1.9962282827789826 gravity=245 pressure=1600 tempK=156 oxygen=false locked=false rings=false rotation=70365 metallicity=0.8668625908386669 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 878581_-3017002_4553300 878572_-3017003_4553306 type=ice mass=0.016668815448320475 radius=0.3484083052264197 gravity=14 pressure=1 tempK=97 oxygen=false locked=false rings=false rotation=17983 metallicity=0.8668625908386669 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 878581_-3017002_4553300 878575_-3017002_4553288 type=icegiant mass=205.92664840109282 radius=9.106319267554383 gravity=248 pressure=1600 tempK=213 oxygen=false locked=false rings=true rotation=9791 metallicity=0.8668625908386669 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 878581_-3017002_4553300 878578_-3017002_4553300 type=desert mass=0.10615272231334011 radius=0.5866299417116883 gravity=31 pressure=3 tempK=222 oxygen=false locked=true rings=false rotation=73625 metallicity=0.8668625908386669 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 878581_-3017002_4553300 878580_-3017002_4553299 type=barren mass=0.3390944455898112 radius=0.7705187789339893 gravity=57 pressure=13 tempK=304 oxygen=false locked=true rings=false rotation=83680 metallicity=0.8668625908386669 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 878581_-3017002_4553300 878580_-3017002_4553302 type=ice mass=0.3704474492415404 radius=0.7882028558055201 gravity=60 pressure=30 tempK=193 oxygen=false locked=true rings=false rotation=85552 metallicity=0.8668625908386669 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 878581_-3017002_4553300 878581_-3017002_4553299 type=barren mass=0.004279089534781598 radius=0.21883050688087927 gravity=9 pressure=0 tempK=372 oxygen=false locked=true rings=false rotation=50818 metallicity=0.8668625908386669 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 878581_-3017002_4553300 878581_-3017002_4553300 type=barren mass=0.003697376618150787 radius=0.2099118806935753 gravity=8 pressure=0 tempK=912 oxygen=false locked=true rings=false rotation=50445 metallicity=0.8668625908386669 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 878581_-3017002_4553300 878584_-3017002_4553302 type=ice mass=0.1570807560599777 radius=0.6020719857479297 gravity=43 pressure=18 tempK=171 oxygen=false locked=true rings=false rotation=74322 metallicity=0.8668625908386669 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 878581_-3017002_4553300 878586_-3017002_4553298 type=gasgiant mass=38.705791121690105 radius=4.402691577407306 gravity=200 pressure=1600 tempK=343 oxygen=false locked=false rings=true rotation=12478 metallicity=0.8668625908386669 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 878581_-3017002_4553300 878588_-3017002_4553300 type=barren mass=0.03288029815145879 radius=0.3946638194579618 gravity=21 pressure=1 tempK=146 oxygen=false locked=true rings=false rotation=25710 metallicity=0.8668625908386669 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 878581_-3017002_4553300 878597_-3017003_4553325 type=gasgiant mass=98.72364455770969 radius=6.614858175508893 gravity=226 pressure=1600 tempK=140 oxygen=false locked=false rings=true rotation=7309 metallicity=0.8668625908386669 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 878581_-3017002_4553300 878612_-3017003_4553330 type=gasgiant mass=19.09730271296885 radius=3.2383609634026165 gravity=182 pressure=1600 tempK=117 oxygen=false locked=false rings=false rotation=11030 metallicity=0.8668625908386669 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 878581_-3017002_4553300 878644_-3017001_4553348 type=barren mass=0.026873364146008688 radius=0.3567889137917456 gravity=21 pressure=11 tempK=44 oxygen=false locked=false rings=false rotation=31938 metallicity=0.8668625908386669 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 878581_-3017002_4553300 878680_-3017002_4553221 type=superearth mass=24.824960233297325 radius=2.3661964095752266 gravity=400 pressure=1600 tempK=74 oxygen=false locked=false rings=false rotation=90978 metallicity=0.8668625908386669 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 905332_3909756_4056715 905332_3909756_4056715 type=superearth mass=7.049954839078062 radius=1.6232022956018095 gravity=268 pressure=0 tempK=45 oxygen=false locked=false rings=false rotation=9498 metallicity=0.7464841711558176 terrain=TerrainOption[NATIVE genType=0 w=1] + system -2782949_-3335311_-3172048 id=-304935909 kind=ROGUE_PLANET name=PGR--3525313.-3525313.-3525313 starless + system -3287314_3807297_3941148 id=-1160817649 kind=ROGUE_PLANET name=PGR--3525313.3525313.3525313 starless + system 3664834_3776565_4532932 id=-284738901 kind=ROGUE_PLANET name=PGR-3525313.3525313.3525313 starless + system 3853702_543117_1039757 id=-589964249 kind=ROGUE_PLANET name=PGR-3525313.0.0 starless + system 4483478_-2850483_3653003 id=-1321358033 kind=ROGUE_PLANET name=PGR-3525313.-3525313.3525313 starless + system 743192_-3039588_157466 id=-1078102009 kind=ROGUE_PLANET name=PGR-0.-3525313.0 starless + system 878581_-3017002_4553300 id=-1897301649 kind=STAR name=PGS-0.-3525313.3525313 starTemp=40 starSize=0.709514319896698 + system 905332_3909756_4056715 id=-1020620017 kind=ROGUE_PLANET name=PGR-0.3525313.3525313 starless +seed 6942069 systems=4 + body -2734142_4401718_4309207 -2734142_4401718_4309207 kind=ROGUE_PLANET orbit=0 radius=0.43631553719843885 starId=-66168209 frame=true at=0,0,0 + body -2908371_-2556424_3786953 -2908371_-2556424_3786953 kind=ROGUE_PLANET orbit=0 radius=1.908019290563573 starId=-264498101 frame=true at=0,0,0 + body 744853_3857094_4340709 744853_3857094_4340709 kind=MOON orbit=0 radius=0.31342515786159897 starId=-1566341957 frame=false at=2946252,0,117650 + body 744853_3857094_4340709 744853_3857094_4340709 kind=ROGUE_PLANET orbit=0 radius=10.804886830349613 starId=-1566341957 frame=true at=0,0,0 + body 748976_106008_-3408393 748976_106008_-3408393 kind=ROGUE_PLANET orbit=0 radius=2.171011837467088 starId=-333451093 frame=true at=0,0,0 + derived -2734142_4401718_4309207 -2734142_4401718_4309207 type=barren mass=0.0410412584139839 radius=0.43631553719843885 gravity=22 pressure=0 tempK=24 oxygen=false locked=false rings=false rotation=18269 metallicity=0.4876802064323611 terrain=TerrainOption[NATIVE genType=0 w=1] + derived -2908371_-2556424_3786953 -2908371_-2556424_3786953 type=superearth mass=12.982415793011713 radius=1.908019290563573 gravity=357 pressure=0 tempK=48 oxygen=false locked=false rings=false rotation=71846 metallicity=0.5583921546780021 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 744853_3857094_4340709 744853_3857094_4340709 type=icegiant mass=305.1760558389291 radius=10.804886830349613 gravity=261 pressure=1600 tempK=45 oxygen=false locked=false rings=true rotation=9642 metallicity=0.5678204702000748 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 748976_106008_-3408393 748976_106008_-3408393 type=ice mass=15.421880528808861 radius=2.171011837467088 gravity=327 pressure=0 tempK=47 oxygen=false locked=false rings=false rotation=39235 metallicity=0.3854381084175446 terrain=TerrainOption[NATIVE genType=0 w=1] + system -2734142_4401718_4309207 id=-66168209 kind=ROGUE_PLANET name=PGR--3525313.3525313.3525313 starless + system -2908371_-2556424_3786953 id=-264498101 kind=ROGUE_PLANET name=PGR--3525313.-3525313.3525313 starless + system 744853_3857094_4340709 id=-1566341957 kind=ROGUE_PLANET name=PGR-0.3525313.3525313 starless + system 748976_106008_-3408393 id=-333451093 kind=ROGUE_PLANET name=PGR-0.0.-3525313 starless +seed 2147483647 systems=9 + body -2493723_-2839512_-3212741 -2493723_-2839512_-3212741 kind=MOON orbit=0 radius=0.33837544643821515 starId=-207345417 frame=false at=-47705,0,-10277 + body -2493723_-2839512_-3212741 -2493723_-2839512_-3212741 kind=MOON orbit=0 radius=0.5074630002826466 starId=-207345417 frame=false at=-38969,0,5810 + body -2493723_-2839512_-3212741 -2493723_-2839512_-3212741 kind=ROGUE_PLANET orbit=0 radius=0.338498394763068 starId=-207345417 frame=true at=0,0,0 + body -2742838_660938_3979190 -2742838_660938_3979190 kind=ROGUE_PLANET orbit=0 radius=0.22137561323537092 starId=-455553521 frame=true at=0,0,0 + body 3834728_104915_3754932 3834728_104915_3754932 kind=MOON orbit=0 radius=2.2091575932064966 starId=-940407273 frame=false at=-26561,0,431984 + body 3834728_104915_3754932 3834728_104915_3754932 kind=ROGUE_PLANET orbit=0 radius=1.5925684061535028 starId=-940407273 frame=true at=0,0,0 + body 4037900_4371065_-3063872 4037900_4371065_-3063872 kind=ROGUE_PLANET orbit=0 radius=2.093359451025224 starId=-657806589 frame=true at=0,0,0 + body 4348408_-3060116_-3223142 4348408_-3060116_-3223142 kind=MOON orbit=0 radius=2.3843255919435293 starId=-1865164581 frame=false at=129914,0,87799 + body 4348408_-3060116_-3223142 4348408_-3060116_-3223142 kind=ROGUE_PLANET orbit=0 radius=1.7114977812892795 starId=-1865164581 frame=true at=0,0,0 + body 712998_-3013860_-3318391 712998_-3013860_-3318391 kind=MOON orbit=0 radius=2.095818752703184 starId=-1420637497 frame=false at=9595,0,81437 + body 712998_-3013860_-3318391 712998_-3013860_-3318391 kind=MOON orbit=0 radius=6.667876582430962 starId=-1420637497 frame=false at=87182,0,-11969 + body 712998_-3013860_-3318391 712998_-3013860_-3318391 kind=ROGUE_PLANET orbit=0 radius=0.5936672169388442 starId=-1420637497 frame=true at=0,0,0 + body 718851_3779944_691719 718851_3779944_691719 kind=MOON orbit=0 radius=1.5472070306152543 starId=-1442192965 frame=false at=100219,0,-18977 + body 718851_3779944_691719 718851_3779944_691719 kind=MOON orbit=0 radius=2.113264014264038 starId=-1442192965 frame=false at=64429,0,-28867 + body 718851_3779944_691719 718851_3779944_691719 kind=ROGUE_PLANET orbit=0 radius=0.3838652235975247 starId=-1442192965 frame=true at=0,0,0 + body 744632_4318880_-2812679 744632_4318880_-2812679 kind=MOON orbit=0 radius=4.676773652233241 starId=-965124545 frame=false at=116755,0,318473 + body 744632_4318880_-2812679 744632_4318880_-2812679 kind=ROGUE_PLANET orbit=0 radius=2.280305265104321 starId=-965124545 frame=true at=0,0,0 + body 974479_672038_-3200167 974479_672038_-3200167 kind=ROGUE_PLANET orbit=0 radius=0.2751076136224251 starId=-1701373473 frame=true at=0,0,0 + derived -2493723_-2839512_-3212741 -2493723_-2839512_-3212741 type=barren mass=0.014821975902813578 radius=0.338498394763068 gravity=13 pressure=0 tempK=21 oxygen=false locked=false rings=false rotation=63594 metallicity=0.404360462100807 terrain=TerrainOption[NATIVE genType=0 w=1] + derived -2742838_660938_3979190 -2742838_660938_3979190 type=ice mass=0.004697374280323576 radius=0.22137561323537092 gravity=10 pressure=0 tempK=19 oxygen=false locked=false rings=false rotation=22932 metallicity=0.7697090492061979 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 3834728_104915_3754932 3834728_104915_3754932 type=ice mass=5.40917540606095 radius=1.5925684061535028 gravity=213 pressure=0 tempK=42 oxygen=false locked=false rings=false rotation=37513 metallicity=0.6300762958523322 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 4037900_4371065_-3063872 4037900_4371065_-3063872 type=ice mass=15.897400529235586 radius=2.093359451025224 gravity=363 pressure=0 tempK=48 oxygen=false locked=false rings=false rotation=14555 metallicity=0.4155887210628324 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 4348408_-3060116_-3223142 4348408_-3060116_-3223142 type=superearth mass=5.782581482149673 radius=1.7114977812892795 gravity=197 pressure=0 tempK=41 oxygen=false locked=false rings=false rotation=13613 metallicity=1.0113311064379111 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 712998_-3013860_-3318391 712998_-3013860_-3318391 type=ice mass=0.18065730676369665 radius=0.5936672169388442 gravity=51 pressure=0 tempK=30 oxygen=false locked=false rings=false rotation=57108 metallicity=0.37057061467784547 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 718851_3779944_691719 718851_3779944_691719 type=barren mass=0.023920205370259073 radius=0.3838652235975247 gravity=16 pressure=0 tempK=22 oxygen=false locked=false rings=false rotation=22790 metallicity=0.9000540929487959 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 744632_4318880_-2812679 744632_4318880_-2812679 type=ice mass=18.458429998067526 radius=2.280305265104321 gravity=355 pressure=0 tempK=48 oxygen=false locked=false rings=false rotation=20045 metallicity=0.8219291577052881 terrain=TerrainOption[NATIVE genType=0 w=1] + derived 974479_672038_-3200167 974479_672038_-3200167 type=barren mass=0.009298637248549268 radius=0.2751076136224251 gravity=12 pressure=0 tempK=21 oxygen=false locked=false rings=false rotation=30428 metallicity=1.5915795954966696 terrain=TerrainOption[NATIVE genType=0 w=1] + system -2493723_-2839512_-3212741 id=-207345417 kind=ROGUE_PLANET name=PGR--3525313.-3525313.-3525313 starless + system -2742838_660938_3979190 id=-455553521 kind=ROGUE_PLANET name=PGR--3525313.0.3525313 starless + system 3834728_104915_3754932 id=-940407273 kind=ROGUE_PLANET name=PGR-3525313.0.3525313 starless + system 4037900_4371065_-3063872 id=-657806589 kind=ROGUE_PLANET name=PGR-3525313.3525313.-3525313 starless + system 4348408_-3060116_-3223142 id=-1865164581 kind=ROGUE_PLANET name=PGR-3525313.-3525313.-3525313 starless + system 712998_-3013860_-3318391 id=-1420637497 kind=ROGUE_PLANET name=PGR-0.-3525313.-3525313 starless + system 718851_3779944_691719 id=-1442192965 kind=ROGUE_PLANET name=PGR-0.3525313.0 starless + system 744632_4318880_-2812679 id=-965124545 kind=ROGUE_PLANET name=PGR-0.3525313.-3525313 starless + system 974479_672038_-3200167 id=-1701373473 kind=ROGUE_PLANET name=PGR-0.0.-3525313 starless 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 1b2e86f5e..255c47d65 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 a4b90cafe..33bbceee6 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 @@ -512,6 +512,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); diff --git a/valkyrienskies/src/main/java/org/valkyrienskies/mod/common/ships/chunk_claims/ShipChunkAllocator.java b/valkyrienskies/src/main/java/org/valkyrienskies/mod/common/ships/chunk_claims/ShipChunkAllocator.java index a749deecd..d74197e4a 100644 --- a/valkyrienskies/src/main/java/org/valkyrienskies/mod/common/ships/chunk_claims/ShipChunkAllocator.java +++ b/valkyrienskies/src/main/java/org/valkyrienskies/mod/common/ships/chunk_claims/ShipChunkAllocator.java @@ -27,7 +27,23 @@ public class ShipChunkAllocator { */ public static final int MAX_CHUNK_LENGTH = 3200; // Who even really cares tbh public static final int MAX_CHUNK_RADIUS = (MAX_CHUNK_LENGTH / 2) - 1; - public static final int CHUNK_X_START = 320000; + /** + * Where the reserved shipyard begins, in chunks. Raised from upstream's 320000 (block X + * 5 094 416 once {@link #MAX_CHUNK_RADIUS} is taken off) to 1 200 000 (block X 19 174 416), + * because {@link #isChunkInShipyard} is what a teleport into the region is silently cancelled + * by — so this constant, not anything in vanilla, is the wall that bounds how far a ship may be + * posed from the origin. It is paired with {@code GalacticCoord.CELL}: a 16M half-cell needs + * clearance to 16M plus room to manoeuvre, and this leaves 3.17M of it. + * + *

    Note the asymmetry the move does NOT fix: the predicate is a half-PLANE, so it reserves the + * whole quadrant out to the world edge while the allocator only ever walks a strip in +Z.

    + * + *

    Timing. The allocator's cursor ({@code lastChunkX}/{@code lastChunkZ}) is SERIALIZED + * into the world, and this constant is not — so a world created before this change restores the + * old cursor and keeps allocating outside the new predicate. The move therefore has to land + * before the release ships, not merely "sometime under the clean break".

    + */ + public static final int CHUNK_X_START = 1200000; public static final int CHUNK_Z_START = 0; private int lastChunkX = CHUNK_X_START; private int lastChunkZ = CHUNK_Z_START;