diff --git a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java
index 42a218f96..67aae2d58 100644
--- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java
+++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java
@@ -2540,14 +2540,57 @@ private void handleTelescope(MinecraftServer server, ICommandSender sender, Stri
zmaster587.advancedRocketry.api.AdvancedRocketryItems.itemMemoryCrystal);
// A deliberately BLANK crystal: the starter addresses would make every count a test
// asserts depend on the world's planet list rather than on what the scan resolved.
- zmaster587.advancedRocketry.item.ItemMemoryCrystal.writeMemory(stack,
- new zmaster587.advancedRocketry.navigation.CrystalMemory());
+ zmaster587.advancedRocketry.navigation.CrystalMemory seeded =
+ new zmaster587.advancedRocketry.navigation.CrystalMemory();
+ // ...unless a caller names ONE world it must hold. A test that needs the deposit to be
+ // the only possible source of a piece of knowledge cannot use a crystal the survey
+ // filled, because the survey teaches this world as it goes.
+ if (args.length >= 6) {
+ int namedDim = parseIntOr(args[5], zmaster587.advancedRocketry.api.Constants.INVALID_PLANET);
+ if (namedDim != zmaster587.advancedRocketry.api.Constants.INVALID_PLANET) {
+ seeded.record(new zmaster587.advancedRocketry.navigation.CrystalEntry(
+ zmaster587.advancedRocketry.space.GalacticCoord.ofSectorLocal(
+ 7000L, 0L, 0L, 0L, 0L, 0L),
+ "probe-named-" + namedDim,
+ zmaster587.advancedRocketry.universe.SystemBodyKind.PLANET,
+ zmaster587.advancedRocketry.universe.InfoTier.TELESCOPE, 1L, namedDim));
+ }
+ }
+ zmaster587.advancedRocketry.item.ItemMemoryCrystal.writeMemory(stack, seeded);
scope.setInventorySlotContents(
zmaster587.advancedRocketry.tile.multiblock.TileObservatory.SLOT_CRYSTAL, stack);
send(sender, "{\"ok\":true,\"addresses\":" + crystalAddresses(scope) + "}");
return;
}
+ if ("deposit".equalsIgnoreCase(verb)) {
+ // The Deposit button's own path: read the crystal in the machine into what THIS world
+ // knows. Reported as landed/total because an address of a world nobody has landed on has
+ // nothing for tier-1 to fly to and is skipped.
+ java.util.List before = new java.util.ArrayList<>();
+ for (zmaster587.advancedRocketry.navigation.CrystalEntry entry
+ : zmaster587.advancedRocketry.item.ItemMemoryCrystal.memoryOf(
+ scope.getStackInSlot(
+ zmaster587.advancedRocketry.tile.multiblock.TileObservatory
+ .SLOT_CRYSTAL)).list()) {
+ if (entry.namesBody()) {
+ before.add(entry.dimId());
+ }
+ }
+ int[] result = scope.uploadCrystalHere();
+ StringBuilder dims = new StringBuilder("[");
+ for (int i = 0; i < before.size(); i++) {
+ if (i > 0) dims.append(',');
+ dims.append(before.get(i));
+ }
+ dims.append(']');
+ // The dims are reported, not just the count: a test that could only read "3 landed" would
+ // have to guess WHICH worlds a pad here may now be aimed at.
+ send(sender, "{\"ok\":true,\"landed\":" + result[0] + ",\"total\":" + result[1]
+ + ",\"dims\":" + dims + "}");
+ return;
+ }
+
if ("abort".equalsIgnoreCase(verb)) {
boolean stopped = scope.abortRegionScan();
send(sender, "{\"ok\":" + stopped + telescopeScanFields(scope, scope.getActiveScan(), world) + "}");
@@ -2630,6 +2673,10 @@ private String telescopeScanFields(zmaster587.advancedRocketry.tile.multiblock.T
.append(",\"origin\":").append(origin == null ? "null" : "\"" + origin.cellKey() + "\"")
.append(",\"scanning\":").append(scan != null)
.append(",\"addresses\":").append(crystalAddresses(scope))
+ // WHICH worlds the crystal holds, read without touching anything. A test that had to
+ // call `deposit` to find out would have deposited them, and could no longer show
+ // that pressing the button is what teaches this world.
+ .append(",\"crystalDims\":").append(crystalDims(scope))
.append(",\"lastDiscoveries\":").append(scope.getLastScanDiscoveries())
// Where the OPERATOR has the instrument pointed — the tile's own pick, which is what
// a GUI click changes and what the next scan will use. Distinct from the region a
@@ -2694,6 +2741,29 @@ private int crystalAddresses(zmaster587.advancedRocketry.tile.multiblock.TileObs
return zmaster587.advancedRocketry.item.ItemMemoryCrystal.memoryOf(stack).size();
}
+ /** The dimensions the crystal in that slot names, as a JSON array. Reads nothing into anything. */
+ private String crystalDims(zmaster587.advancedRocketry.tile.multiblock.TileObservatory scope) {
+ net.minecraft.item.ItemStack stack = scope.getStackInSlot(
+ zmaster587.advancedRocketry.tile.multiblock.TileObservatory.SLOT_CRYSTAL);
+ if (!zmaster587.advancedRocketry.item.ItemMemoryCrystal.isCrystal(stack)) {
+ return "[]";
+ }
+ StringBuilder out = new StringBuilder("[");
+ boolean first = true;
+ for (zmaster587.advancedRocketry.navigation.CrystalEntry entry
+ : zmaster587.advancedRocketry.item.ItemMemoryCrystal.memoryOf(stack).list()) {
+ if (!entry.namesBody()) {
+ continue;
+ }
+ if (!first) {
+ out.append(',');
+ }
+ out.append(entry.dimId());
+ first = false;
+ }
+ return out.append(']').toString();
+ }
+
private zmaster587.advancedRocketry.tile.multiblock.TileObservatory observatoryAt(
net.minecraft.world.World world, BlockPos pos) {
net.minecraft.tileentity.TileEntity te = world.getTileEntity(pos);
@@ -5230,7 +5300,21 @@ private void handleSpace(MinecraftServer server, ICommandSender sender, String[]
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);
+ // A cell names a family - a planet and the moons that share its address - so the probe
+ // states WHICH of them it means. Default 0, the planet, with an optional variant arg;
+ // a caller that wants the moon has to say so, exactly as a descent does.
+ int variant = args.length >= 5 ? parseIntOr(args[4], 0) : 0;
+ java.util.List family =
+ zmaster587.advancedRocketry.universe.UniverseRegistry.get(server) == null
+ ? java.util.Collections.emptyList()
+ : zmaster587.advancedRocketry.universe.UniverseRegistry.get(server)
+ .realizableBodiesAt(cell);
+ if (variant < 0 || variant >= family.size()) {
+ send(sender, "{\"ok\":false,\"reason\":\"no body with that variant in the cell\"}");
+ return;
+ }
+ int dimId = zmaster587.advancedRocketry.universe.PlanetRealizer.realize(server,
+ family.get(variant));
if (dimId == zmaster587.advancedRocketry.api.Constants.INVALID_PLANET) {
send(sender, "{\"ok\":false,\"reason\":\"nothing landable in that cell\"}");
return;
@@ -5993,6 +6077,36 @@ private void handleDim(ICommandSender sender, String[] args) {
// Planet/weather probes ----------------------------------------------
private void handlePlanet(ICommandSender sender, String[] args) {
+ // What a TIER-1 launch pad standing on one world may be aimed at, asked of the production
+ // gate rather than re-derived here: a real rocket in the standing world answers
+ // IPlanetDefiner.isPlanetKnown, and the two halves are reported beside it so a red test says
+ // WHICH of them moved - the pack's floor or what this body has learned.
+ if (args.length >= 3 && "knowledge".equalsIgnoreCase(args[0])) {
+ int standingDim = parseIntOr(args[1], Integer.MIN_VALUE);
+ int targetDim = parseIntOr(args[2], Integer.MIN_VALUE);
+ net.minecraft.world.World here = net.minecraftforge.common.DimensionManager
+ .getWorld(standingDim);
+ DimensionProperties target = DimensionManager.getInstance()
+ .getDimensionPropertiesOrNull(targetDim);
+ DimensionProperties standing = DimensionManager.getInstance()
+ .getDimensionPropertiesOrNull(standingDim);
+ if (here == null || target == null) {
+ send(sender, "{\"error\":\"standing world not loaded or unknown target\"}");
+ return;
+ }
+ zmaster587.advancedRocketry.entity.EntityRocket rocket =
+ new zmaster587.advancedRocketry.entity.EntityRocket(here);
+ send(sender, "{\"standing\":" + standingDim + ",\"target\":" + targetDim
+ + ",\"known\":" + rocket.isPlanetKnown(target)
+ + ",\"global\":" + DimensionManager.getInstance().isPlanetKnown(targetDim)
+ + ",\"local\":" + (standing != null && standing.isPlanetKnownHere(targetDim))
+ + ",\"research\":"
+ + zmaster587.advancedRocketry.api.ARConfiguration.getCurrentConfig()
+ .planetsMustBeDiscovered
+ + "}");
+ return;
+ }
+
if (args.length >= 2 && "info".equalsIgnoreCase(args[0])) {
int dim = parseIntOr(args[1], Integer.MIN_VALUE);
DimensionProperties props = DimensionManager.getInstance().getDimensionProperties(dim);
diff --git a/src/main/java/zmaster587/advancedRocketry/dimension/DimensionProperties.java b/src/main/java/zmaster587/advancedRocketry/dimension/DimensionProperties.java
index 9305ed61d..95e971a01 100644
--- a/src/main/java/zmaster587/advancedRocketry/dimension/DimensionProperties.java
+++ b/src/main/java/zmaster587/advancedRocketry/dimension/DimensionProperties.java
@@ -767,11 +767,64 @@ public void setHasRings(boolean value) {
//Adds a beacon location to the planet's surface
public void addBeaconLocation(World world, HashedBlockPosition pos) {
beaconLocations.add(pos);
- DimensionManager.getInstance().knownPlanets.add(getId());
//LAAZZY
- if (!world.isRemote)
+ if (!world.isRemote) {
+ for (DimensionProperties taught : teachOwnSystem()) {
+ PacketHandler.sendToAll(new PacketDimInfo(taught.getId(), taught));
+ }
PacketHandler.sendToAll(new PacketDimInfo(getId(), this));
+ }
+ }
+
+ /**
+ * Tell the bodies of this body's own system that this place exists, and return the ones that
+ * did not already know.
+ *
+ * A beacon is a local announcement, not a galactic one. It used to add this planet to
+ * the GLOBAL known-set, so planting one anywhere made the place selectable from every launch pad
+ * in the game. What is true is narrower and more interesting: the neighbours know, because they
+ * can see it. So the beacon writes into the known-set of every body of its own system, and
+ * nothing outside that system learns anything.
+ *
+ * It can never be the thing that first reveals a place - a beacon is planted by hand, so
+ * somebody already flew here, which means the destination was already reachable. That is why
+ * scoping it costs nothing: a beacon spreads knowledge inside reach and never creates reach.
+ */
+ public List teachOwnSystem() {
+ List taught = new ArrayList<>();
+ discoverPlanet(getId()); // the body it stands on, always - the degenerate system of one
+ StellarBody star = getStar();
+ if (star == null) {
+ return taught; // a world with no star of its own: the beacon teaches only its own ground
+ }
+ for (IDimensionProperties sibling : star.getPlanets()) {
+ DimensionProperties props = DimensionManager.getInstance()
+ .getDimensionPropertiesOrNull(sibling.getId());
+ if (props == null) {
+ continue;
+ }
+ teach(props, taught);
+ // A moon is a child of its planet rather than of the star, so the star's list alone
+ // would leave every moon of the system ignorant of a beacon in it.
+ for (int childId : props.getChildPlanets()) {
+ DimensionProperties child = DimensionManager.getInstance()
+ .getDimensionPropertiesOrNull(childId);
+ if (child != null) {
+ teach(child, taught);
+ }
+ }
+ }
+ return taught;
+ }
+
+ /** Teach {@code body} about this place, collecting it when that changed anything. */
+ private void teach(DimensionProperties body, List taught) {
+ if (body.getId() == getId() || body.isPlanetKnownHere(getId())) {
+ return;
+ }
+ body.discoverPlanet(getId());
+ taught.add(body);
}
public HashSet getBeacons() {
@@ -1692,7 +1745,10 @@ private void readFromTechnicalNBT(NBTTagCompound nbt) {
int[] location = list.getIntArrayAt(i);
beaconLocations.add(new HashedBlockPosition(location[0], location[1], location[2]));
}
- DimensionManager.getInstance().knownPlanets.add(getId());
+ // No global add on load any more. What a beacon taught is held by the bodies it taught,
+ // in their own saved known-sets, so re-announcing this place to the whole game at every
+ // load would put back exactly the reach the scoping removed. A world whose beacons
+ // predate the local sets simply has nothing recorded - 3.0.0 carries no old saves.
} else
beaconLocations.clear();
@@ -1734,6 +1790,13 @@ public void readFromNBT(NBTTagCompound nbt) {
NBTTagList list;
+ // Cleared first: this object is reused across loads, and a merge would make a body remember
+ // what a previous save taught it.
+ locallyKnownPlanets.clear();
+ for (int dimId : nbt.getIntArray("locallyKnownPlanets")) {
+ locallyKnownPlanets.add(dimId);
+ }
+
if (nbt.hasKey("skyColor")) {
list = nbt.getTagList("skyColor", NBT.TAG_FLOAT);
skyColor = new float[list.tagCount()];
@@ -2197,9 +2260,50 @@ public void write_terraforming_data(NBTTagCompound nbt) {
}
+ /**
+ * What is known ON this body: the planets a launch pad standing here may be aimed at, beyond the
+ * ones everybody knows.
+ *
+ * Knowledge belongs to a place. An observatory built here teaches THIS body; a beacon
+ * teaches the bodies of its own system; a memory crystal uploaded here deposits what somebody
+ * carried in. None of that reaches the global set, and none of it reaches a neighbouring world -
+ * a launch pad on a moon offers a different list than the pad on the planet below it.
+ *
+ * It is ADDITIVE over the global set rather than a replacement for it, so a pack that authors
+ * {@code } keeps authoring exactly as it did: the global set is the floor everyone
+ * stands on, this is what a particular world has learned since.
+ *
+ * Communal per world, not per player: two players on the same body see the same list.
+ */
+ private final Set locallyKnownPlanets = new HashSet<>();
+
+ /** Teach this body about {@code dimId}. Idempotent. */
+ public void discoverPlanet(int dimId) {
+ locallyKnownPlanets.add(dimId);
+ }
+
+ /** Whether THIS body knows {@code dimId} - the local half of the gate, with no global fallback. */
+ public boolean isPlanetKnownHere(int dimId) {
+ return locallyKnownPlanets.contains(dimId);
+ }
+
+ /** What this body knows, for readers that need the whole set (GUI, sync, tests). */
+ public Set getLocallyKnownPlanets() {
+ return Collections.unmodifiableSet(locallyKnownPlanets);
+ }
+
public void writeToNBT(NBTTagCompound nbt) {
NBTTagList list;
+ if (!locallyKnownPlanets.isEmpty()) {
+ int[] known = new int[locallyKnownPlanets.size()];
+ int k = 0;
+ for (int dimId : locallyKnownPlanets) {
+ known[k++] = dimId;
+ }
+ nbt.setIntArray("locallyKnownPlanets", known);
+ }
+
if (skyColor != null) {
list = new NBTTagList();
for (float f : skyColor) {
diff --git a/src/main/java/zmaster587/advancedRocketry/entity/EntityRocket.java b/src/main/java/zmaster587/advancedRocketry/entity/EntityRocket.java
index 46cce49a1..fec72e461 100644
--- a/src/main/java/zmaster587/advancedRocketry/entity/EntityRocket.java
+++ b/src/main/java/zmaster587/advancedRocketry/entity/EntityRocket.java
@@ -3852,7 +3852,20 @@ public LinkedList getConnectedInfrastructure() {
@Override
public boolean isPlanetKnown(IDimensionProperties properties) {
- return !ARConfiguration.getCurrentConfig().planetsMustBeDiscovered || DimensionManager.getInstance().isPlanetKnown(properties.getId());
+ if (!ARConfiguration.getCurrentConfig().planetsMustBeDiscovered) {
+ return true;
+ }
+ int target = properties.getId();
+ // The global set is the FLOOR - what a pack authored as known, plus dim 0. Everything past it
+ // is learned by a particular world, so the second question is asked of the body this rocket
+ // is standing on and not of the game.
+ if (DimensionManager.getInstance().isPlanetKnown(target)) {
+ return true;
+ }
+ DimensionProperties here = world == null
+ ? null
+ : DimensionManager.getInstance().getDimensionPropertiesOrNull(world.provider.getDimension());
+ return here != null && here.isPlanetKnownHere(target);
}
@Override
diff --git a/src/main/java/zmaster587/advancedRocketry/tile/TileAdvancedFlightComputer.java b/src/main/java/zmaster587/advancedRocketry/tile/TileAdvancedFlightComputer.java
index 7cb80fc1a..918681b3b 100644
--- a/src/main/java/zmaster587/advancedRocketry/tile/TileAdvancedFlightComputer.java
+++ b/src/main/java/zmaster587/advancedRocketry/tile/TileAdvancedFlightComputer.java
@@ -594,8 +594,10 @@ public void update() {
// descend. The scan above must never allocate a dimension.
int targetDim = body.dimId();
if (targetDim == zmaster587.advancedRocketry.api.Constants.INVALID_PLANET) {
+ // The BODY, not its cell: a moon shares its planet's address, so a
+ // cell names a family and only the body says which of them was flown to.
targetDim = zmaster587.advancedRocketry.universe.PlanetRealizer
- .realize(server, body.name());
+ .realize(server, body);
if (targetDim
== zmaster587.advancedRocketry.api.Constants.INVALID_PLANET) {
continue; // nothing landable here after all
diff --git a/src/main/java/zmaster587/advancedRocketry/tile/multiblock/TileObservatory.java b/src/main/java/zmaster587/advancedRocketry/tile/multiblock/TileObservatory.java
index 190661e7a..223bca4dc 100644
--- a/src/main/java/zmaster587/advancedRocketry/tile/multiblock/TileObservatory.java
+++ b/src/main/java/zmaster587/advancedRocketry/tile/multiblock/TileObservatory.java
@@ -1,5 +1,10 @@
package zmaster587.advancedRocketry.tile.multiblock;
+import java.util.HashSet;
+import java.util.Set;
+import zmaster587.advancedRocketry.dimension.DimensionManager;
+import zmaster587.advancedRocketry.dimension.DimensionProperties;
+import zmaster587.advancedRocketry.network.PacketDimInfo;
import io.netty.buffer.ByteBuf;
import net.minecraft.block.Block;
import net.minecraft.block.state.IBlockState;
@@ -25,6 +30,7 @@
import zmaster587.advancedRocketry.item.IDataItem;
import zmaster587.advancedRocketry.item.ItemAsteroidChip;
import zmaster587.advancedRocketry.item.ItemMemoryCrystal;
+import zmaster587.advancedRocketry.navigation.CrystalEntry;
import zmaster587.advancedRocketry.space.GalacticCoord;
import zmaster587.advancedRocketry.tile.hatch.TileDataBus;
import zmaster587.advancedRocketry.universe.RegionScan;
@@ -112,6 +118,7 @@ public class TileObservatory extends TileMultiPowerConsumer implements IModularI
private static final byte ABORT_SCAN = 20;
private static final byte PASSIVE_SWEEP = 21;
private static final byte TOGGLE_WHOLE_SYSTEM = 22;
+ private static final byte UPLOAD_CRYSTAL = 23;
/** Progress id of the region-scan bar; the machine's own bar keeps id 0. */
private static final int PROGRESS_SCAN = 1;
/**
@@ -743,6 +750,12 @@ public List getModules(int ID, EntityPlayer player) {
zmaster587.libVulpes.inventory.TextureResources.buttonBuild,
LibVulpes.proxy.getLocalizedString("msg.observetory.scan.abort.tooltip"), 40, 18));
}
+ // Reading a crystal INTO this world, the reverse of the survey that fills one. It costs
+ // no power and no data: the knowledge already exists, it is being put down here.
+ modules.add(new ModuleButton(100, 142, 10,
+ LibVulpes.proxy.getLocalizedString("msg.observetory.upload.button"), this,
+ zmaster587.libVulpes.inventory.TextureResources.buttonBuild,
+ LibVulpes.proxy.getLocalizedString("msg.observetory.upload.tooltip"), 64, 18));
modules.add(new ModuleButton(166, 42, 8,
LibVulpes.proxy.getLocalizedString(passive
? "msg.observetory.scan.mode.passive" : "msg.observetory.scan.mode.active"),
@@ -901,6 +914,35 @@ public boolean beginPassiveSweep() {
return true;
}
+ /**
+ * Teach the world this observatory stands on what the survey just made out.
+ *
+ * Server side only, and only what has a dimension. The set is a body's own, additive over the
+ * global known-set rather than a replacement for it, so a pack that authored its known planets
+ * keeps them and a world merely adds what it has learned since. Syncing goes through the same
+ * channel a beacon uses, because this is the same kind of fact.
+ */
+ private void teachThisBody(Set discovered) {
+ if (discovered.isEmpty() || world == null || world.isRemote) {
+ return;
+ }
+ DimensionProperties here = DimensionManager.getInstance()
+ .getDimensionPropertiesOrNull(world.provider.getDimension());
+ if (here == null) {
+ return; // a world the planet layer does not own - nothing here can learn
+ }
+ boolean learned = false;
+ for (int dimId : discovered) {
+ if (!here.isPlanetKnownHere(dimId)) {
+ here.discoverPlanet(dimId);
+ learned = true;
+ }
+ }
+ if (learned) {
+ PacketHandler.sendToAll(new PacketDimInfo(here.getId(), here));
+ }
+ }
+
/** The observation in flight, or {@code null}. */
@Nullable
public RegionScan getActiveScan() {
@@ -1083,9 +1125,15 @@ private void completeRegionScanIfDue() {
GalacticCoord origin = scanOrigin();
UniverseRegistry registry = UniverseRegistry.get(world);
lastScanObscured += countObscured(registry, origin, activeScan, activeScan.cellsDone(), cells);
+ // WHERE the instrument stands is what it teaches. A survey writes the crystal the operator
+ // will carry away, and it also teaches the body underneath: a launch pad here may afterwards
+ // be aimed at what this telescope made out, while a pad on the next world may not. Only a
+ // NAMED body can be taught - a bare address has no world to fly to.
+ Set taughtHere = new HashSet<>();
lastScanDiscoveries += TelescopeScan.resolveBatch(registry, activeScan,
activeScan.cellsDone(), cells, crystal, now, TelescopeScan.dimensionNames(), origin,
- characteriseWholeSystem);
+ characteriseWholeSystem, taughtHere::add);
+ teachThisBody(taughtHere);
activeScan = instant ? activeScan.completed(now) : activeScan.advanced(now, cells);
if (activeScan.isComplete()) {
activeScan = null;
@@ -1146,6 +1194,41 @@ public void onInventoryButtonPressed(int buttonId) {
if (buttonId == 9) {
PacketHandler.sendToServer(new PacketMachine(this, TOGGLE_WHOLE_SYSTEM));
}
+ if (buttonId == 10) {
+ PacketHandler.sendToServer(new PacketMachine(this, UPLOAD_CRYSTAL));
+ }
+ }
+
+ /**
+ * Read the crystal in the machine into the KNOWLEDGE OF THIS BODY: what somebody carried here
+ * becomes what a launch pad standing here may be aimed at.
+ *
+ * This is the one sanctioned crossing between the two discovery systems, and it goes one way.
+ * A crystal deposits into a body's set; a body's set never feeds a crystal, a console, or a jump
+ * target.
+ *
+ * An address without a world is skipped, and the count says so. A procedural body
+ * carries no dimension until somebody descends to it, so a survey of unvisited space writes
+ * addresses that tier-1 has nothing to fly to yet - and an upload that silently did nothing
+ * would be indistinguishable from a broken button. Re-surveying such a system after somebody has
+ * landed there records the world it now has.
+ *
+ * @return {@code {landed, total}}
+ */
+ public int[] uploadCrystalHere() {
+ ItemStack crystal = getStackInSlot(SLOT_CRYSTAL);
+ if (!ItemMemoryCrystal.isCrystal(crystal)) {
+ return new int[] {0, 0};
+ }
+ List entries = ItemMemoryCrystal.memoryOf(crystal).list();
+ Set landed = new HashSet<>();
+ for (CrystalEntry entry : entries) {
+ if (entry.namesBody()) {
+ landed.add(entry.dimId());
+ }
+ }
+ teachThisBody(landed);
+ return new int[] {landed.size(), entries.size()};
}
@@ -1219,6 +1302,11 @@ else if (id == TOGGLE_WHOLE_SYSTEM) {
player.openGui(LibVulpes.instance, GuiHandler.guiId.MODULARNOINV.ordinal(),
getWorld(), pos.getX(), pos.getY(), pos.getZ());
}
+ else if (id == UPLOAD_CRYSTAL) {
+ int[] result = uploadCrystalHere();
+ player.sendMessage(new net.minecraft.util.text.TextComponentTranslation(
+ "msg.observetory.upload.result", result[0], result[1]));
+ }
else if (id == START_SCAN || id == ABORT_SCAN || id == PASSIVE_SWEEP) {
if (id == ABORT_SCAN) {
abortRegionScan();
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/PlanetRealizer.java b/src/main/java/zmaster587/advancedRocketry/universe/PlanetRealizer.java
index e90aba161..553bc3625 100644
--- a/src/main/java/zmaster587/advancedRocketry/universe/PlanetRealizer.java
+++ b/src/main/java/zmaster587/advancedRocketry/universe/PlanetRealizer.java
@@ -66,20 +66,27 @@ private PlanetRealizer() {
* 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) {
+ public static int realize(MinecraftServer server, SystemBody approached) {
+ if (server == null || approached == null) {
return Constants.INVALID_PLANET;
}
UniverseRegistry registry = UniverseRegistry.get(server);
if (registry == null) {
return Constants.INVALID_PLANET;
}
+ GalacticCoord bodyCell = approached.name();
// 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);
+ OptionalInt variantOpt = registry.variantOf(approached);
+ if (!variantOpt.isPresent()) {
+ return Constants.INVALID_PLANET; // not a body of that cell, or nothing landable
+ }
+ int variant = variantOpt.getAsInt();
+
+ OptionalInt existing = registry.realizedDimAt(bodyCell, variant);
if (existing.isPresent()) {
return existing.getAsInt();
}
@@ -90,33 +97,26 @@ public static int realize(MinecraftServer server, GalacticCoord bodyCell) {
}
GalacticCoord anchor = anchorOpt.get();
- List here = registry.bodiesAt(bodyCell);
- SystemBody target = null;
+ List here = registry.realizableBodiesAt(bodyCell);
+ if (variant >= here.size()) {
+ return Constants.INVALID_PLANET;
+ }
+ SystemBody target = here.get(variant);
+ if (!target.kind().canDescend() || target.dimId() != Constants.INVALID_PLANET) {
+ return Constants.INVALID_PLANET;
+ }
+ // The parent a moon hangs off: the first NON-moon of the same cell. A moon shares its
+ // parent's cell by construction, so the family is right here.
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) {
+ if (body.kind() != SystemBodyKind.MOON) {
parentBody = body;
+ break;
}
- // 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) {
+ // A moon whose parent is not in its own cell cannot be built: the family is what gives it its
+ // star, its orbit and its sky, and by construction the parent is always here.
+ if (parentBody == null && target.kind() == SystemBodyKind.MOON) {
return Constants.INVALID_PLANET;
}
@@ -153,7 +153,7 @@ public static int realize(MinecraftServer server, GalacticCoord bodyCell) {
return Constants.INVALID_PLANET;
}
star.addPlanet(props);
- if (!registry.realizeBody(bodyCell, dimId)) {
+ if (!registry.realizeBody(bodyCell, variant, 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;
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/TelescopeScan.java b/src/main/java/zmaster587/advancedRocketry/universe/TelescopeScan.java
index 904d3d02d..3e969bef7 100644
--- a/src/main/java/zmaster587/advancedRocketry/universe/TelescopeScan.java
+++ b/src/main/java/zmaster587/advancedRocketry/universe/TelescopeScan.java
@@ -4,6 +4,7 @@
import java.util.Collections;
import java.util.List;
import java.util.Optional;
+import java.util.function.IntConsumer;
import java.util.function.IntFunction;
import net.minecraft.item.ItemStack;
@@ -231,6 +232,20 @@ public static List detect(UniverseRegistry registry, GalacticCoord lo
public static int characterise(UniverseRegistry registry, Detection hit, CrystalMemory memory,
long observedTick, IntFunction nameOf,
boolean wholeSystem) {
+ return characterise(registry, hit, memory, observedTick, nameOf, wholeSystem, null);
+ }
+
+ /**
+ * The same, telling {@code named} the dimension of every body this look actually made out.
+ *
+ * The callback is a fact about the OBSERVATION - "this look named that world" - and nothing
+ * more; what a caller does with it belongs to the caller. It fires only for a body that has a
+ * dimension: an unrealized body has no world to fly to, and an address that names no body
+ * (unresolvable, obscured, or positions-only) reports nothing at all.
+ */
+ public static int characterise(UniverseRegistry registry, Detection hit, CrystalMemory memory,
+ long observedTick, IntFunction nameOf,
+ boolean wholeSystem, IntConsumer named) {
if (registry == null || hit == null || memory == null) {
return 0;
}
@@ -244,6 +259,9 @@ public static int characterise(UniverseRegistry registry, Detection hit, Crystal
if (memory.record(entryFor(body, observedTick, nameOf))) {
written++;
}
+ if (named != null && body.dimId() != Constants.INVALID_PLANET) {
+ named.accept(body.dimId());
+ }
}
}
if (!namedSomething) {
@@ -269,12 +287,20 @@ public static int resolveBatch(UniverseRegistry registry, RegionScan scan, int f
public static int resolveBatch(UniverseRegistry registry, RegionScan scan, int from, int count,
ItemStack crystal, long observedTick, IntFunction nameOf,
GalacticCoord observer, boolean wholeSystem) {
+ return resolveBatch(registry, scan, from, count, crystal, observedTick, nameOf, observer,
+ wholeSystem, null);
+ }
+
+ /** The same, reporting every body the batch named to {@code named}. */
+ public static int resolveBatch(UniverseRegistry registry, RegionScan scan, int from, int count,
+ ItemStack crystal, long observedTick, IntFunction nameOf,
+ GalacticCoord observer, boolean wholeSystem, IntConsumer named) {
if (!ItemMemoryCrystal.isCrystal(crystal)) {
return 0;
}
CrystalMemory memory = ItemMemoryCrystal.memoryOf(crystal);
int written = resolveBatch(registry, scan, from, count, memory, observedTick, nameOf,
- observer, wholeSystem);
+ observer, wholeSystem, named);
if (written > 0) {
ItemMemoryCrystal.writeMemory(crystal, memory);
}
@@ -294,6 +320,14 @@ public static int resolveBatch(UniverseRegistry registry, RegionScan scan, int f
public static int resolveBatch(UniverseRegistry registry, RegionScan scan, int from, int count,
CrystalMemory memory, long observedTick, IntFunction nameOf,
GalacticCoord observer, boolean wholeSystem) {
+ return resolveBatch(registry, scan, from, count, memory, observedTick, nameOf, observer,
+ wholeSystem, null);
+ }
+
+ /** The same, reporting every body the batch named to {@code named}. */
+ public static int resolveBatch(UniverseRegistry registry, RegionScan scan, int from, int count,
+ CrystalMemory memory, long observedTick, IntFunction nameOf,
+ GalacticCoord observer, boolean wholeSystem, IntConsumer named) {
if (registry == null || scan == null || memory == null) {
return 0;
}
@@ -301,7 +335,7 @@ public static int resolveBatch(UniverseRegistry registry, RegionScan scan, int f
int written = 0;
for (int index = from; index < from + count && index < scan.totalCells(); index++) {
written += resolveLook(registry, scan.cellAt(index), memory, observedTick, nameOf,
- observer, limit, wholeSystem);
+ observer, limit, wholeSystem, named);
}
return written;
}
@@ -320,9 +354,18 @@ public static int resolveLook(UniverseRegistry registry, GalacticCoord look, Cry
long observedTick, IntFunction nameOf,
GalacticCoord observer, double limitMagnitude,
boolean wholeSystem) {
+ return resolveLook(registry, look, memory, observedTick, nameOf, observer, limitMagnitude,
+ wholeSystem, null);
+ }
+
+ /** The same, reporting every body this look named to {@code named}. */
+ public static int resolveLook(UniverseRegistry registry, GalacticCoord look, CrystalMemory memory,
+ long observedTick, IntFunction nameOf,
+ GalacticCoord observer, double limitMagnitude,
+ boolean wholeSystem, IntConsumer named) {
int written = 0;
for (Detection hit : detect(registry, look, observer, limitMagnitude)) {
- written += characterise(registry, hit, memory, observedTick, nameOf, wholeSystem);
+ written += characterise(registry, hit, memory, observedTick, nameOf, wholeSystem, named);
}
return written;
}
diff --git a/src/main/java/zmaster587/advancedRocketry/universe/UniverseRegistry.java b/src/main/java/zmaster587/advancedRocketry/universe/UniverseRegistry.java
index 35552efc3..d1c6c4df9 100644
--- a/src/main/java/zmaster587/advancedRocketry/universe/UniverseRegistry.java
+++ b/src/main/java/zmaster587/advancedRocketry/universe/UniverseRegistry.java
@@ -792,7 +792,7 @@ public Optional starAt(GalacticCoord coord) {
* 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) {
+ public boolean realizeBody(GalacticCoord bodyCell, int variant, int dimId) {
Optional anchorOpt = anchorForCell(bodyCell);
if (!anchorOpt.isPresent()) {
return false;
@@ -802,16 +802,23 @@ public boolean realizeBody(GalacticCoord bodyCell, int dimId) {
return false;
}
GalacticCoord cell = bodyCell.cellCentre();
+ int seen = -1;
for (int i = 0; i < pinned.bodies.size(); i++) {
SystemBody body = pinned.bodies.get(i);
- if (!body.kind().canDescend() || !body.name().sameCell(cell)) {
+ if (!body.name().sameCell(cell) || !isRealizableKind(body)) {
+ continue;
+ }
+ // Counted the same way realizableBodiesAt counts, so a variant means one body and not a
+ // family: writing into "the first free one" is what let a moon inherit its planet.
+ seen++;
+ if (seen != variant) {
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
+ return false; // this body already holds a different world
}
pinned.bodies.set(i, body.withDimId(dimId));
namesByDim.put(dimId, new RecordedName(cell, pinned.starId));
@@ -821,16 +828,78 @@ public boolean realizeBody(GalacticCoord bodyCell, int dimId) {
return false;
}
- /** The realized dimension of the descend-target body at {@code bodyCell}, if it has one. */
- public OptionalInt realizedDimAt(GalacticCoord bodyCell) {
+ /**
+ * The bodies of {@code bodyCell} that a descent could ever mint a world for, in the order the
+ * generator produced them.
+ *
+ * A cell holds more than one world, and this is the list that says which. A moon is
+ * built in its PARENT's cell so that a planet and its moons travel as one destination, so
+ * "the body at this cell" names a family rather than an object. A body's index in THIS list is
+ * its {@code variant} - the same number the derivation is keyed on - and it is the only identity
+ * a body has inside its cell.
+ *
+ * Stars, station slots and belts are not in it: nothing descends onto them, and counting them
+ * would shift every variant by one and silently materialize the wrong world.
+ */
+ public List realizableBodiesAt(GalacticCoord bodyCell) {
+ List out = new ArrayList<>();
for (SystemBody body : bodiesAt(bodyCell)) {
- if (body.kind().canDescend() && body.dimId() != Constants.INVALID_PLANET) {
- return OptionalInt.of(body.dimId());
+ if (isRealizableKind(body)) {
+ out.add(body);
+ }
+ }
+ return out;
+ }
+
+ /** Whether a descent could mint a world for a body of this kind. See {@link #realizableBodiesAt}. */
+ private static boolean isRealizableKind(SystemBody body) {
+ return body.kind() != SystemBodyKind.STAR
+ && body.kind() != SystemBodyKind.STATION_SLOT
+ && body.kind() != SystemBodyKind.ASTEROID_BELT;
+ }
+
+ /**
+ * Which body of its cell {@code body} is - its {@code variant} - or empty if the cell does not
+ * hold it.
+ *
+ * Matched by ADDRESS, KIND and ORBIT rather than by object identity: a caller holds a body it
+ * got from a derived list, while the pinned snapshot holds another instance of the same body,
+ * and a realized one differs from both by carrying a dimension.
+ */
+ public OptionalInt variantOf(SystemBody body) {
+ if (body == null) {
+ return OptionalInt.empty();
+ }
+ List family = realizableBodiesAt(body.name());
+ for (int i = 0; i < family.size(); i++) {
+ SystemBody candidate = family.get(i);
+ if (candidate.kind() == body.kind()
+ && candidate.orbitalDistance() == body.orbitalDistance()
+ && candidate.name().sameCell(body.name())) {
+ return OptionalInt.of(i);
}
}
return OptionalInt.empty();
}
+ /**
+ * The realized dimension of a PARTICULAR body of {@code bodyCell}, named by its variant.
+ *
+ * It used to answer for the first realized body of the cell, whatever was asked - so once a
+ * planet had a world, every one of its moons answered with the planet's, and a descent aimed at a
+ * moon put the ship on the planet instead.
+ */
+ public OptionalInt realizedDimAt(GalacticCoord bodyCell, int variant) {
+ List family = realizableBodiesAt(bodyCell);
+ if (variant < 0 || variant >= family.size()) {
+ return OptionalInt.empty();
+ }
+ SystemBody body = family.get(variant);
+ return body.dimId() == Constants.INVALID_PLANET
+ ? OptionalInt.empty()
+ : OptionalInt.of(body.dimId());
+ }
+
/** The POIs at a system's cell (a copy), excluding the derived star/planet/moon bodies. */
public List poisAt(GalacticCoord systemCoord) {
List list = poiOverrides.get(systemCoord.cellCentre().cellKey());
diff --git a/src/main/resources/assets/advancedrocketry/lang/en_US.lang b/src/main/resources/assets/advancedrocketry/lang/en_US.lang
index a7972f2b3..b9d8ede19 100644
--- a/src/main/resources/assets/advancedrocketry/lang/en_US.lang
+++ b/src/main/resources/assets/advancedrocketry/lang/en_US.lang
@@ -430,6 +430,9 @@ 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.upload.button=Deposit
+msg.observetory.upload.tooltip=Read the crystal into what is known ON THIS WORLD, so a launch pad here can be aimed at it. An address of a world nobody has landed on yet has nothing to fly to and is skipped.
+msg.observetory.upload.result=Deposited %s of %s addresses into local knowledge.
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.
diff --git a/src/main/resources/assets/advancedrocketry/lang/ru_RU.lang b/src/main/resources/assets/advancedrocketry/lang/ru_RU.lang
index 859323e53..4872a73fb 100644
--- a/src/main/resources/assets/advancedrocketry/lang/ru_RU.lang
+++ b/src/main/resources/assets/advancedrocketry/lang/ru_RU.lang
@@ -263,6 +263,9 @@ msg.observetory.scan.obscured=Мешает пыль — только коорд
msg.observetory.scan.idle=Простаивает
msg.observetory.scan.abort=Стоп
msg.observetory.scan.abort.tooltip=Прервать обзор. Всё, что уже разрешено, уже лежит в кристалле.
+msg.observetory.upload.button=Выгрузить
+msg.observetory.upload.tooltip=Прочитать кристалл в то, что известно НА ЭТОМ МИРЕ, чтобы отсюда мог целиться пусковой стол. Адрес мира, на который ещё никто не садился, лететь некуда — он пропускается.
+msg.observetory.upload.result=В местное знание внесено адресов: %s из %s.
msg.observetory.scan.mode.active=Даль
msg.observetory.scan.mode.passive=Округа
msg.observetory.scan.mode.tooltip=Округа наблюдает соседние ячейки и держит данные наготове; даль смотрит на выбранную далёкую область. Одно за раз — инструмент, вглядывающийся в глубокий космос, не видит того, что рядом.
diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/ObservatoryDepositButtonE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/ObservatoryDepositButtonE2ETest.java
new file mode 100644
index 000000000..6342e9e9e
--- /dev/null
+++ b/src/test/java/zmaster587/advancedRocketry/test/client/ObservatoryDepositButtonE2ETest.java
@@ -0,0 +1,118 @@
+package zmaster587.advancedRocketry.test.client;
+
+import org.junit.FixMethodOrder;
+import org.junit.Test;
+import org.junit.runners.MethodSorters;
+
+import static org.junit.Assert.assertTrue;
+import static zmaster587.advancedRocketry.test.client.ClientGuiTestSupport.openGuiByRightClick;
+
+/**
+ * The one human act in the tier-1/tier-2 knowledge loop: a player standing at an observatory presses
+ * Deposit, and the world he is standing on learns the addresses on the crystal in the machine.
+ *
+ * The server tier already pins the path from the button's handler onwards. What it structurally
+ * cannot pin is the CLICK: that the control exists on the survey tab, that it is enabled, and that
+ * pressing it reaches the tile. That gap is a client e2e and not a playtest, so it lives here.
+ *
+ * The assertion is deliberately made on the SERVER's answer afterwards - `planet knowledge` asks
+ * the production gate a rocket asks - rather than on anything the GUI says about itself. A button
+ * that lights up and does nothing would satisfy a screen-scraping test and fail this one.
+ */
+@FixMethodOrder(MethodSorters.NAME_ASCENDING)
+public class ObservatoryDepositButtonE2ETest extends AbstractSharedClientE2ETest {
+
+ /** The survey tab of the observatory GUI: data, asteroid, region-scan. */
+ private static final int TAB_REGION_SCAN = 2;
+ /** The Deposit control's own id on that tab. */
+ private static final int BUTTON_DEPOSIT = 10;
+
+ private static final int X = 5200;
+ private static final int Y = 64;
+ private static final int Z = 5200;
+
+ @Override
+ protected String subsystem() {
+ return "knowledge-deposit";
+ }
+
+ @Test
+ public void pressingDepositTeachesTheWorldTheCrystalsAddresses() throws Exception {
+ String where = "0 " + X + " " + Y + " " + Z;
+
+ // The address on the crystal names a world MINTED for this test, and deliberately not one a
+ // survey found: a survey teaches the world it is made from as it goes, so a crystal filled
+ // by sweeping here would hold only things this world already knows - and the click could
+ // then teach nothing and still look successful.
+ exec("artest config set planetsMustBeDiscovered true");
+ int fresh;
+ try {
+ String installed = exec("artest space gen-install 0.9 2000000 987654321");
+ assertTrue("the procedural generator must install: " + installed,
+ installed.contains("\"ok\":true"));
+ String found = exec("artest space find-procedural 4");
+ assertTrue("a dense procedural galaxy must offer a landable body: " + found,
+ found.contains("\"ok\":true"));
+ String realized = exec("artest space realize " + intOf(found, "sx") + " "
+ + intOf(found, "sy") + " " + intOf(found, "sz"));
+ assertTrue("realization must mint a world: " + realized, realized.contains("\"ok\":true"));
+ fresh = intOf(realized, "dim");
+ } finally {
+ exec("artest space gen-reset");
+ }
+
+ String before = exec("artest planet knowledge 0 " + fresh);
+ assertTrue("arrangement: a just-minted world must be unknown here: " + before,
+ before.contains("\"local\":false"));
+ assertTrue("arrangement: and unknown to the pack: " + before,
+ before.contains("\"global\":false"));
+ // The COMPLETE multiblock, not a lone block: the survey tab is a machine's GUI, and a test
+ // that opened a half-built one would be measuring the incomplete panel.
+ // The COMPLETE multiblock, not a lone block: the survey tab is a machine's GUI, and a test
+ // that opened a half-built one would be measuring the incomplete panel.
+ String built = exec("artest fixture multiblock observatory 0 " + X + " " + Y + " " + Z);
+ assertTrue("could not build an observatory: " + built, built.contains("\"ok\":true"));
+ String crystal = exec("artest telescope crystal " + where + " " + fresh);
+ assertTrue("the machine must hold a crystal naming exactly that world: " + crystal,
+ crystal.contains("\"addresses\":1"));
+ String info = exec("artest telescope info " + where);
+ assertTrue("and the probe must see it there without depositing anything: " + info,
+ info.contains("\"crystalDims\":[" + fresh + "]"));
+
+ // Stand at the machine and open its GUI the way a player does.
+ exec("tp @a " + (X + 0.5) + " " + (Y + 2) + " " + (Z + 2.5) + " 0 30");
+ bot().waitTicks(20);
+ String screen = openGuiByRightClick(bot(), X, Y, Z);
+ assertTrue("right-clicking the observatory must open a GUI, got: " + screen,
+ screen.contains("Gui"));
+
+ bot().clickButtonById(TAB_REGION_SCAN);
+ bot().waitTicks(10);
+ bot().clickButtonById(BUTTON_DEPOSIT);
+ bot().waitTicks(20);
+
+ // The button's promise, asked of the server: the address the machine held is now something a
+ // tier-1 pad standing on this world may be aimed at, and it is known LOCALLY - a deposit may
+ // not touch the pack's global floor.
+ String after = exec("artest planet knowledge 0 " + fresh);
+ assertTrue("after the click a pad here must be offered that world: " + after,
+ after.contains("\"known\":true"));
+ assertTrue("and it must be known LOCALLY, not announced to the whole game: " + after,
+ after.contains("\"local\":true"));
+ assertTrue("the pack's own floor must be untouched: " + after,
+ after.contains("\"global\":false"));
+ }
+
+ /** A numeric field of a probe reply. */
+ private static int intOf(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 Integer.parseInt(json.substring(from, to));
+ }
+}
diff --git a/src/test/java/zmaster587/advancedRocketry/test/integration/LocalKnowledgeBelongsToABodyTest.java b/src/test/java/zmaster587/advancedRocketry/test/integration/LocalKnowledgeBelongsToABodyTest.java
new file mode 100644
index 000000000..afc332a33
--- /dev/null
+++ b/src/test/java/zmaster587/advancedRocketry/test/integration/LocalKnowledgeBelongsToABodyTest.java
@@ -0,0 +1,158 @@
+package zmaster587.advancedRocketry.test.integration;
+
+import java.util.List;
+
+import net.minecraft.nbt.NBTTagCompound;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import zmaster587.advancedRocketry.api.dimension.solar.StellarBody;
+import zmaster587.advancedRocketry.dimension.DimensionManager;
+import zmaster587.advancedRocketry.dimension.DimensionProperties;
+import zmaster587.advancedRocketry.test.MinecraftBootstrap;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * What a BODY knows, as opposed to what the game knows.
+ *
+ * Discovery used to have exactly one store: a global set that a beacon or the planet XML wrote
+ * into, which every launch pad in every world read. A telescope survey wrote nothing into it, so a
+ * player could chart a system, fly there with a ship, and still not be offered that planet by a
+ * tier-1 rocket standing on the ground.
+ *
+ * The contract these tests pin is that knowledge is a property of the PLACE: what one body has
+ * learned does not leak to its neighbour, the global set remains a floor under every body rather
+ * than being replaced, and a body's learning survives a save/load. They deliberately do not pin who
+ * WRITES the set - an observatory, a beacon and a crystal upload are separate mechanics with their
+ * own tests - only that a body has one and that it is asked.
+ */
+public class LocalKnowledgeBelongsToABodyTest {
+
+ private static final int TARGET = 4242;
+ private static final int OTHER_TARGET = 4243;
+
+ @BeforeClass
+ public static void bootstrap() {
+ MinecraftBootstrap.ensure();
+ }
+
+ @Test
+ public void aBeaconTeachesItsOwnSystemAndNothingBeyondIt() {
+ // A beacon used to add its planet to the GLOBAL known-set, so planting one made the place
+ // selectable from every launch pad in the game. What is actually true is narrower: the
+ // neighbours know, because they are the ones who can see it.
+ StellarBody star = new StellarBody();
+ star.setId(910);
+ star.setName("Beacon-Test");
+ star.setTemperature(100);
+ DimensionManager.getInstance().addStar(star);
+
+ DimensionProperties beaconed = registerBody(9101, star);
+ DimensionProperties sibling = registerBody(9102, star);
+ DimensionProperties elsewhere = registerBody(9201, null);
+
+ List taught = beaconed.teachOwnSystem();
+
+ assertTrue("the body the beacon stands on must know itself",
+ beaconed.isPlanetKnownHere(9101));
+ assertTrue("a body of the same system must learn it", sibling.isPlanetKnownHere(9101));
+ assertFalse("a body outside the system must learn nothing",
+ elsewhere.isPlanetKnownHere(9101));
+ assertTrue("and the sibling must be reported as changed, so it can be synced",
+ taught.contains(sibling));
+
+ List again = beaconed.teachOwnSystem();
+ assertTrue("a second beacon in the same system teaches nobody twice", again.isEmpty());
+ }
+
+ /** A body registered with the planet layer, optionally orbiting {@code star}. */
+ private static DimensionProperties registerBody(int dimId, StellarBody star) {
+ DimensionProperties props = new DimensionProperties(dimId);
+ props.setName("Body-" + dimId);
+ if (star != null) {
+ props.setStar(star);
+ star.addPlanet(props);
+ }
+ DimensionManager.getInstance().registerDimNoUpdate(props, false);
+ return props;
+ }
+
+ @Test
+ public void whatOneBodyLearnsIsNotKnownOnAnother() {
+ DimensionProperties here = new DimensionProperties(101);
+ DimensionProperties elsewhere = new DimensionProperties(102);
+
+ here.discoverPlanet(TARGET);
+
+ assertTrue("the body that learned it must know it", here.isPlanetKnownHere(TARGET));
+ assertFalse("a different body must not have learned anything",
+ elsewhere.isPlanetKnownHere(TARGET));
+ }
+
+ @Test
+ public void aBodyKnowsOnlyWhatItWasTaught() {
+ DimensionProperties here = new DimensionProperties(103);
+ here.discoverPlanet(TARGET);
+
+ assertFalse("a target nobody taught it must stay unknown", here.isPlanetKnownHere(OTHER_TARGET));
+ assertEquals("and the set holds exactly what was taught", 1, here.getLocallyKnownPlanets().size());
+ }
+
+ @Test
+ public void teachingTheSameBodyTwiceIsOneFact() {
+ DimensionProperties here = new DimensionProperties(104);
+
+ here.discoverPlanet(TARGET);
+ here.discoverPlanet(TARGET);
+
+ assertEquals("a second survey of the same target must not double the entry",
+ 1, here.getLocallyKnownPlanets().size());
+ }
+
+ @Test
+ public void whatABodyLearnedSurvivesASaveAndLoad() {
+ DimensionProperties saved = new DimensionProperties(105);
+ saved.discoverPlanet(TARGET);
+ saved.discoverPlanet(OTHER_TARGET);
+
+ NBTTagCompound nbt = new NBTTagCompound();
+ saved.writeToNBT(nbt);
+
+ DimensionProperties loaded = new DimensionProperties(105);
+ loaded.readFromNBT(nbt);
+
+ assertTrue("the first target must survive the round trip", loaded.isPlanetKnownHere(TARGET));
+ assertTrue("and so must the second", loaded.isPlanetKnownHere(OTHER_TARGET));
+ assertEquals("with nothing else invented on the way",
+ 2, loaded.getLocallyKnownPlanets().size());
+ }
+
+ @Test
+ public void aBodyThatLearnedNothingWritesNothing() {
+ DimensionProperties untaught = new DimensionProperties(106);
+
+ NBTTagCompound nbt = new NBTTagCompound();
+ untaught.writeToNBT(nbt);
+
+ assertFalse("an empty set must not occupy a key in every planet's save data",
+ nbt.hasKey("locallyKnownPlanets"));
+ }
+
+ @Test
+ public void loadingReplacesWhatTheObjectHeldRatherThanMergingIntoIt() {
+ // These objects are reused across loads. A merge would make a body remember a target that
+ // the save it is being loaded from does not contain - knowledge appearing from nowhere.
+ DimensionProperties reused = new DimensionProperties(107);
+ reused.discoverPlanet(TARGET);
+
+ NBTTagCompound fromAnotherSave = new NBTTagCompound();
+ fromAnotherSave.setIntArray("locallyKnownPlanets", new int[]{OTHER_TARGET});
+ reused.readFromNBT(fromAnotherSave);
+
+ assertTrue("the loaded target must be known", reused.isPlanetKnownHere(OTHER_TARGET));
+ assertFalse("what the object held before the load must be gone",
+ reused.isPlanetKnownHere(TARGET));
+ }
+}
diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/Tier1AimsAtWhatThisWorldKnowsE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/Tier1AimsAtWhatThisWorldKnowsE2ETest.java
new file mode 100644
index 000000000..80ef6fc77
--- /dev/null
+++ b/src/test/java/zmaster587/advancedRocketry/test/server/Tier1AimsAtWhatThisWorldKnowsE2ETest.java
@@ -0,0 +1,191 @@
+package zmaster587.advancedRocketry.test.server;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * The loop the two discovery systems exist to close, driven on a real server: an address is carried
+ * to a world, deposited there, and a tier-1 launch pad standing on that world may then be aimed at
+ * it.
+ *
+ * The unit and integration tiers pin the pieces - a body carries its own known-set, one body's
+ * finds never reach its neighbour, a beacon teaches its own system. What only a server can answer is
+ * whether the PRODUCTION gate a rocket asks agrees: `IPlanetDefiner.isPlanetKnown` reading the world
+ * it is standing in, with the pack's authored set as the floor beneath it.
+ *
+ * Both assertions here are stated against dims the probe itself reports, never against a literal
+ * this test wrote down: which worlds a survey resolves depends on the server's own sky.
+ *
+ * Position-isolated at x=4900-4960 (clear of the survey fixtures at 4300-4660 and the observatory
+ * multiblock fixtures at 4000-4060).
+ */
+public class Tier1AimsAtWhatThisWorldKnowsE2ETest extends AbstractSharedServerTest {
+
+ private static final int CY = 64;
+ private static final int CZ = 4900;
+ private static final int X = 4900;
+
+ private String exec(String command) throws Exception {
+ return String.join("\n", client().execute(command));
+ }
+
+ private String where() {
+ return "0 " + X + " " + CY + " " + CZ;
+ }
+
+ /** Whether the gate a rocket standing in {@code standing} asks says {@code target} is known. */
+ private boolean known(int standing, int target) throws Exception {
+ String reply = exec("artest planet knowledge " + standing + " " + target);
+ assertTrue("the knowledge probe failed: " + reply, reply.contains("\"known\":"));
+ return reply.contains("\"known\":true");
+ }
+
+ /** The same reply's two halves, so a red test says WHICH source moved. */
+ private String halves(int standing, int target) throws Exception {
+ return exec("artest planet knowledge " + standing + " " + target);
+ }
+
+ /** A numeric field of a probe reply. */
+ private static int intField(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 Integer.parseInt(json.substring(from, to));
+ }
+
+ private static List ints(String json, String field) {
+ String key = "\"" + field + "\":[";
+ int at = json.indexOf(key);
+ assertTrue("probe reply has no array " + field + ": " + json, at >= 0);
+ int end = json.indexOf(']', at);
+ String body = json.substring(at + key.length(), end).trim();
+ List out = new ArrayList<>();
+ if (!body.isEmpty()) {
+ for (String piece : body.split(",")) {
+ out.add(Integer.parseInt(piece.trim()));
+ }
+ }
+ return out;
+ }
+
+ @Test
+ public void withResearchOnAPadIsNotOfferedAWorldNobodyHasFoundHere() throws Exception {
+ exec("artest config set planetsMustBeDiscovered true");
+
+ assertTrue("the overworld must always be known - it is the floor every pack starts from",
+ known(0, 0));
+
+ // The target is MINTED for this test rather than picked out of the server's planet list: a
+ // world that has just come into existence cannot be in anybody's known-set, so the assertion
+ // below cannot be quietly satisfied by whatever another test taught this world earlier.
+ try {
+ String installed = exec("artest space gen-install 0.9 2000000 987654321");
+ assertTrue("the procedural generator must install: " + installed,
+ installed.contains("\"ok\":true"));
+ String found = exec("artest space find-procedural 4");
+ assertTrue("a dense procedural galaxy must offer a landable body: " + found,
+ found.contains("\"ok\":true"));
+ String cell = intField(found, "sx") + " " + intField(found, "sy") + " "
+ + intField(found, "sz");
+ String realized = exec("artest space realize " + cell);
+ assertTrue("realization must mint a world to ask about: " + realized,
+ realized.contains("\"ok\":true"));
+ int fresh = intField(realized, "dim");
+
+ String reply = halves(0, fresh);
+ assertTrue("a freshly minted world must be in nobody's global set: " + reply,
+ reply.contains("\"global\":false"));
+ assertTrue("nor known on the world we are standing on: " + reply,
+ reply.contains("\"local\":false"));
+ assertFalse("and a pad here must therefore not be offered it: " + reply,
+ known(0, fresh));
+ } finally {
+ exec("artest space gen-reset");
+ }
+ }
+
+ @Test
+ public void withResearchOffThePlaceBoundSetGatesNothing() throws Exception {
+ // The other half of the same knob, and the one a pack that does not want research at all
+ // relies on: with the master switch off there must be NO new gate anywhere. Measured against
+ // the hardest case - a world minted a moment ago, which by construction is in neither the
+ // pack's authored set nor this world's own, and which must still be selectable.
+ exec("artest config set planetsMustBeDiscovered false");
+ try {
+ String installed = exec("artest space gen-install 0.9 2000000 987654321");
+ assertTrue("the procedural generator must install: " + installed,
+ installed.contains("\"ok\":true"));
+ String found = exec("artest space find-procedural 4");
+ assertTrue("a dense procedural galaxy must offer a landable body: " + found,
+ found.contains("\"ok\":true"));
+ String realized = exec("artest space realize " + intField(found, "sx") + " "
+ + intField(found, "sy") + " " + intField(found, "sz"));
+ assertTrue("realization must mint a world to ask about: " + realized,
+ realized.contains("\"ok\":true"));
+ int fresh = intField(realized, "dim");
+
+ String reply = halves(0, fresh);
+ assertTrue("arrangement: nobody may have taught this world globally: " + reply,
+ reply.contains("\"global\":false"));
+ assertTrue("arrangement: nor locally: " + reply, reply.contains("\"local\":false"));
+ assertTrue("with research off a pad must still be offered it - the place-bound set is"
+ + " additive over a gate that is not there: " + reply, known(0, fresh));
+ } finally {
+ exec("artest space gen-reset");
+ exec("artest config set planetsMustBeDiscovered true");
+ }
+ }
+
+ @Test
+ public void anAddressDepositedHereBecomesSomethingAPadHereCanBeAimedAt() throws Exception {
+ // The sweep runs with research OFF, where what the instrument reaches is resolved outright -
+ // the pacing is a different mechanic with its own test, and waiting for it here would only
+ // make this fixture slower and flakier. The GATE is then asked with research ON, which is
+ // the mode the whole question exists in.
+ exec("artest config set planetsMustBeDiscovered false");
+ exec("artest config set telescopeLimitingMagnitude 30");
+ exec("artest config set telescopePassiveRadiusSteps 1");
+
+ String placed = exec("artest telescope place " + where());
+ assertTrue("could not place an observatory: " + placed, placed.contains("\"ok\":true"));
+ String crystal = exec("artest telescope crystal " + where());
+ assertTrue("the crystal must start blank: " + crystal, crystal.contains("\"addresses\":0"));
+
+ // The instrument watching its own neighbourhood: what it resolves are the bodies of the
+ // system this observatory is standing in, which are the ones that have worlds to fly to.
+ String swept = exec("artest telescope passive " + where());
+ assertTrue("the passive sweep did not start: " + swept, swept.contains("\"ok\":true"));
+ String afterSweep = exec("artest telescope info " + where());
+ assertFalse("the sweep must be finished with research off: " + afterSweep,
+ afterSweep.contains("\"scanning\":true"));
+ exec("artest config set planetsMustBeDiscovered true");
+
+ String deposited = exec("artest telescope deposit " + where());
+ List landed = ints(deposited, "dims");
+ assertFalse("the sweep resolved nothing with a world in it, so there is nothing to deposit"
+ + " and this fixture proves nothing: " + deposited, landed.isEmpty());
+
+ for (int dim : landed) {
+ String reply = halves(0, dim);
+ assertTrue("a deposited address must be known to a pad standing here: " + reply,
+ reply.contains("\"known\":true"));
+ assertTrue("and it must be known LOCALLY - the deposit may not touch the global floor: "
+ + reply, reply.contains("\"local\":true"));
+ }
+
+ String depositedAgain = exec("artest telescope deposit " + where());
+ assertEquals("depositing the same crystal twice must land the same addresses, not more",
+ landed, ints(depositedAgain, "dims"));
+ }
+}
diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetRealizationTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetRealizationTest.java
index 41a478c61..ad25a0691 100644
--- a/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetRealizationTest.java
+++ b/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetRealizationTest.java
@@ -116,6 +116,38 @@ private static SystemBody[] findPlanetWithMoon(UniverseRegistry reg) {
return new SystemBody[] {null, null};
}
+ @Test
+ public void aMoonGetsItsOwnWorldAndNotItsPlanetsOne() {
+ // A moon is built in its PARENT's cell so the family travels as one destination, which makes
+ // a cell the address of several worlds. Realization used to be keyed on the cell alone: once
+ // the planet had a world, asking about the moon answered with the planet's, so a descent
+ // aimed at a moon put the ship on the planet - and a moon could never be realized at all.
+ UniverseRegistry reg = registryWithProceduralGalaxy();
+ SystemBody[] pair = findPlanetWithMoon(reg);
+ assertNotNull("arrangement: a planet with a moon must be findable", pair[0]);
+ assertNotNull("arrangement: and the moon with it", pair[1]);
+ GalacticCoord cell = pair[0].name();
+ assertTrue("arrangement: the two must share one cell", pair[1].name().sameCell(cell));
+ reg.pinSystem(cell);
+
+ java.util.List family = reg.realizableBodiesAt(cell);
+ assertTrue("arrangement: the cell must hold at least the two of them", family.size() >= 2);
+ int planetVariant = reg.variantOf(pair[0]).getAsInt();
+ int moonVariant = reg.variantOf(pair[1]).getAsInt();
+ assertNotEquals("a planet and its moon must not be the same body", planetVariant, moonVariant);
+
+ assertTrue(reg.realizeBody(cell, planetVariant, 4001));
+
+ assertFalse("the moon must NOT inherit the planet's world",
+ reg.realizedDimAt(cell, moonVariant).isPresent());
+ assertTrue("and the moon must still be able to get one of its own",
+ reg.realizeBody(cell, moonVariant, 4002));
+ assertEquals("which is its own and not the planet's", 4002,
+ reg.realizedDimAt(cell, moonVariant).getAsInt());
+ assertEquals("while the planet keeps the world it was given", 4001,
+ reg.realizedDimAt(cell, planetVariant).getAsInt());
+ }
+
@Test
public void theProceduralGalaxyOffersLandableBodiesThatHaveNoWorldYet() {
// The precondition of everything below, and the defect the whole batch exists to fix: the
@@ -203,9 +235,9 @@ public void realizingABodyMakesItADescentTargetAndRecordsItsCellName() {
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));
+ assertTrue("the pinned body must accept a dimension", reg.realizeBody(cell, 0, 4242));
- OptionalInt realized = reg.realizedDimAt(cell);
+ OptionalInt realized = reg.realizedDimAt(cell, 0);
assertTrue("the cell must now report a realized world", realized.isPresent());
assertEquals(4242, realized.getAsInt());
@@ -231,13 +263,13 @@ public void asecondDescentIntoTheSameCellReusesTheWorld() {
GalacticCoord cell = findLandableCell(reg);
assertNotNull(cell);
reg.pinSystem(cell);
- assertTrue(reg.realizeBody(cell, 777));
+ assertTrue(reg.realizeBody(cell, 0, 777));
assertEquals("asking again must answer the SAME world", 777,
- reg.realizedDimAt(cell).getAsInt());
+ reg.realizedDimAt(cell, 0).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());
+ reg.realizeBody(cell, 0, 777));
+ assertEquals(777, reg.realizedDimAt(cell, 0).getAsInt());
}
@Test
@@ -246,10 +278,10 @@ public void aBodyThatAlreadyHasAWorldRefusesASecondOne() {
GalacticCoord cell = findLandableCell(reg);
assertNotNull(cell);
reg.pinSystem(cell);
- assertTrue(reg.realizeBody(cell, 100));
+ assertTrue(reg.realizeBody(cell, 0, 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());
+ assertFalse("a body must never be re-pointed at a different world", reg.realizeBody(cell, 0, 200));
+ assertEquals("and it must still hold the first one", 100, reg.realizedDimAt(cell, 0).getAsInt());
}
@Test
@@ -260,8 +292,8 @@ public void anUnpinnedSystemCannotBeRealizedIntoAtAll() {
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());
+ reg.realizeBody(cell, 0, 55));
+ assertFalse(reg.realizedDimAt(cell, 0).isPresent());
}
@Test
@@ -306,7 +338,7 @@ public void aRealizedBodyKeepsItsCellItsOrbitAndItsKind() {
}
assertNotNull(before);
reg.pinSystem(cell);
- assertTrue(reg.realizeBody(cell, 999));
+ assertTrue(reg.realizeBody(cell, 0, 999));
SystemBody after = null;
for (SystemBody b : reg.bodiesAt(cell)) {
diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/TelescopeConeSurveyTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/TelescopeConeSurveyTest.java
index ddf5ca9d7..656760bf2 100644
--- a/src/test/java/zmaster587/advancedRocketry/test/unit/TelescopeConeSurveyTest.java
+++ b/src/test/java/zmaster587/advancedRocketry/test/unit/TelescopeConeSurveyTest.java
@@ -1,5 +1,6 @@
package zmaster587.advancedRocketry.test.unit;
+import java.util.Collections;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@@ -607,4 +608,66 @@ public void aGeneratorWithNoStarsGivesAnInstrumentNothingToReach() {
empty.maxRangeLightYears(), 0d);
assertEquals("which is still a pointing, of one territory", 1, empty.maxRangeSteps());
}
+
+ // ── what a look may teach the ground it was made from ─────────────────────
+
+ @Test
+ public void aLookReportsOnlyTheBodiesItActuallyMadeOut() {
+ // The coupling that lets an observatory teach the world underneath it: the instrument may
+ // pass on a body only when it RESOLVED one. A star has no dimension of its own, so of the
+ // fixture's two objects exactly one can ever be taught.
+ double sunLike = StellarMagnitude.luminositySuns(1.15d, 100);
+ double near = StellarMagnitude.detectionRangeLightYears(sunLike, 12d - 6.5d) / 2d;
+ UniverseRegistry registry = oneStarAt(near, 1.15f, 100);
+ List hits = TelescopeScan.detect(registry, seatAt(near), HOME, 12d);
+ assertEquals("arrangement: one system to look at", 1, hits.size());
+ assertTrue("arrangement: and it must be resolvable at this distance", hits.get(0).resolvable());
+
+ List taught = new ArrayList<>();
+ TelescopeScan.characterise(registry, hits.get(0), new CrystalMemory(), 1_000L,
+ id -> "Body-" + id, true, taught::add);
+
+ assertEquals("exactly the planet, and not the star that has no world: " + taught,
+ Collections.singletonList(701), taught);
+ }
+
+ @Test
+ public void aLookThatOnlyREGISTEREDTeachesNothing() {
+ // Inside the aperture, outside what it can make out. The crystal still gets the address -
+ // that is the whole mechanic - but nothing about the system may reach the ground, because
+ // nothing about it was learned.
+ double sunLike = StellarMagnitude.luminositySuns(1.15d, 100);
+ double detectReach = StellarMagnitude.detectionRangeLightYears(sunLike, 12d);
+ double resolveReach = StellarMagnitude.detectionRangeLightYears(sunLike, 12d - 6.5d);
+ double far = (detectReach + resolveReach) / 2d;
+ UniverseRegistry registry = oneStarAt(far, 1.15f, 100);
+ List hits = TelescopeScan.detect(registry, seatAt(far), HOME, 12d);
+ assertEquals("arrangement: it must still register", 1, hits.size());
+ assertFalse("arrangement: and must not be resolvable", hits.get(0).resolvable());
+
+ CrystalMemory memory = new CrystalMemory();
+ List taught = new ArrayList<>();
+ TelescopeScan.characterise(registry, hits.get(0), memory, 1_000L, id -> "Body-" + id,
+ true, taught::add);
+
+ assertTrue("a point of light teaches the ground nothing: " + taught, taught.isEmpty());
+ assertEquals("but the address is still written down", 1, memory.size());
+ }
+
+ @Test
+ public void recordingPositionsOnlyTeachesNothingEither() {
+ // The operator's own choice, not the aperture's limit. Asking for less must also GIVE less
+ // to the ground, or "positions only" would quietly be a full survey for tier-1.
+ double sunLike = StellarMagnitude.luminositySuns(1.15d, 100);
+ double near = StellarMagnitude.detectionRangeLightYears(sunLike, 12d - 6.5d) / 2d;
+ UniverseRegistry registry = oneStarAt(near, 1.15f, 100);
+ List hits = TelescopeScan.detect(registry, seatAt(near), HOME, 12d);
+ assertTrue("arrangement: the aperture must not be what limits this", hits.get(0).resolvable());
+
+ List taught = new ArrayList<>();
+ TelescopeScan.characterise(registry, hits.get(0), new CrystalMemory(), 1_000L,
+ id -> "Body-" + id, false, taught::add);
+
+ assertTrue("an operator recording addresses teaches no world: " + taught, taught.isEmpty());
+ }
}