diff --git a/common/src/main/java/dev/ryanhcode/sable/api/sublevel/ServerSubLevelContainer.java b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/ServerSubLevelContainer.java index b1ac1437..23840ed6 100644 --- a/common/src/main/java/dev/ryanhcode/sable/api/sublevel/ServerSubLevelContainer.java +++ b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/ServerSubLevelContainer.java @@ -139,13 +139,39 @@ public void removeSubLevel(final int x, final int z, final SubLevelRemovalReason super.removeSubLevel(x, z, reason); - if (reason == SubLevelRemovalReason.REMOVED) { + if (reason.clearsOccupancy()) { final ServerLevel level = this.getLevel(); SubLevelOccupancySavedData.getOrLoad(level).setDirty(); this.holdingChunkMap.queueDeletion(subLevel); } } + /** + * Moves loading-ticket ownership to a replacement sub-level in another container. + */ + @ApiStatus.Internal + public void transferTicketsTo( + final ServerSubLevel source, + final ServerSubLevelContainer destinationContainer, + final ServerSubLevel destination) { + final UUID uuid = source.getUniqueId(); + final SubLevelTicketInfo info = this.allTickets.remove(uuid); + this.activeTickets.remove(source); + + if (info == null) { + return; + } + + info.setPointer(null); + destinationContainer.allTickets.put(uuid, info); + if (!info.tickets().isEmpty()) { + destinationContainer.activeTickets.put(destination, new ObjectArraySet<>(info.tickets())); + } + + SubLevelTicketsSavedData.getOrLoad(this.getLevel()).setDirty(); + SubLevelTicketsSavedData.getOrLoad(destinationContainer.getLevel()).setDirty(); + } + @Override protected SubLevel createSubLevel(final int globalPlotX, final int globalPlotZ, final Pose3d pose, final UUID uuid) { final ServerLevel level = this.getLevel(); @@ -212,6 +238,40 @@ public boolean addForceLoadTicket(final ServerSubLevel subLevel, final SubLe return false; } + /** + * Adds a force-loading ticket that only lasts for the current server session and is not written to saved data. + */ + public boolean addTransientForceLoadTicket( + final ServerSubLevel subLevel, + final SubLevelLoadingTicketType ticketType, + final T key) { + final SubLevelLoadingTicket ticket = new SubLevelLoadingTicket<>( + ticketType, subLevel.getUniqueId(), key); + return this.activeTickets + .computeIfAbsent(subLevel, ignored -> new ObjectArraySet<>()) + .add(ticket); + } + + /** + * Removes a force-loading ticket previously added with {@link #addTransientForceLoadTicket}. + */ + public boolean removeTransientForceLoadTicket( + final ServerSubLevel subLevel, + final SubLevelLoadingTicketType ticketType, + final T key) { + final ObjectSet> tickets = this.activeTickets.get(subLevel); + if (tickets == null) { + return false; + } + + final boolean removed = tickets.remove(new SubLevelLoadingTicket<>( + ticketType, subLevel.getUniqueId(), key)); + if (tickets.isEmpty()) { + this.activeTickets.remove(subLevel); + } + return removed; + } + /** * Removes a sub-level force-loading ticket * diff --git a/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelContainer.java b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelContainer.java index e1a56349..23bf01c7 100644 --- a/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelContainer.java +++ b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelContainer.java @@ -230,13 +230,24 @@ public int getIndex(final int x, final int z) { * @return the allocated plot */ public SubLevel allocateNewSubLevel(final Pose3d pose) { + return this.allocateNewSubLevel(UUID.randomUUID(), pose); + } + + /** + * Allocates the first free plot for a sub-level with an existing persistent ID. + * + * @param uuid the persistent sub-level ID + * @param pose the initial pose + * @return the allocated sub-level + */ + public SubLevel allocateNewSubLevel(final UUID uuid, final Pose3d pose) { final Vector2i firstEmptyPlot = this.getFirstEmptyPlot(); if (firstEmptyPlot == null) { throw new IllegalStateException("No empty plots left in the plotgrid"); } - return this.allocateSubLevel(UUID.randomUUID(), firstEmptyPlot.x, firstEmptyPlot.y, pose); + return this.allocateSubLevel(uuid, firstEmptyPlot.x, firstEmptyPlot.y, pose); } /** @@ -492,7 +503,7 @@ public void removeSubLevel(final int x, final int z, final SubLevelRemovalReason this.allSubLevels.remove(subLevel); this.subLevelsByUUID.remove(subLevel.getUniqueId()); - if (reason == SubLevelRemovalReason.REMOVED) { + if (reason.clearsOccupancy()) { this.getOccupancy().clear(index); } } @@ -529,6 +540,14 @@ public void removeSubLevel(final SubLevel subLevel, final SubLevelRemovalReason return this.subLevelsByUUID.get(uuid); } + /** + * Notifies this container's observers that one sub-level instance replaced another. + */ + @ApiStatus.Internal + public void notifySubLevelTransferred(final SubLevel source, final SubLevel destination) { + this.observers.forEach(observer -> observer.onSubLevelTransferred(source, destination)); + } + /** * The occupancy of the plotgrid, including loaded and unloaded plots */ diff --git a/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelObserver.java b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelObserver.java index 59ac039f..d214f485 100644 --- a/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelObserver.java +++ b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelObserver.java @@ -24,6 +24,16 @@ default void onSubLevelAdded(final SubLevel subLevel) { default void onSubLevelRemoved(final SubLevel subLevel, final SubLevelRemovalReason reason) { } + /** + * Called after a server sub-level has moved between level containers. + * Implementations must replace references to {@code source} with {@code destination}. + * + * @param source the removed source instance + * @param destination the replacement instance in the destination level + */ + default void onSubLevelTransferred(final SubLevel source, final SubLevel destination) { + } + /** * Called every tick for each {@link SubLevelContainer}. * diff --git a/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelTicketLoadingSystem.java b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelTicketLoadingSystem.java index ffa65ce0..123caafd 100644 --- a/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelTicketLoadingSystem.java +++ b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelTicketLoadingSystem.java @@ -42,7 +42,7 @@ public void onSubLevelRemoved(final SubLevel subLevel, final SubLevelRemovalReas if (info != null) { info.setPointer(serverSubLevel.getLastSerializationPointer()); } - } else if (reason == SubLevelRemovalReason.REMOVED) { + } else if (reason == SubLevelRemovalReason.REMOVED || reason == SubLevelRemovalReason.TRANSFERRED) { this.container.allTickets.remove(uuid); this.container.activeTickets.remove(serverSubLevel); } diff --git a/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelTransferResult.java b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelTransferResult.java new file mode 100644 index 00000000..006e9ae2 --- /dev/null +++ b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelTransferResult.java @@ -0,0 +1,19 @@ +package dev.ryanhcode.sable.api.sublevel; + +import dev.ryanhcode.sable.sublevel.ServerSubLevel; + +import java.util.Map; +import java.util.UUID; + +/** + * The replacement instances produced by a successful cross-level transfer. + * Persistent UUIDs are preserved, but runtime IDs and Java object identities change. + * + * @param root replacement for the requested root sub-level + * @param replacements all replacements indexed by persistent UUID + */ +public record SubLevelTransferResult(ServerSubLevel root, Map replacements) { + public SubLevelTransferResult { + replacements = Map.copyOf(replacements); + } +} diff --git a/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelTransferService.java b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelTransferService.java new file mode 100644 index 00000000..ac1e16ff --- /dev/null +++ b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelTransferService.java @@ -0,0 +1,247 @@ +package dev.ryanhcode.sable.api.sublevel; + +import dev.ryanhcode.sable.api.SubLevelHelper; +import dev.ryanhcode.sable.companion.math.Pose3d; +import dev.ryanhcode.sable.companion.math.Pose3dc; +import dev.ryanhcode.sable.sublevel.ServerSubLevel; +import dev.ryanhcode.sable.sublevel.SubLevel; +import dev.ryanhcode.sable.sublevel.storage.SubLevelRemovalReason; +import dev.ryanhcode.sable.sublevel.storage.serialization.SubLevelData; +import dev.ryanhcode.sable.sublevel.storage.serialization.SubLevelSerializer; +import dev.ryanhcode.sable.sublevel.system.SubLevelPhysicsSystem; +import dev.ryanhcode.sable.sublevel.tracking_points.SubLevelTrackingPointSavedData; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.portal.DimensionTransition; +import net.minecraft.world.phys.Vec3; +import org.joml.Quaterniond; +import org.joml.Vector3d; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Atomically replaces a group of loaded server sub-levels with equivalent instances in another level. + */ +public final class SubLevelTransferService { + private SubLevelTransferService() { + } + + /** + * Transfers a sub-level and its complete loading-dependency chain to another level. + * The destination root pose defines the rigid transform applied to every body in the chain. + * This method must run on the server thread and outside a physics step. + * + * @param root root sub-level to transfer + * @param destinationLevel destination parent level + * @param destinationRootPose desired pose of the root in the destination level + * @return all replacement sub-level instances + */ + public static SubLevelTransferResult transfer( + final ServerSubLevel root, + final ServerLevel destinationLevel, + final Pose3dc destinationRootPose) { + validateRequest(root, destinationLevel); + + final ServerLevel sourceLevel = root.getLevel(); + final ServerSubLevelContainer sourceContainer = requireContainer(sourceLevel); + final ServerSubLevelContainer destinationContainer = requireContainer(destinationLevel); + final List sources = collectSources(root, sourceLevel, destinationContainer); + final List dependencyIds = sources.stream().map(SubLevel::getUniqueId).toList(); + final Map snapshots = new LinkedHashMap<>(); + final Map destinationPoses = new LinkedHashMap<>(); + + final Pose3dc sourceRootPose = root.logicalPose(); + final Quaterniond worldRotation = new Quaterniond(destinationRootPose.orientation()) + .mul(new Quaterniond(sourceRootPose.orientation()).conjugate()); + + for (final ServerSubLevel source : sources) { + snapshots.put(source.getUniqueId(), SubLevelSerializer.toData(source, dependencyIds)); + destinationPoses.put(source.getUniqueId(), transformPose( + source.logicalPose(), sourceRootPose, destinationRootPose, worldRotation)); + } + + final Map replacements = new LinkedHashMap<>(); + final List transferredEntities = new ArrayList<>(); + try { + for (final ServerSubLevel source : sources) { + final UUID uuid = source.getUniqueId(); + final ServerSubLevel replacement = SubLevelSerializer.fullyLoadForTransfer( + destinationLevel, + snapshots.get(uuid), + destinationPoses.get(uuid), + worldRotation); + if (replacement == null) { + throw new IllegalStateException("Unable to allocate destination sub-level " + uuid); + } + replacements.put(uuid, replacement); + } + transferPlotEntities(sources, replacements, destinationLevel, transferredEntities); + } catch (final RuntimeException exception) { + rollbackEntities(transferredEntities); + rollback(destinationContainer, replacements); + throw exception; + } + + for (final ServerSubLevel source : sources) { + sourceContainer.transferTicketsTo(source, destinationContainer, replacements.get(source.getUniqueId())); + SubLevelTrackingPointSavedData.transferSubLevelPoints(source, replacements.get(source.getUniqueId())); + } + for (final ServerSubLevel source : sources) { + sourceContainer.removeSubLevel(source, SubLevelRemovalReason.TRANSFERRED); + } + for (final ServerSubLevel source : sources) { + final ServerSubLevel replacement = replacements.get(source.getUniqueId()); + sourceContainer.notifySubLevelTransferred(source, replacement); + destinationContainer.notifySubLevelTransferred(source, replacement); + } + + return new SubLevelTransferResult(replacements.get(root.getUniqueId()), replacements); + } + + private static void validateRequest(final ServerSubLevel root, final ServerLevel destinationLevel) { + if (root.isRemoved()) { + throw new IllegalArgumentException("Cannot transfer a removed sub-level"); + } + + final ServerLevel sourceLevel = root.getLevel(); + final MinecraftServer server = sourceLevel.getServer(); + if (server != destinationLevel.getServer()) { + throw new IllegalArgumentException("Source and destination levels belong to different servers"); + } + if (sourceLevel == destinationLevel) { + throw new IllegalArgumentException("Source and destination levels must be different"); + } + if (!server.isSameThread()) { + throw new IllegalStateException("Sub-level transfer must run on the server thread"); + } + if (SubLevelPhysicsSystem.IN_PHYSICS_STEP) { + throw new IllegalStateException("Sub-level transfer cannot run during a physics step"); + } + } + + private static ServerSubLevelContainer requireContainer(final ServerLevel level) { + final ServerSubLevelContainer container = SubLevelContainer.getContainer(level); + if (container == null) { + throw new IllegalStateException("Level " + level.dimension().location() + " has no sub-level container"); + } + return container; + } + + private static List collectSources( + final ServerSubLevel root, + final ServerLevel sourceLevel, + final ServerSubLevelContainer destinationContainer) { + final List sources = new ArrayList<>(); + for (final ServerSubLevel source : SubLevelHelper.getLoadingDependencyChain(root)) { + if (source.getLevel() != sourceLevel) { + throw new IllegalStateException("A loading dependency already belongs to another level: " + source.getUniqueId()); + } + if (destinationContainer.getSubLevel(source.getUniqueId()) != null) { + throw new IllegalStateException("Destination already contains sub-level " + source.getUniqueId()); + } + sources.add(source); + } + return sources; + } + + private static Pose3d transformPose( + final Pose3dc source, + final Pose3dc sourceRoot, + final Pose3dc destinationRoot, + final Quaterniond worldRotation) { + final Vector3d transformedPosition = sourceRoot.transformPositionInverse(new Vector3d(source.position())); + destinationRoot.transformPosition(transformedPosition); + + final Pose3d transformed = new Pose3d(source); + transformed.position().set(transformedPosition); + transformed.orientation().set(worldRotation).mul(source.orientation()); + return transformed; + } + + private static void rollback( + final ServerSubLevelContainer destinationContainer, + final Map replacements) { + final List reverseOrder = new ArrayList<>(replacements.values()); + for (int i = reverseOrder.size() - 1; i >= 0; i--) { + final ServerSubLevel replacement = reverseOrder.get(i); + if (!replacement.isRemoved()) { + destinationContainer.removeSubLevel(replacement, SubLevelRemovalReason.REMOVED); + } + } + } + + private static void transferPlotEntities( + final List sources, + final Map replacements, + final ServerLevel destinationLevel, + final List transferredEntities) { + for (final ServerSubLevel source : sources) { + final ServerSubLevel replacement = replacements.get(source.getUniqueId()); + final BlockPosOffset offset = BlockPosOffset.between( + source.getPlot().getCenterBlock(), replacement.getPlot().getCenterBlock()); + + for (final Entity entity : source.getPlot().collectRootEntities()) { + final Vec3 sourcePosition = entity.position(); + final Vec3 sourceVelocity = entity.getDeltaMovement(); + final float sourceYRot = entity.getYRot(); + final float sourceXRot = entity.getXRot(); + final Vec3 destinationPosition = sourcePosition.add(offset.x(), offset.y(), offset.z()); + final Entity destinationEntity = entity.changeDimension(new DimensionTransition( + destinationLevel, + destinationPosition, + sourceVelocity, + sourceYRot, + sourceXRot, + DimensionTransition.DO_NOTHING)); + if (destinationEntity == null) { + throw new IllegalStateException("Entity " + entity.getUUID() + " rejected sub-level transfer"); + } + transferredEntities.add(new TransferredEntity( + destinationEntity, + source.getLevel(), + sourcePosition, + sourceVelocity, + sourceYRot, + sourceXRot)); + } + } + } + + private static void rollbackEntities(final List transferredEntities) { + for (int i = transferredEntities.size() - 1; i >= 0; i--) { + final TransferredEntity transfer = transferredEntities.get(i); + transfer.entity().changeDimension(new DimensionTransition( + transfer.sourceLevel(), + transfer.sourcePosition(), + transfer.sourceVelocity(), + transfer.yRot(), + transfer.xRot(), + DimensionTransition.DO_NOTHING)); + } + } + + private record BlockPosOffset(int x, int y, int z) { + private static BlockPosOffset between( + final net.minecraft.core.BlockPos source, + final net.minecraft.core.BlockPos destination) { + return new BlockPosOffset( + destination.getX() - source.getX(), + destination.getY() - source.getY(), + destination.getZ() - source.getZ()); + } + } + + private record TransferredEntity( + Entity entity, + ServerLevel sourceLevel, + Vec3 sourcePosition, + Vec3 sourceVelocity, + float yRot, + float xRot) { + } +} diff --git a/common/src/main/java/dev/ryanhcode/sable/network/packets/tcp/ClientboundStartTrackingSubLevelPacket.java b/common/src/main/java/dev/ryanhcode/sable/network/packets/tcp/ClientboundStartTrackingSubLevelPacket.java index 159e9ed3..2e4a6783 100644 --- a/common/src/main/java/dev/ryanhcode/sable/network/packets/tcp/ClientboundStartTrackingSubLevelPacket.java +++ b/common/src/main/java/dev/ryanhcode/sable/network/packets/tcp/ClientboundStartTrackingSubLevelPacket.java @@ -66,6 +66,15 @@ public void handle(final PacketContext context) { return; } + Sable.LOGGER.debug( + "Receiving full sync for sub-level {}: clientDimension={}, pose={}, bounds={}, localPlot={},{}", + this.subLevelID, + level.dimension().location(), + this.pose, + this.bounds, + ChunkPos.getX(this.plotCoordinate), + ChunkPos.getZ(this.plotCoordinate)); + final ClientSubLevel subLevel = (ClientSubLevel) clientContainer.allocateSubLevel(this.subLevelID, ChunkPos.getX(this.plotCoordinate), ChunkPos.getZ(this.plotCoordinate), new Pose3d(this.lastPose)); final SubLevelSnapshotInterpolator interpolator = subLevel.getInterpolator(); @@ -90,4 +99,4 @@ public void handle(final PacketContext context) { subLevel.setName(this.name); } } -} \ No newline at end of file +} diff --git a/common/src/main/java/dev/ryanhcode/sable/sublevel/plot/ServerLevelPlot.java b/common/src/main/java/dev/ryanhcode/sable/sublevel/plot/ServerLevelPlot.java index f0e57705..3ac582bc 100644 --- a/common/src/main/java/dev/ryanhcode/sable/sublevel/plot/ServerLevelPlot.java +++ b/common/src/main/java/dev/ryanhcode/sable/sublevel/plot/ServerLevelPlot.java @@ -336,6 +336,10 @@ public CompoundTag save() { final CompoundTag tag = new CompoundTag(); tag.putInt("plot_x", this.plotPos.x - this.container.getOrigin().x); tag.putInt("plot_z", this.plotPos.z - this.container.getOrigin().y); + tag.putInt("plot_grid_origin_x", this.container.getOrigin().x); + tag.putInt("plot_grid_origin_z", this.container.getOrigin().y); + tag.putInt("min_section_y", this.getSubLevel().getLevel().getMinSection()); + tag.putInt("plot_center_y", this.getCenterBlock().getY()); tag.putInt("log_size", this.logSize); tag.putString("biome", this.biome.location().toString()); tag.putInt("data_version", DATA_VERSION); @@ -416,6 +420,29 @@ public CompoundTag save() { return tag; } + /** + * Returns the block-space offset applied when serialized plot contents are loaded here. + */ + public BlockPos getRelocationOffset(final CompoundTag tag) { + final ServerLevel level = this.getSubLevel().getLevel(); + final int sourceOriginX = tag.contains("plot_grid_origin_x") + ? tag.getInt("plot_grid_origin_x") + : this.container.getOrigin().x; + final int sourceOriginZ = tag.contains("plot_grid_origin_z") + ? tag.getInt("plot_grid_origin_z") + : this.container.getOrigin().y; + final int sourcePlotX = sourceOriginX + tag.getInt("plot_x"); + final int sourcePlotZ = sourceOriginZ + tag.getInt("plot_z"); + final int sourceCenterY = tag.contains("plot_center_y") + ? tag.getInt("plot_center_y") + : this.getCenterBlock().getY(); + + return new BlockPos( + (this.plotPos.x - sourcePlotX) << (this.logSize + 4), + this.getCenterBlock().getY() - sourceCenterY, + (this.plotPos.z - sourcePlotZ) << (this.logSize + 4)); + } + /** * Deserializes a plot from an NBT tag */ @@ -432,6 +459,13 @@ public void load(final CompoundTag tag) { final ServerSubLevel subLevel = this.getSubLevel(); final ServerLevel level = subLevel.getLevel(); + final BlockPos relocationOffset = this.getRelocationOffset(tag); + final int blockOffsetX = relocationOffset.getX(); + final int blockOffsetZ = relocationOffset.getZ(); + final int sourceMinSection = tag.contains("min_section_y") + ? tag.getInt("min_section_y") + : level.getMinSection(); + final int blockOffsetY = relocationOffset.getY(); if (tag.contains("biome")) { final ResourceLocation location = ResourceLocation.tryParse(tag.getString("biome")); @@ -458,7 +492,13 @@ public void load(final CompoundTag tag) { boolean hasLit = false; for (final String sectionKey : sectionsTag.getAllKeys()) { - final int yIndex = Integer.parseInt(sectionKey); + final int sourceSectionY = sourceMinSection + Integer.parseInt(sectionKey); + final int destinationSectionY = sourceSectionY + Math.floorDiv(blockOffsetY, 16); + final int yIndex = level.getSectionIndexFromSectionY(destinationSectionY); + if (yIndex < 0 || yIndex >= chunk.getSectionsCount()) { + throw new IllegalArgumentException("Serialized section Y " + sourceSectionY + + " is outside destination build height after relocation"); + } final LevelChunkSection[] sections = chunk.getSections(); @@ -497,10 +537,12 @@ public void load(final CompoundTag tag) { if (dataVersion >= 0) { final LevelChunkTicks blockTicks = LevelChunkTicks.load( - chunkTag.getList("block_ticks", Tag.TAG_COMPOUND), id -> BuiltInRegistries.BLOCK.getOptional(ResourceLocation.tryParse(id)), global + relocatePositions(chunkTag.getList("block_ticks", Tag.TAG_COMPOUND), blockOffsetX, blockOffsetY, blockOffsetZ), + id -> BuiltInRegistries.BLOCK.getOptional(ResourceLocation.tryParse(id)), global ); final LevelChunkTicks fluidTicks = LevelChunkTicks.load( - chunkTag.getList("fluid_ticks", Tag.TAG_COMPOUND), id -> BuiltInRegistries.FLUID.getOptional(ResourceLocation.tryParse(id)), global + relocatePositions(chunkTag.getList("fluid_ticks", Tag.TAG_COMPOUND), blockOffsetX, blockOffsetY, blockOffsetZ), + id -> BuiltInRegistries.FLUID.getOptional(ResourceLocation.tryParse(id)), global ); //noinspection unchecked @@ -536,7 +578,10 @@ public void load(final CompoundTag tag) { // Add block entities for (int i = 0; i < blockEntitiesTag.size(); i++) { - final CompoundTag blockEntityTag = blockEntitiesTag.getCompound(i); + final CompoundTag blockEntityTag = blockEntitiesTag.getCompound(i).copy(); + blockEntityTag.putInt("x", blockEntityTag.getInt("x") + blockOffsetX); + blockEntityTag.putInt("y", blockEntityTag.getInt("y") + blockOffsetY); + blockEntityTag.putInt("z", blockEntityTag.getInt("z") + blockOffsetZ); final boolean keepBlockEntityPacked = blockEntityTag.getBoolean("keepPacked"); if (keepBlockEntityPacked) { @@ -654,6 +699,41 @@ public void load(final CompoundTag tag) { } + /** + * Collects root entities stored in this plot's chunks. Passengers are transferred with their vehicle. + */ + public List collectRootEntities() { + final Set entities = new ReferenceOpenHashSet<>(); + final PersistentEntitySectionManager manager = this.getSubLevel().getLevel().entityManager; + for (final PlotChunkHolder chunk : this.getLoadedChunks()) { + final Stream> sections = manager.sectionStorage.getExistingSectionsInChunk(chunk.getPos().toLong()); + for (final EntitySection section : sections.toList()) { + section.getEntities().filter(entity -> !entity.isPassenger()).forEach(entities::add); + } + } + return List.copyOf(entities); + } + + private static ListTag relocatePositions( + final ListTag positions, + final int offsetX, + final int offsetY, + final int offsetZ) { + if (offsetX == 0 && offsetY == 0 && offsetZ == 0) { + return positions; + } + + final ListTag relocated = new ListTag(); + for (final Tag position : positions) { + final CompoundTag relocatedPosition = ((CompoundTag) position).copy(); + relocatedPosition.putInt("x", relocatedPosition.getInt("x") + offsetX); + relocatedPosition.putInt("y", relocatedPosition.getInt("y") + offsetY); + relocatedPosition.putInt("z", relocatedPosition.getInt("z") + offsetZ); + relocated.add(relocatedPosition); + } + return relocated; + } + /** * Handles a change in block-state in the plot at global block position x, y, z. * diff --git a/common/src/main/java/dev/ryanhcode/sable/sublevel/storage/SubLevelRemovalReason.java b/common/src/main/java/dev/ryanhcode/sable/sublevel/storage/SubLevelRemovalReason.java index 66cff570..8b95102b 100644 --- a/common/src/main/java/dev/ryanhcode/sable/sublevel/storage/SubLevelRemovalReason.java +++ b/common/src/main/java/dev/ryanhcode/sable/sublevel/storage/SubLevelRemovalReason.java @@ -9,10 +9,26 @@ public enum SubLevelRemovalReason { /** * The sub-level was removed because it was unloaded, not clearing occupancy data */ - UNLOADED, + UNLOADED(false), /** * The sub-level was removed because it was removed from the container, clearing occupancy data */ - REMOVED + REMOVED(true), + + /** + * The sub-level was replaced by an equivalent instance in another level. + * Its source plot is released without deleting entities as destroyed content. + */ + TRANSFERRED(true); + + private final boolean clearsOccupancy; + + SubLevelRemovalReason(final boolean clearsOccupancy) { + this.clearsOccupancy = clearsOccupancy; + } + + public boolean clearsOccupancy() { + return this.clearsOccupancy; + } } diff --git a/common/src/main/java/dev/ryanhcode/sable/sublevel/storage/serialization/SubLevelSerializer.java b/common/src/main/java/dev/ryanhcode/sable/sublevel/storage/serialization/SubLevelSerializer.java index 0944212d..b064661a 100644 --- a/common/src/main/java/dev/ryanhcode/sable/sublevel/storage/serialization/SubLevelSerializer.java +++ b/common/src/main/java/dev/ryanhcode/sable/sublevel/storage/serialization/SubLevelSerializer.java @@ -9,6 +9,7 @@ import dev.ryanhcode.sable.companion.math.BoundingBox3i; import dev.ryanhcode.sable.companion.math.JOMLConversion; import dev.ryanhcode.sable.companion.math.Pose3d; +import dev.ryanhcode.sable.companion.math.Pose3dc; import dev.ryanhcode.sable.sublevel.ServerSubLevel; import dev.ryanhcode.sable.sublevel.plot.ServerLevelPlot; import dev.ryanhcode.sable.sublevel.storage.SubLevelRemovalReason; @@ -19,11 +20,13 @@ import net.minecraft.nbt.ListTag; import net.minecraft.nbt.NbtUtils; import net.minecraft.nbt.Tag; +import net.minecraft.core.BlockPos; import net.minecraft.server.level.ServerLevel; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.joml.Vector3d; import org.joml.Vector3dc; +import org.joml.Quaterniondc; import java.util.List; import java.util.UUID; @@ -129,13 +132,41 @@ public static SubLevelData fromData(final CompoundTag tag) { * @param halfLoadedSubLevel the half loaded sub-level to fully load */ public static ServerSubLevel fullyLoad(final ServerLevel level, final SubLevelData halfLoadedSubLevel) { + return fullyLoad(level, halfLoadedSubLevel, null, null); + } + + /** + * Loads a serialized sub-level into the first free plot while applying a dimension-transfer transform. + * Velocities are retained exactly and rotated into the destination coordinate system. + * + * @param level the destination level + * @param halfLoadedSubLevel the serialized source data + * @param destinationPose the destination pose + * @param velocityRotation rotation from source-world vectors to destination-world vectors + * @return the replacement sub-level, or {@code null} when allocation fails + */ + public static ServerSubLevel fullyLoadForTransfer( + final ServerLevel level, + final SubLevelData halfLoadedSubLevel, + final Pose3dc destinationPose, + final Quaterniondc velocityRotation) { + return fullyLoad(level, halfLoadedSubLevel, destinationPose, velocityRotation); + } + + private static ServerSubLevel fullyLoad( + final ServerLevel level, + final SubLevelData halfLoadedSubLevel, + @Nullable final Pose3dc destinationPose, + @Nullable final Quaterniondc velocityRotation) { final CompoundTag tag = halfLoadedSubLevel.fullTag(); final CompoundTag plotTag = tag.getCompound("plot"); final int plotX = plotTag.getInt("plot_x"); final int plotZ = plotTag.getInt("plot_z"); - final Pose3d pose = SableNBTUtils.readPose3d(tag.getCompound("pose")); + final Pose3d pose = destinationPose == null + ? SableNBTUtils.readPose3d(tag.getCompound("pose")) + : new Pose3d(destinationPose); final Vector3d position = pose.position(); final Vector3d cor = pose.rotationPoint(); @@ -154,14 +185,31 @@ public static ServerSubLevel fullyLoad(final ServerLevel level, final SubLevelDa final ServerSubLevel subLevel; try { - subLevel = (ServerSubLevel) plotContainer.allocateSubLevel(halfLoadedSubLevel.uuid(), plotX, plotZ, pose); + subLevel = destinationPose == null + ? (ServerSubLevel) plotContainer.allocateSubLevel(halfLoadedSubLevel.uuid(), plotX, plotZ, pose) + : (ServerSubLevel) plotContainer.allocateNewSubLevel(halfLoadedSubLevel.uuid(), pose); } catch (final IllegalArgumentException e) { Sable.LOGGER.error("Failed to load sub-level {}, skipping", halfLoadedSubLevel, e); return null; + } catch (final IllegalStateException e) { + Sable.LOGGER.error("Failed to allocate a plot for sub-level {}, skipping", halfLoadedSubLevel, e); + return null; } final ServerLevelPlot plot = subLevel.getPlot(); - plot.load(plotTag); + if (destinationPose != null) { + final BlockPos relocationOffset = plot.getRelocationOffset(plotTag); + pose.rotationPoint().add( + relocationOffset.getX(), + relocationOffset.getY(), + relocationOffset.getZ()); + } + try { + plot.load(plotTag); + } catch (final RuntimeException e) { + plotContainer.removeSubLevel(subLevel, SubLevelRemovalReason.REMOVED); + throw e; + } if (plot.getBoundingBox() == BoundingBox3i.EMPTY || plot.getBoundingBox().volume() <= 0) { Sable.LOGGER.error("Failed to load sub-level, invalid plot bounds: {}", plot.getBoundingBox() == BoundingBox3i.EMPTY ? "EMPTY" : plot.getBoundingBox()); @@ -174,17 +222,25 @@ public static ServerSubLevel fullyLoad(final ServerLevel level, final SubLevelDa physicsSystem.getPipeline().teleport(subLevel, position, pose.orientation()); subLevel.updateLastPose(); - Vector3dc linearVelocity = JOMLConversion.ZERO; - Vector3dc angularVelocity = JOMLConversion.ZERO; + final double velocityMultiplier = velocityRotation == null + ? SableConfig.VELOCITY_RETAINED_ON_LOAD.getAsDouble() + : 1.0; + Vector3d linearVelocity = new Vector3d(); + Vector3d angularVelocity = new Vector3d(); if (tag.contains("linear_velocity")) { linearVelocity = SableNBTUtils.readVector3d(tag.getCompound("linear_velocity")) - .mul(SableConfig.VELOCITY_RETAINED_ON_LOAD.getAsDouble()); + .mul(velocityMultiplier); } if (tag.contains("angular_velocity")) { angularVelocity = SableNBTUtils.readVector3d(tag.getCompound("angular_velocity")) - .mul(SableConfig.VELOCITY_RETAINED_ON_LOAD.getAsDouble()); + .mul(velocityMultiplier); + } + + if (velocityRotation != null) { + velocityRotation.transform(linearVelocity); + velocityRotation.transform(angularVelocity); } physicsSystem.getPipeline().addLinearAndAngularVelocity(subLevel, linearVelocity, angularVelocity); diff --git a/common/src/main/java/dev/ryanhcode/sable/sublevel/system/SubLevelTrackingSystem.java b/common/src/main/java/dev/ryanhcode/sable/sublevel/system/SubLevelTrackingSystem.java index ebb3b74a..d76e0d57 100644 --- a/common/src/main/java/dev/ryanhcode/sable/sublevel/system/SubLevelTrackingSystem.java +++ b/common/src/main/java/dev/ryanhcode/sable/sublevel/system/SubLevelTrackingSystem.java @@ -106,6 +106,15 @@ private void sendFullSync(final ServerPlayer player, final ServerSubLevel subLev final LevelPlot plot = subLevel.getPlot(); final Collection chunks = plot.getLoadedChunks(); + dev.ryanhcode.sable.Sable.LOGGER.debug( + "Sending full sync for sub-level {} to player {}: dimension={}, pose={}, bounds={}, plot={}, chunks={}", + subLevel.getUniqueId(), + player.getGameProfile().getName(), + this.level.dimension().location(), + subLevel.logicalPose(), + subLevel.boundingBox(), + plot.plotPos, + chunks.size()); final ObjectList> packets = new ObjectArrayList<>(3 + chunks.size()); packets.add(new ClientboundCustomPayloadPacket(new ClientboundStartTrackingSubLevelPacket(l, subLevel.getUniqueId(), subLevel.lastPose(), subLevel.logicalPose(), plot.getBoundingBox(), subLevel.getName(), this.interpolationTick))); @@ -136,6 +145,7 @@ private void sendRemoval(final VeilPacketManager.PacketSink sink, final ServerSu @Override public void tick(final SubLevelContainer container) { + final Set fullSyncPlayers = new ObjectOpenHashSet<>(); for (final SubLevel subLevel : this.additionQueue) { // If the sub-level has been removed before we could even send it to clients, skip it if (subLevel.isRemoved()) { @@ -170,6 +180,7 @@ public void tick(final SubLevelContainer container) { } this.sendFullSync(player, serverSubLevel, extraPacket); + fullSyncPlayers.add(uuid); } serverSubLevel.clearSplitFrom(); @@ -216,13 +227,14 @@ public void tick(final SubLevelContainer container) { if (this.shouldLoad(player, entityPos) && !tracking.contains(uuid)) { tracking.add(uuid); this.sendFullSync(player, serverSubLevel, null); + fullSyncPlayers.add(uuid); } } } // send positional updates separately this.sendBoundsUpdates(container); - this.sendMovementUpdates(container); + this.sendMovementUpdates(container, fullSyncPlayers); } /** @@ -258,7 +270,7 @@ public int getInterpolationTick() { * * @param container the sublevels to send updates for */ - private void sendMovementUpdates(final SubLevelContainer container) { + private void sendMovementUpdates(final SubLevelContainer container, final Set fullSyncPlayers) { // we want to batch updates we send to players, so we'll collect them here final Map> movementUpdates = new Object2ObjectOpenHashMap<>(); @@ -363,7 +375,9 @@ private void sendMovementUpdates(final SubLevelContainer container) { final int maxBatchSize = 16; final SableUDPServer udpServer = SableUDPServer.getServer(this.level.getServer()); - if (udpServer != null && udpServer.isConnectedTo(player)) { + // Keep the first snapshot ordered after the TCP full-sync bundle. UDP, especially the local + // singleplayer event loop, can otherwise deliver movement before the client creates the sub-level. + if (udpServer != null && udpServer.isConnectedTo(player) && !fullSyncPlayers.contains(uuid)) { final Iterator iter = entries.iterator(); udpServer.sendUDPPacket(player, new ClientboundSableSnapshotInfoDualPacket(msSinceLastSend, this.interpolationTick, false), true); diff --git a/common/src/main/java/dev/ryanhcode/sable/sublevel/tracking_points/SubLevelTrackingPointSavedData.java b/common/src/main/java/dev/ryanhcode/sable/sublevel/tracking_points/SubLevelTrackingPointSavedData.java index ee7155f9..5eec8558 100644 --- a/common/src/main/java/dev/ryanhcode/sable/sublevel/tracking_points/SubLevelTrackingPointSavedData.java +++ b/common/src/main/java/dev/ryanhcode/sable/sublevel/tracking_points/SubLevelTrackingPointSavedData.java @@ -30,6 +30,7 @@ import org.joml.Vector3dc; import java.util.Map; +import java.util.List; import java.util.UUID; public class SubLevelTrackingPointSavedData extends SavedData implements SubLevelObserver { @@ -51,6 +52,35 @@ public static SubLevelTrackingPointSavedData getOrLoad(final ServerLevel level) SubLevelTrackingPointSavedData.FILE_ID); } + /** + * Moves tracking points owned by a transferred sub-level to the destination level data. + */ + public static void transferSubLevelPoints(final ServerSubLevel source, final ServerSubLevel destination) { + final SubLevelTrackingPointSavedData sourceData = getOrLoad(source.getLevel()); + final SubLevelTrackingPointSavedData destinationData = getOrLoad(destination.getLevel()); + final List transferred = new ObjectArrayList<>(); + + for (final Map.Entry entry : sourceData.trackingPoints.entrySet()) { + final TrackingPoint point = entry.getValue(); + if (point.inSubLevel() && source.getUniqueId().equals(point.subLevelID())) { + final Vector3d placeholder = destination.logicalPose().transformPosition(new Vector3d(point.point())); + destinationData.trackingPoints.put(entry.getKey(), new TrackingPoint( + true, + destination.getUniqueId(), + null, + new Vector3d(point.point()), + placeholder)); + transferred.add(entry.getKey()); + } + } + + if (!transferred.isEmpty()) { + transferred.forEach(sourceData.trackingPoints::remove); + sourceData.setDirty(true); + destinationData.setDirty(true); + } + } + private static SubLevelTrackingPointSavedData load(final ServerLevel level, final CompoundTag tag) { final SubLevelTrackingPointSavedData data = new SubLevelTrackingPointSavedData(level); @@ -256,4 +286,4 @@ public void removeTrackingPoint(final UUID key) { public TrackingPoint getTrackingPoint(final UUID uuid) { return this.trackingPoints.get(uuid); } -} \ No newline at end of file +} diff --git a/neoforge/src/main/java/dev/ryanhcode/sable/neoforge/gametest/SableTestHelper.java b/neoforge/src/main/java/dev/ryanhcode/sable/neoforge/gametest/SableTestHelper.java index ba641844..3ba46e33 100644 --- a/neoforge/src/main/java/dev/ryanhcode/sable/neoforge/gametest/SableTestHelper.java +++ b/neoforge/src/main/java/dev/ryanhcode/sable/neoforge/gametest/SableTestHelper.java @@ -17,14 +17,23 @@ import org.joml.Vector3d; import org.joml.Vector3dc; import java.util.function.Consumer; +import java.util.UUID; public final class SableTestHelper { public static ServerSubLevel spawnSubLevel(final SubLevelContainer plotContainer, final Vector3dc pos, final Consumer setter) { + return spawnSubLevel(plotContainer, UUID.randomUUID(), pos, setter); + } + + public static ServerSubLevel spawnSubLevel( + final SubLevelContainer plotContainer, + final UUID uuid, + final Vector3dc pos, + final Consumer setter) { final Pose3d pose = new Pose3d(); pose.position().set(pos); - final SubLevel subLevel = plotContainer.allocateNewSubLevel(pose); + final SubLevel subLevel = plotContainer.allocateNewSubLevel(uuid, pose); final LevelPlot plot = subLevel.getPlot(); final ChunkPos center = plot.getCenterChunk(); @@ -39,6 +48,14 @@ public static ServerSubLevel spawnSingleBlockSubLevel(final SubLevelContainer pl return spawnSubLevel(plotContainer, pos, accessor -> accessor.setBlock(BlockPos.ZERO, state, 3)); } + public static ServerSubLevel spawnSingleBlockSubLevel( + final SubLevelContainer plotContainer, + final UUID uuid, + final Vector3dc pos, + final BlockState state) { + return spawnSubLevel(plotContainer, uuid, pos, accessor -> accessor.setBlock(BlockPos.ZERO, state, 3)); + } + public static Vector3d absoluteDirection(final GameTestHelper helper, final Vector3dc localDirection) { return new Vector3d(localDirection).rotateY(-getAngle(helper.getTestRotation())); } diff --git a/neoforge/src/main/java/dev/ryanhcode/sable/neoforge/gametest/SubLevelTransferTest.java b/neoforge/src/main/java/dev/ryanhcode/sable/neoforge/gametest/SubLevelTransferTest.java new file mode 100644 index 00000000..de41875f --- /dev/null +++ b/neoforge/src/main/java/dev/ryanhcode/sable/neoforge/gametest/SubLevelTransferTest.java @@ -0,0 +1,192 @@ +package dev.ryanhcode.sable.neoforge.gametest; + +import static dev.ryanhcode.sable.neoforge.gametest.SableTestHelper.removeSubLevel; +import static dev.ryanhcode.sable.neoforge.gametest.SableTestHelper.spawnSingleBlockSubLevel; + +import dev.ryanhcode.sable.Sable; +import dev.ryanhcode.sable.api.physics.handle.RigidBodyHandle; +import dev.ryanhcode.sable.api.sublevel.ServerSubLevelContainer; +import dev.ryanhcode.sable.api.sublevel.SubLevelContainer; +import dev.ryanhcode.sable.api.sublevel.SubLevelTransferResult; +import dev.ryanhcode.sable.api.sublevel.SubLevelTransferService; +import dev.ryanhcode.sable.companion.math.Pose3d; +import dev.ryanhcode.sable.sublevel.ServerSubLevel; +import net.minecraft.core.BlockPos; +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.phys.Vec3; +import net.neoforged.neoforge.gametest.GameTestHolder; +import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; +import org.joml.Vector3d; + +import java.util.UUID; + +@GameTestHolder(Sable.MOD_ID) +@PrefixGameTestTemplate(false) +public final class SubLevelTransferTest { + private static final String TEMPLATE = "assemblytest.brittlebreak"; + + private SubLevelTransferTest() { + } + + @GameTest(template = TEMPLATE) + public static void transfersAcrossDimensions(final GameTestHelper helper) { + final ServerLevel sourceLevel = helper.getLevel(); + final ServerLevel destinationLevel = sourceLevel.getServer().getLevel(Level.END); + if (destinationLevel == null) { + helper.fail("The End level is unavailable"); + return; + } + + final ServerSubLevelContainer sourceContainer = requireContainer(sourceLevel); + final ServerSubLevelContainer destinationContainer = requireContainer(destinationLevel); + final Vec3 sourceCenter = helper.absolutePos(BlockPos.ZERO).getCenter(); + final Vector3d sourcePosition = new Vector3d(sourceCenter.x, sourceCenter.y, sourceCenter.z); + final ServerSubLevel source = spawnSingleBlockSubLevel( + sourceContainer, sourcePosition, Blocks.DIAMOND_BLOCK.defaultBlockState()); + final ServerSubLevel destinationOccupant = spawnSingleBlockSubLevel( + destinationContainer, new Vector3d(0.5, 80.0, 0.5), Blocks.STONE.defaultBlockState()); + final UUID uuid = source.getUniqueId(); + final CompoundTag userData = new CompoundTag(); + userData.putString("transfer_test", "preserved"); + source.setUserDataTag(userData); + + final Entity plotEntity = EntityType.ARMOR_STAND.create(sourceLevel); + if (plotEntity == null) { + helper.fail("Unable to create plot entity"); + return; + } + final Vec3 sourceEntityPosition = source.getPlot().getCenterBlock().getCenter(); + plotEntity.setPos(sourceEntityPosition); + sourceLevel.addFreshEntity(plotEntity); + final UUID plotEntityUuid = plotEntity.getUUID(); + + final RigidBodyHandle sourceHandle = RigidBodyHandle.of(source); + if (sourceHandle == null) { + helper.fail("Source rigid body was not created"); + return; + } + sourceHandle.addLinearAndAngularVelocity(new Vector3d(2.0, 3.0, 4.0), new Vector3d(0.0, 0.0, 2.0)); + + final Pose3d destinationPose = new Pose3d(source.logicalPose()); + destinationPose.position().set(20.5, 90.0, -12.5); + final BlockPos sourcePlotCenter = source.getPlot().getCenterBlock(); + final SubLevelTransferResult result = SubLevelTransferService.transfer(source, destinationLevel, destinationPose); + final ServerSubLevel replacement = result.root(); + + if (!source.isRemoved() || sourceContainer.getSubLevel(uuid) != null) { + helper.fail("Source sub-level remained loaded after transfer"); + return; + } + if (destinationContainer.getSubLevel(uuid) != replacement || !replacement.getUniqueId().equals(uuid)) { + helper.fail("Persistent UUID was not preserved in the destination"); + return; + } + if (!replacement.getPlot().getEmbeddedLevelAccessor().getBlockState(BlockPos.ZERO).is(Blocks.DIAMOND_BLOCK)) { + helper.fail("Transferred block contents were not preserved: state=" + + replacement.getPlot().getEmbeddedLevelAccessor().getBlockState(BlockPos.ZERO) + + ", sourcePlot=" + source.getPlot().plotPos + + ", destinationPlot=" + replacement.getPlot().plotPos + + ", destinationBounds=" + replacement.getPlot().getBoundingBox()); + return; + } + if (replacement.getUserDataTag() == null + || !"preserved".equals(replacement.getUserDataTag().getString("transfer_test"))) { + helper.fail("Transferred user data was not preserved"); + return; + } + final Entity transferredEntity = destinationLevel.getEntity(plotEntityUuid); + final Vec3 destinationEntityPosition = replacement.getPlot().getCenterBlock().getCenter(); + if (transferredEntity == null || transferredEntity.position().distanceTo(destinationEntityPosition) > 0.001) { + helper.fail("Plot entity was not transferred with its sub-level"); + return; + } + if (replacement.logicalPose().position().distance(destinationPose.position()) > 0.001) { + helper.fail("Destination pose was not applied"); + return; + } + final BlockPos destinationPlotCenter = replacement.getPlot().getCenterBlock(); + final Vector3d expectedRotationPoint = new Vector3d(destinationPose.rotationPoint()).add( + destinationPlotCenter.getX() - sourcePlotCenter.getX(), + destinationPlotCenter.getY() - sourcePlotCenter.getY(), + destinationPlotCenter.getZ() - sourcePlotCenter.getZ()); + if (sourcePlotCenter.equals(destinationPlotCenter)) { + helper.fail("Transfer test did not allocate a relocated destination plot"); + return; + } + if (replacement.logicalPose().rotationPoint().distance(expectedRotationPoint) > 0.001) { + helper.fail("Rotation point was not relocated with plot contents: expected=" + + expectedRotationPoint + ", actual=" + replacement.logicalPose().rotationPoint()); + return; + } + + final RigidBodyHandle replacementHandle = RigidBodyHandle.of(replacement); + if (replacementHandle == null + || replacementHandle.getLinearVelocity(new Vector3d()).distance(new Vector3d(2.0, 3.0, 4.0)) > 0.001 + || replacementHandle.getAngularVelocity(new Vector3d()).distance(new Vector3d(0.0, 0.0, 2.0)) > 0.001) { + helper.fail("Velocity was not preserved across transfer"); + return; + } + + removeSubLevel(destinationContainer, replacement); + removeSubLevel(destinationContainer, destinationOccupant); + helper.succeed(); + } + + @GameTest(template = TEMPLATE) + public static void rejectsDestinationUuidCollision(final GameTestHelper helper) { + final ServerLevel sourceLevel = helper.getLevel(); + final ServerLevel destinationLevel = sourceLevel.getServer().getLevel(Level.END); + if (destinationLevel == null) { + helper.fail("The End level is unavailable"); + return; + } + + final ServerSubLevelContainer sourceContainer = requireContainer(sourceLevel); + final ServerSubLevelContainer destinationContainer = requireContainer(destinationLevel); + final Vec3 sourceCenter = helper.absolutePos(BlockPos.ZERO).getCenter(); + final Vector3d sourcePosition = new Vector3d(sourceCenter.x, sourceCenter.y, sourceCenter.z); + final ServerSubLevel source = spawnSingleBlockSubLevel( + sourceContainer, sourcePosition, Blocks.GOLD_BLOCK.defaultBlockState()); + final ServerSubLevel collision = spawnSingleBlockSubLevel( + destinationContainer, + source.getUniqueId(), + new Vector3d(0.5, 80.0, 0.5), + Blocks.IRON_BLOCK.defaultBlockState()); + + try { + SubLevelTransferService.transfer(source, destinationLevel, new Pose3d(source.logicalPose())); + helper.fail("Transfer succeeded despite a destination UUID collision"); + return; + } catch (final IllegalStateException expected) { + // Expected preflight rejection. + } + + if (source.isRemoved() || sourceContainer.getSubLevel(source.getUniqueId()) != source) { + helper.fail("Rejected transfer modified the source sub-level"); + return; + } + if (destinationContainer.getSubLevel(collision.getUniqueId()) != collision) { + helper.fail("Rejected transfer modified the destination sub-level"); + return; + } + + removeSubLevel(sourceContainer, source); + removeSubLevel(destinationContainer, collision); + helper.succeed(); + } + + private static ServerSubLevelContainer requireContainer(final ServerLevel level) { + final ServerSubLevelContainer container = SubLevelContainer.getContainer(level); + if (container == null) { + throw new IllegalStateException("Missing sub-level container in " + level.dimension().location()); + } + return container; + } +}