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()); + } }