From de236fd4aa46d67f73b0b349cce2cdea5d397b77 Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:44:58 +0200 Subject: [PATCH 1/8] =?UTF-8?q?=E2=9C=A8=20feat(NmsPlayerBridge):=20add=20?= =?UTF-8?q?vehicle=20and=20entity=20resync=20methods=20for=20seamless=20tr?= =?UTF-8?q?ansfers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - implement resyncVehicleState to reconcile player's vehicle state after server moves - add resyncEntityForViewer to refresh entity tracking for individual players - introduce resyncPlayerState to restore player abilities lost during seamless transitions --- .../V1_21_11SurfPaperNmsPlayerBridgeImpl.kt | 82 +++++++++++++++++++ .../V26_1SurfPaperNmsPlayerBridgeImpl.kt | 82 +++++++++++++++++++ .../V26_2SurfPaperNmsPlayerBridgeImpl.kt | 82 +++++++++++++++++++ .../nms/bridges/SurfPaperNmsPlayerBridge.kt | 57 +++++++++++++ 4 files changed, 303 insertions(+) diff --git a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsPlayerBridgeImpl.kt b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsPlayerBridgeImpl.kt index b453a647..4c2c4054 100644 --- a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsPlayerBridgeImpl.kt +++ b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsPlayerBridgeImpl.kt @@ -27,12 +27,14 @@ import net.minecraft.nbt.CompoundTag import net.minecraft.nbt.NbtIo import net.minecraft.network.chat.* import net.minecraft.network.protocol.game.ClientboundPlayerChatPacket +import net.minecraft.network.protocol.game.ClientboundSetPassengersPacket import net.minecraft.server.MinecraftServer import net.minecraft.server.players.NameAndId import net.minecraft.util.ProblemReporter import net.minecraft.util.ProblemReporter.ScopedCollector import net.minecraft.util.Util import net.minecraft.world.ItemStackWithSlot +import net.minecraft.world.entity.Entity as NmsEntity import net.minecraft.world.entity.EntityEquipment import net.minecraft.world.entity.LivingEntity import net.minecraft.world.entity.npc.InventoryCarrier @@ -41,6 +43,7 @@ import net.minecraft.world.entity.player.ProfilePublicKey import net.minecraft.world.level.storage.* import org.bukkit.craftbukkit.CraftEquipmentSlot import org.bukkit.craftbukkit.inventory.CraftItemStack +import org.bukkit.entity.Entity import org.bukkit.entity.Player import org.bukkit.inventory.EquipmentSlot import org.bukkit.inventory.ItemStack @@ -90,6 +93,85 @@ class V1_21_11SurfPaperNmsPlayerBridgeImpl : SurfPaperNmsPlayerBridge { } } + @Suppress("USELESS_ELVIS") + override fun resyncVehicleState(player: Player, swallowExceptions: Boolean): Int { + val nmsPlayer = player.toNms() + val connection = nmsPlayer.connection ?: return 0 + val chunkMap = nmsPlayer.level().chunkSource.chunkMap + + val root = nmsPlayer.rootVehicle + if (root === nmsPlayer && nmsPlayer.passengers.isEmpty()) { + // Player is neither riding anything nor carrying passengers -> nothing to reconcile. + return 0 + } + + // The whole connected vehicle tree, in a stable order (root first). + val chain = LinkedHashSet() + chain.add(root) + for (passenger in root.indirectPassengers) { + chain.add(passenger) + } + + // Pass 1: re-pair every entity of the tree (except the player itself) so the client gets a + // clean copy carrying the server's current network id, metadata, equipment and links. + var resynced = 0 + for (entity in chain) { + if (entity === nmsPlayer) continue + val tracker = chunkMap.entityMap.get(entity.id) ?: continue + try { + if (!tracker.seenBy.contains(connection)) { + tracker.seenBy.add(connection) + } + tracker.serverEntity.removePairing(nmsPlayer) + tracker.serverEntity.addPairing(nmsPlayer) + resynced++ + } catch (e: Throwable) { + if (!swallowExceptions) throw e + } + } + + // Pass 2: re-assert every passenger link now that all involved entities are guaranteed to + // exist on the client. This re-mounts the player and resolves stacked vehicles in order. + for (entity in chain) { + if (entity.passengers.isEmpty()) continue + try { + connection.send(ClientboundSetPassengersPacket(entity)) + } catch (e: Throwable) { + if (!swallowExceptions) throw e + } + } + + return resynced + } + + @Suppress("USELESS_ELVIS") + override fun resyncEntityForViewer(viewer: Player, entity: Entity, swallowExceptions: Boolean): Boolean { + val nmsViewer = viewer.toNms() + val connection = nmsViewer.connection ?: return false + val nmsEntity = entity.toNms() + if (nmsEntity === nmsViewer) return false + + val tracker = nmsViewer.level().chunkSource.chunkMap.entityMap.get(nmsEntity.id) ?: return false + return try { + if (!tracker.seenBy.contains(connection)) { + tracker.seenBy.add(connection) + } + tracker.serverEntity.removePairing(nmsViewer) + tracker.serverEntity.addPairing(nmsViewer) + true + } catch (e: Throwable) { + if (!swallowExceptions) throw e + false + } + } + + @Suppress("USELESS_ELVIS") + override fun resyncPlayerState(player: Player) { + val nmsPlayer = player.toNms() + nmsPlayer.connection ?: return + nmsPlayer.onUpdateAbilities() + } + override fun getRemoteChatSessionData(player: Player): RemoteChatSessionData? { val session = player.toNms().chatSession?.asData() ?: return null val profilePublicKey = session.profilePublicKey() diff --git a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsPlayerBridgeImpl.kt b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsPlayerBridgeImpl.kt index 07a80832..acc98cb3 100644 --- a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsPlayerBridgeImpl.kt +++ b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsPlayerBridgeImpl.kt @@ -26,12 +26,14 @@ import net.minecraft.nbt.NbtIo import net.minecraft.network.chat.* import net.minecraft.network.protocol.game.ClientboundPlayerChatPacket import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket +import net.minecraft.network.protocol.game.ClientboundSetPassengersPacket import net.minecraft.server.MinecraftServer import net.minecraft.server.players.NameAndId import net.minecraft.util.ProblemReporter import net.minecraft.util.ProblemReporter.ScopedCollector import net.minecraft.util.Util import net.minecraft.world.ItemStackWithSlot +import net.minecraft.world.entity.Entity as NmsEntity import net.minecraft.world.entity.EntityEquipment import net.minecraft.world.entity.LivingEntity import net.minecraft.world.entity.npc.InventoryCarrier @@ -40,6 +42,7 @@ import net.minecraft.world.entity.player.ProfilePublicKey import net.minecraft.world.level.storage.* import org.bukkit.craftbukkit.CraftEquipmentSlot import org.bukkit.craftbukkit.inventory.CraftItemStack +import org.bukkit.entity.Entity import org.bukkit.entity.Player import org.bukkit.inventory.EquipmentSlot import org.bukkit.inventory.ItemStack @@ -90,6 +93,85 @@ class V26_1SurfPaperNmsPlayerBridgeImpl : SurfPaperNmsPlayerBridge { } } + @Suppress("USELESS_ELVIS") + override fun resyncVehicleState(player: Player, swallowExceptions: Boolean): Int { + val nmsPlayer = player.toNms() + val connection = nmsPlayer.connection ?: return 0 + val chunkMap = nmsPlayer.level().chunkSource.chunkMap + + val root = nmsPlayer.rootVehicle + if (root === nmsPlayer && nmsPlayer.passengers.isEmpty()) { + // Player is neither riding anything nor carrying passengers -> nothing to reconcile. + return 0 + } + + // The whole connected vehicle tree, in a stable order (root first). + val chain = LinkedHashSet() + chain.add(root) + for (passenger in root.indirectPassengers) { + chain.add(passenger) + } + + // Pass 1: re-pair every entity of the tree (except the player itself) so the client gets a + // clean copy carrying the server's current network id, metadata, equipment and links. + var resynced = 0 + for (entity in chain) { + if (entity === nmsPlayer) continue + val tracker = chunkMap.entityMap.get(entity.id) ?: continue + try { + if (!tracker.seenBy.contains(connection)) { + tracker.seenBy.add(connection) + } + tracker.serverEntity.removePairing(nmsPlayer) + tracker.serverEntity.addPairing(nmsPlayer) + resynced++ + } catch (e: Throwable) { + if (!swallowExceptions) throw e + } + } + + // Pass 2: re-assert every passenger link now that all involved entities are guaranteed to + // exist on the client. This re-mounts the player and resolves stacked vehicles in order. + for (entity in chain) { + if (entity.passengers.isEmpty()) continue + try { + connection.send(ClientboundSetPassengersPacket(entity)) + } catch (e: Throwable) { + if (!swallowExceptions) throw e + } + } + + return resynced + } + + @Suppress("USELESS_ELVIS") + override fun resyncEntityForViewer(viewer: Player, entity: Entity, swallowExceptions: Boolean): Boolean { + val nmsViewer = viewer.toNms() + val connection = nmsViewer.connection ?: return false + val nmsEntity = entity.toNms() + if (nmsEntity === nmsViewer) return false + + val tracker = nmsViewer.level().chunkSource.chunkMap.entityMap.get(nmsEntity.id) ?: return false + return try { + if (!tracker.seenBy.contains(connection)) { + tracker.seenBy.add(connection) + } + tracker.serverEntity.removePairing(nmsViewer) + tracker.serverEntity.addPairing(nmsViewer) + true + } catch (e: Throwable) { + if (!swallowExceptions) throw e + false + } + } + + @Suppress("USELESS_ELVIS") + override fun resyncPlayerState(player: Player) { + val nmsPlayer = player.toNms() + nmsPlayer.connection ?: return + nmsPlayer.onUpdateAbilities() + } + @Suppress("USELESS_ELVIS") override fun getRemoteChatSessionData(player: Player): RemoteChatSessionData? { val connection = player.toNms().connection ?: return null diff --git a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsPlayerBridgeImpl.kt b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsPlayerBridgeImpl.kt index 3f6d94e5..18e7c2a8 100644 --- a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsPlayerBridgeImpl.kt +++ b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsPlayerBridgeImpl.kt @@ -26,12 +26,14 @@ import net.minecraft.nbt.NbtIo import net.minecraft.network.chat.* import net.minecraft.network.protocol.game.ClientboundPlayerChatPacket import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket +import net.minecraft.network.protocol.game.ClientboundSetPassengersPacket import net.minecraft.server.MinecraftServer import net.minecraft.server.players.NameAndId import net.minecraft.util.ProblemReporter import net.minecraft.util.ProblemReporter.ScopedCollector import net.minecraft.util.Util import net.minecraft.world.ItemStackWithSlot +import net.minecraft.world.entity.Entity as NmsEntity import net.minecraft.world.entity.EntityEquipment import net.minecraft.world.entity.LivingEntity import net.minecraft.world.entity.npc.InventoryCarrier @@ -40,6 +42,7 @@ import net.minecraft.world.entity.player.ProfilePublicKey import net.minecraft.world.level.storage.* import org.bukkit.craftbukkit.CraftEquipmentSlot import org.bukkit.craftbukkit.inventory.CraftItemStack +import org.bukkit.entity.Entity import org.bukkit.entity.Player import org.bukkit.inventory.EquipmentSlot import org.bukkit.inventory.ItemStack @@ -90,6 +93,85 @@ class V26_2SurfPaperNmsPlayerBridgeImpl : SurfPaperNmsPlayerBridge { } } + @Suppress("USELESS_ELVIS") + override fun resyncVehicleState(player: Player, swallowExceptions: Boolean): Int { + val nmsPlayer = player.toNms() + val connection = nmsPlayer.connection ?: return 0 + val chunkMap = nmsPlayer.level().chunkSource.chunkMap + + val root = nmsPlayer.rootVehicle + if (root === nmsPlayer && nmsPlayer.passengers.isEmpty()) { + // Player is neither riding anything nor carrying passengers -> nothing to reconcile. + return 0 + } + + // The whole connected vehicle tree, in a stable order (root first). + val chain = LinkedHashSet() + chain.add(root) + for (passenger in root.indirectPassengers) { + chain.add(passenger) + } + + // Pass 1: re-pair every entity of the tree (except the player itself) so the client gets a + // clean copy carrying the server's current network id, metadata, equipment and links. + var resynced = 0 + for (entity in chain) { + if (entity === nmsPlayer) continue + val tracker = chunkMap.entityMap.get(entity.id) ?: continue + try { + if (!tracker.seenBy.contains(connection)) { + tracker.seenBy.add(connection) + } + tracker.serverEntity.removePairing(nmsPlayer) + tracker.serverEntity.addPairing(nmsPlayer) + resynced++ + } catch (e: Throwable) { + if (!swallowExceptions) throw e + } + } + + // Pass 2: re-assert every passenger link now that all involved entities are guaranteed to + // exist on the client. This re-mounts the player and resolves stacked vehicles in order. + for (entity in chain) { + if (entity.passengers.isEmpty()) continue + try { + connection.send(ClientboundSetPassengersPacket(entity)) + } catch (e: Throwable) { + if (!swallowExceptions) throw e + } + } + + return resynced + } + + @Suppress("USELESS_ELVIS") + override fun resyncEntityForViewer(viewer: Player, entity: Entity, swallowExceptions: Boolean): Boolean { + val nmsViewer = viewer.toNms() + val connection = nmsViewer.connection ?: return false + val nmsEntity = entity.toNms() + if (nmsEntity === nmsViewer) return false + + val tracker = nmsViewer.level().chunkSource.chunkMap.entityMap.get(nmsEntity.id) ?: return false + return try { + if (!tracker.seenBy.contains(connection)) { + tracker.seenBy.add(connection) + } + tracker.serverEntity.removePairing(nmsViewer) + tracker.serverEntity.addPairing(nmsViewer) + true + } catch (e: Throwable) { + if (!swallowExceptions) throw e + false + } + } + + @Suppress("USELESS_ELVIS") + override fun resyncPlayerState(player: Player) { + val nmsPlayer = player.toNms() + nmsPlayer.connection ?: return + nmsPlayer.onUpdateAbilities() + } + @Suppress("USELESS_ELVIS") override fun getRemoteChatSessionData(player: Player): RemoteChatSessionData? { val connection = player.toNms().connection ?: return null diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge.kt index 7c92ade4..1eb10b13 100644 --- a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge.kt +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.CoroutineScope import net.kyori.adventure.chat.ChatType import net.kyori.adventure.chat.SignedMessage import net.kyori.adventure.text.Component +import org.bukkit.entity.Entity import org.bukkit.entity.Player import org.bukkit.inventory.EntityEquipment import org.bukkit.inventory.ItemStack @@ -25,6 +26,62 @@ interface SurfPaperNmsPlayerBridge { fun removeAllTrackedEntities(player: Player, swallowExceptions: Boolean = true) fun removeAllTrackedPlayers(player: Player, swallowExceptions: Boolean = true) + /** + * Re-sends the full client-side tracking and passenger state for [player]'s own vehicle + * chain so that the client and the server agree again on what the player is riding. + * + * This exists for *seamless* server moves (e.g. shard transfers) where the client keeps its + * existing PLAY-state world because the login/respawn/reconfiguration packets are suppressed + * upstream. On the target server the vehicle is re-created from the player's persisted + * `RootVehicle` data, but with a **fresh network entity id** and a freshly initialised entity + * tracker. The relationship packet ([net.minecraft.network.protocol.game.ClientboundSetPassengersPacket]) + * is normally only emitted when the passenger list *changes*, so after a seamless move the + * client can be left believing it is (not) riding while the server believes the opposite. + * + * For every entity in the player's vehicle tree (the root vehicle and all of its + * (indirect) passengers, except the player itself) this destroys and immediately re-creates + * the client pairing — i.e. it sends a remove packet followed by the full add-pairing bundle + * (spawn, metadata, equipment, attributes and the passenger links). Afterwards it re-asserts + * every passenger link once more, after all involved entities are guaranteed to exist on the + * client, so that stacked vehicles resolve in the correct order. + * + * Must be called on the owning region/entity tick thread. + * + * @param player the transferred player whose riding state should be reconciled + * @param swallowExceptions when true, per-entity failures are ignored instead of propagated + * @return the number of vehicle-chain entities that were resynced; `0` when the player is + * neither riding anything nor carrying any passengers (nothing to do) + */ + fun resyncVehicleState(player: Player, swallowExceptions: Boolean = true): Int + + /** + * Re-pairs a single [entity] for [viewer] so the viewer's client holds the entity with the + * server's *current* network id, metadata and passenger links. + * + * This destroys the existing client pairing and re-creates it (remove packet followed by the + * full add-pairing bundle). If the viewer was not yet part of the entity tracker's seen-by + * set it is added, so subsequent incremental updates keep flowing. Useful to repair an + * individual entity whose id drifted after a seamless server move (which otherwise breaks + * interaction packets that reference the network id). + * + * Must be called on the owning region/entity tick thread. + * + * @param viewer the player that should receive the refreshed entity + * @param entity the entity to re-pair for the viewer + * @param swallowExceptions when true, failures are ignored instead of propagated + * @return true if the entity had an active tracker and was resynced, false otherwise + */ + fun resyncEntityForViewer(viewer: Player, entity: Entity, swallowExceptions: Boolean = true): Boolean + + /** + * Re-sends the connection-bound player state that a normal respawn would refresh but that is + * lost during a seamless server move, namely the player's abilities (fly/allow-fly/walk and + * fly speed). Safe to call repeatedly; it is a no-op when the player has no connection. + * + * Must be called on the owning region/entity tick thread. + */ + fun resyncPlayerState(player: Player) + fun getRemoteChatSessionData(player: Player): RemoteChatSessionData? fun createChatSessionSnapshot(player: Player): PlayerChatSessionSnapshot? From 333c4fa7fc6d23daa17aeeaf1130d1203e4b54a3 Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 23 Jun 2026 08:59:19 +0200 Subject: [PATCH 2/8] =?UTF-8?q?=E2=9C=A8=20feat(entity-bridge):=20add=20ve?= =?UTF-8?q?hicle=20serialization=20and=20restoration=20methods=20for=20pla?= =?UTF-8?q?yer=20mounts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - implement captureVehicleNbt to serialize vehicle and passenger data into NBT format - implement restoreVehicleAndMount to recreate vehicle state and mount player at specified location --- .../V1_21_11SurfPaperNmsEntityBridgeImpl.kt | 87 ++++++++++++++++++ .../V26_1SurfPaperNmsEntityBridgeImpl.kt | 87 ++++++++++++++++++ .../V26_2SurfPaperNmsEntityBridgeImpl.kt | 88 +++++++++++++++++++ .../nms/bridges/SurfPaperNmsEntityBridge.kt | 44 ++++++++++ 4 files changed, 306 insertions(+) diff --git a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsEntityBridgeImpl.kt b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsEntityBridgeImpl.kt index 48e5c5eb..30303cf0 100644 --- a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsEntityBridgeImpl.kt +++ b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsEntityBridgeImpl.kt @@ -12,11 +12,28 @@ import dev.slne.surf.api.paper.util.chunkX import dev.slne.surf.api.paper.util.chunkZ import io.papermc.paper.math.FinePosition import net.kyori.adventure.nbt.CompoundBinaryTag +import net.kyori.adventure.text.logger.slf4j.ComponentLogger +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.DoubleTag +import net.minecraft.nbt.FloatTag +import net.minecraft.nbt.ListTag +import net.minecraft.nbt.NbtAccounter +import net.minecraft.nbt.NbtIo import net.minecraft.server.MinecraftServer import net.minecraft.server.commands.SummonCommand +import net.minecraft.util.ProblemReporter +import net.minecraft.world.entity.EntityProcessor +import net.minecraft.world.entity.EntitySpawnReason +import net.minecraft.world.entity.EntityType as NmsEntityType +import net.minecraft.world.level.storage.TagValueOutput import org.bukkit.World import org.bukkit.entity.Entity import org.bukkit.entity.EntityType +import org.bukkit.entity.Player +import org.bukkit.event.entity.CreatureSpawnEvent +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.UUID @NmsUseWithCaution class V1_21_11SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { @@ -59,4 +76,74 @@ class V1_21_11SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { override fun getById(world: World, id: Int): Entity? { return world.toNms().getEntity(id)?.bukkitEntity } + + override fun captureVehicleNbt(rootVehicle: Entity): ByteArray { + val nmsEntity = rootVehicle.toNms() + val level = nmsEntity.level() + + val tag = CompoundTag() + ProblemReporter.ScopedCollector(VEHICLE_LOGGER).use { reporter -> + val output = TagValueOutput.createWrappingWithContext(reporter, level.registryAccess(), tag) + // Saves "id" + Pos/Motion/Rotation/UUID + non-player passengers; players are excluded. + nmsEntity.save(output) + } + + val out = ByteArrayOutputStream() + NbtIo.writeCompressed(tag, out) + return out.toByteArray() + } + + override fun restoreVehicleAndMount( + player: Player, + nbt: ByteArray, + directVehicleUuid: UUID, + x: Double, + y: Double, + z: Double, + yaw: Float, + pitch: Float, + ): Boolean { + val nmsPlayer = player.toNms() + val level = nmsPlayer.level() + + val tag = NbtIo.readCompressed(ByteArrayInputStream(nbt), NbtAccounter.unlimitedHeap()) + // Override the captured crossing position with the safe target position so the vehicle (and + // therefore the mounted player) does not re-appear on the border. + tag.put("Pos", doubleList(x, y, z)) + tag.put("Rotation", floatList(yaw, pitch)) + + val root = NmsEntityType.loadEntityRecursive( + tag, + level, + EntitySpawnReason.LOAD, + EntityProcessor { entity -> + // add-with-uuid rejects entities whose uuid is already present -> built-in dedup. + if (level.addWithUUID(entity, CreatureSpawnEvent.SpawnReason.MOUNT)) entity else null + } + ) ?: return false + + val directVehicle = if (root.uuid == directVehicleUuid) { + root + } else { + root.indirectPassengers.firstOrNull { it.uuid == directVehicleUuid } ?: root + } + + nmsPlayer.startRiding(directVehicle, true, false) + return nmsPlayer.isPassenger + } + + private fun doubleList(x: Double, y: Double, z: Double) = ListTag().apply { + add(DoubleTag.valueOf(x)) + add(DoubleTag.valueOf(y)) + add(DoubleTag.valueOf(z)) + } + + private fun floatList(yaw: Float, pitch: Float) = ListTag().apply { + add(FloatTag.valueOf(yaw)) + add(FloatTag.valueOf(pitch)) + } + + companion object { + private val VEHICLE_LOGGER = ComponentLogger.logger("SurfPaperNmsEntityBridge Vehicle") + } } diff --git a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsEntityBridgeImpl.kt b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsEntityBridgeImpl.kt index 53265d83..4cd70902 100644 --- a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsEntityBridgeImpl.kt +++ b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsEntityBridgeImpl.kt @@ -12,11 +12,28 @@ import dev.slne.surf.api.paper.util.chunkX import dev.slne.surf.api.paper.util.chunkZ import io.papermc.paper.math.FinePosition import net.kyori.adventure.nbt.CompoundBinaryTag +import net.kyori.adventure.text.logger.slf4j.ComponentLogger +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.DoubleTag +import net.minecraft.nbt.FloatTag +import net.minecraft.nbt.ListTag +import net.minecraft.nbt.NbtAccounter +import net.minecraft.nbt.NbtIo import net.minecraft.server.MinecraftServer import net.minecraft.server.commands.SummonCommand +import net.minecraft.util.ProblemReporter +import net.minecraft.world.entity.EntityProcessor +import net.minecraft.world.entity.EntitySpawnReason +import net.minecraft.world.entity.EntityType as NmsEntityType +import net.minecraft.world.level.storage.TagValueOutput import org.bukkit.World import org.bukkit.entity.Entity import org.bukkit.entity.EntityType +import org.bukkit.entity.Player +import org.bukkit.event.entity.CreatureSpawnEvent +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.UUID @NmsUseWithCaution @Suppress("ClassName") @@ -60,4 +77,74 @@ class V26_1SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { override fun getById(world: World, id: Int): Entity? { return world.toNms().getEntity(id)?.bukkitEntity } + + override fun captureVehicleNbt(rootVehicle: Entity): ByteArray { + val nmsEntity = rootVehicle.toNms() + val level = nmsEntity.level() + + val tag = CompoundTag() + ProblemReporter.ScopedCollector(VEHICLE_LOGGER).use { reporter -> + val output = TagValueOutput.createWrappingWithContext(reporter, level.registryAccess(), tag) + // Saves "id" + Pos/Motion/Rotation/UUID + non-player passengers; players are excluded. + nmsEntity.save(output) + } + + val out = ByteArrayOutputStream() + NbtIo.writeCompressed(tag, out) + return out.toByteArray() + } + + override fun restoreVehicleAndMount( + player: Player, + nbt: ByteArray, + directVehicleUuid: UUID, + x: Double, + y: Double, + z: Double, + yaw: Float, + pitch: Float, + ): Boolean { + val nmsPlayer = player.toNms() + val level = nmsPlayer.level() + + val tag = NbtIo.readCompressed(ByteArrayInputStream(nbt), NbtAccounter.unlimitedHeap()) + // Override the captured crossing position with the safe target position so the vehicle (and + // therefore the mounted player) does not re-appear on the border. + tag.put("Pos", doubleList(x, y, z)) + tag.put("Rotation", floatList(yaw, pitch)) + + val root = NmsEntityType.loadEntityRecursive( + tag, + level, + EntitySpawnReason.LOAD, + EntityProcessor { entity -> + // add-with-uuid rejects entities whose uuid is already present -> built-in dedup. + if (level.addWithUUID(entity, CreatureSpawnEvent.SpawnReason.MOUNT)) entity else null + } + ) ?: return false + + val directVehicle = if (root.uuid == directVehicleUuid) { + root + } else { + root.indirectPassengers.firstOrNull { it.uuid == directVehicleUuid } ?: root + } + + nmsPlayer.startRiding(directVehicle, true, false) + return nmsPlayer.isPassenger + } + + private fun doubleList(x: Double, y: Double, z: Double) = ListTag().apply { + add(DoubleTag.valueOf(x)) + add(DoubleTag.valueOf(y)) + add(DoubleTag.valueOf(z)) + } + + private fun floatList(yaw: Float, pitch: Float) = ListTag().apply { + add(FloatTag.valueOf(yaw)) + add(FloatTag.valueOf(pitch)) + } + + companion object { + private val VEHICLE_LOGGER = ComponentLogger.logger("SurfPaperNmsEntityBridge Vehicle") + } } diff --git a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsEntityBridgeImpl.kt b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsEntityBridgeImpl.kt index 6df0edc8..7b029fda 100644 --- a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsEntityBridgeImpl.kt +++ b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsEntityBridgeImpl.kt @@ -12,11 +12,29 @@ import dev.slne.surf.api.paper.util.chunkX import dev.slne.surf.api.paper.util.chunkZ import io.papermc.paper.math.FinePosition import net.kyori.adventure.nbt.CompoundBinaryTag +import net.kyori.adventure.text.logger.slf4j.ComponentLogger +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.DoubleTag +import net.minecraft.nbt.FloatTag +import net.minecraft.nbt.ListTag +import net.minecraft.nbt.NbtAccounter +import net.minecraft.nbt.NbtIo import net.minecraft.server.MinecraftServer import net.minecraft.server.commands.SummonCommand +import net.minecraft.util.ProblemReporter +import net.minecraft.world.entity.EntityProcessor +import net.minecraft.world.entity.EntitySpawnReason +import net.minecraft.world.entity.EntitySpawnRequest +import net.minecraft.world.entity.EntityType as NmsEntityType +import net.minecraft.world.level.storage.TagValueOutput import org.bukkit.World import org.bukkit.entity.Entity import org.bukkit.entity.EntityType +import org.bukkit.entity.Player +import org.bukkit.event.entity.CreatureSpawnEvent +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.UUID @NmsUseWithCaution @Suppress("ClassName") @@ -60,4 +78,74 @@ class V26_2SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { override fun getById(world: World, id: Int): Entity? { return world.toNms().getEntity(id)?.bukkitEntity } + + override fun captureVehicleNbt(rootVehicle: Entity): ByteArray { + val nmsEntity = rootVehicle.toNms() + val level = nmsEntity.level() + + val tag = CompoundTag() + ProblemReporter.ScopedCollector(VEHICLE_LOGGER).use { reporter -> + val output = TagValueOutput.createWrappingWithContext(reporter, level.registryAccess(), tag) + // Saves "id" + Pos/Motion/Rotation/UUID + non-player passengers; players are excluded. + nmsEntity.save(output) + } + + val out = ByteArrayOutputStream() + NbtIo.writeCompressed(tag, out) + return out.toByteArray() + } + + override fun restoreVehicleAndMount( + player: Player, + nbt: ByteArray, + directVehicleUuid: UUID, + x: Double, + y: Double, + z: Double, + yaw: Float, + pitch: Float, + ): Boolean { + val nmsPlayer = player.toNms() + val level = nmsPlayer.level() + + val tag = NbtIo.readCompressed(ByteArrayInputStream(nbt), NbtAccounter.unlimitedHeap()) + // Override the captured crossing position with the safe target position so the vehicle (and + // therefore the mounted player) does not re-appear on the border. + tag.put("Pos", doubleList(x, y, z)) + tag.put("Rotation", floatList(yaw, pitch)) + + val root = NmsEntityType.loadEntityRecursive( + tag, + level, + EntitySpawnRequest(EntitySpawnReason.LOAD, false), + EntityProcessor { entity -> + // add-with-uuid rejects entities whose uuid is already present -> built-in dedup. + if (level.addWithUUID(entity, CreatureSpawnEvent.SpawnReason.MOUNT)) entity else null + } + ) ?: return false + + val directVehicle = if (root.uuid == directVehicleUuid) { + root + } else { + root.indirectPassengers.firstOrNull { it.uuid == directVehicleUuid } ?: root + } + + nmsPlayer.startRiding(directVehicle, true, false) + return nmsPlayer.isPassenger + } + + private fun doubleList(x: Double, y: Double, z: Double) = ListTag().apply { + add(DoubleTag.valueOf(x)) + add(DoubleTag.valueOf(y)) + add(DoubleTag.valueOf(z)) + } + + private fun floatList(yaw: Float, pitch: Float) = ListTag().apply { + add(FloatTag.valueOf(yaw)) + add(FloatTag.valueOf(pitch)) + } + + companion object { + private val VEHICLE_LOGGER = ComponentLogger.logger("SurfPaperNmsEntityBridge Vehicle") + } } diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge.kt index 03552949..13b2cd3a 100644 --- a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge.kt +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge.kt @@ -10,6 +10,8 @@ import net.kyori.adventure.nbt.CompoundBinaryTag import org.bukkit.World import org.bukkit.entity.Entity import org.bukkit.entity.EntityType +import org.bukkit.entity.Player +import java.util.UUID @NmsUseWithCaution interface SurfPaperNmsEntityBridge { @@ -21,6 +23,48 @@ interface SurfPaperNmsEntityBridge { fun getById(world: World, id: Int): Entity? + /** + * Serializes [rootVehicle] – which must be the *root* of a ride tree – together with its + * non-player passenger subtree into a portable, compressed NBT blob that can be migrated to + * another server. + * + * Player passengers are excluded automatically (players are not serialized as passengers). + * Entity UUIDs, position, rotation, velocity, metadata and any non-player passengers are + * preserved so the tree can be faithfully recreated elsewhere with [restoreVehicleAndMount]. + * + * Must be called on the owning region/entity tick thread. + * + * @param rootVehicle the root entity of the ride tree (e.g. the boat the player sits in) + * @return the gzip-compressed NBT representation of the tree + */ + fun captureVehicleNbt(rootVehicle: Entity): ByteArray + + /** + * Recreates a vehicle tree previously captured with [captureVehicleNbt] in [player]'s current + * world at the given coordinates / rotation, then force-mounts [player] onto the entity + * identified by [directVehicleUuid]. + * + * The tree is recreated with its original entity UUIDs using add-with-uuid semantics, so a + * duplicate (an entity whose UUID is already present in the world) is rejected instead of + * being spawned a second time. The root position is overridden with [x]/[y]/[z] so the + * vehicle (and therefore the mounted player) appears at a safe, caller-chosen location rather + * than the captured crossing point. + * + * Must be called on the owning region/entity tick thread, after [player] is already in-world. + * + * @return true if [player] ended up mounted on the recreated vehicle, false otherwise + */ + fun restoreVehicleAndMount( + player: Player, + nbt: ByteArray, + directVehicleUuid: UUID, + x: Double, + y: Double, + z: Double, + yaw: Float, + pitch: Float, + ): Boolean + companion object : SurfPaperNmsEntityBridge by bridge { val INSTANCE get() = bridge } From 7a76afd60aeb21cc282c90e3d9219f8294189df5 Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:26:50 +0200 Subject: [PATCH 3/8] =?UTF-8?q?=E2=9C=A8=20feat(vehicle):=20add=20methods?= =?UTF-8?q?=20for=20spawning=20vehicle=20trees=20and=20mounting=20passenge?= =?UTF-8?q?rs=20in=20order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - implement spawnVehicleTree to recreate vehicle trees from NBT data - add mountPassengersInOrder to restore passenger order after migration - ensure camels stand up correctly after migration to prevent visual issues --- .../V1_21_11SurfPaperNmsEntityBridgeImpl.kt | 59 +++++++++++++++++++ .../V26_1SurfPaperNmsEntityBridgeImpl.kt | 59 +++++++++++++++++++ .../V26_2SurfPaperNmsEntityBridgeImpl.kt | 59 +++++++++++++++++++ .../nms/bridges/SurfPaperNmsEntityBridge.kt | 38 ++++++++++++ 4 files changed, 215 insertions(+) diff --git a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsEntityBridgeImpl.kt b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsEntityBridgeImpl.kt index 30303cf0..6b855066 100644 --- a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsEntityBridgeImpl.kt +++ b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsEntityBridgeImpl.kt @@ -22,9 +22,11 @@ import net.minecraft.nbt.NbtIo import net.minecraft.server.MinecraftServer import net.minecraft.server.commands.SummonCommand import net.minecraft.util.ProblemReporter +import net.minecraft.world.entity.Entity as NmsEntity import net.minecraft.world.entity.EntityProcessor import net.minecraft.world.entity.EntitySpawnReason import net.minecraft.world.entity.EntityType as NmsEntityType +import net.minecraft.world.entity.animal.camel.Camel import net.minecraft.world.level.storage.TagValueOutput import org.bukkit.World import org.bukkit.entity.Entity @@ -121,6 +123,7 @@ class V1_21_11SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { if (level.addWithUUID(entity, CreatureSpawnEvent.SpawnReason.MOUNT)) entity else null } ) ?: return false + standUpCamels(root) val directVehicle = if (root.uuid == directVehicleUuid) { root @@ -132,6 +135,62 @@ class V1_21_11SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { return nmsPlayer.isPassenger } + override fun spawnVehicleTree( + world: World, + nbt: ByteArray, + x: Double, + y: Double, + z: Double, + yaw: Float, + pitch: Float, + ): Boolean { + val level = world.toNms() + + val tag = NbtIo.readCompressed(ByteArrayInputStream(nbt), NbtAccounter.unlimitedHeap()) + tag.put("Pos", doubleList(x, y, z)) + tag.put("Rotation", floatList(yaw, pitch)) + + val root = NmsEntityType.loadEntityRecursive( + tag, + level, + EntitySpawnReason.LOAD, + EntityProcessor { entity -> + if (level.addWithUUID(entity, CreatureSpawnEvent.SpawnReason.MOUNT)) entity else null + } + ) ?: return false + standUpCamels(root) + return true + } + + override fun mountPassengersInOrder(vehicle: Entity, orderedPassengers: List): Boolean { + val nmsVehicle = vehicle.toNms() + + // Clear the current passenger list first so the order is fully controlled by us. + for (passenger in nmsVehicle.passengers.toList()) { + passenger.stopRiding() + } + + // Re-mount in order; the server appends each passenger, so the resulting order matches. + for (passenger in orderedPassengers) { + passenger.toNms().startRiding(nmsVehicle, true, false) + } + + return nmsVehicle.passengers.isNotEmpty() + } + + /** + * Camels encode their (sitting) pose as an absolute game-time tick. After migrating to a shard + * with a different game time that tick lies in the past/future, leaving the camel stuck in a + * sit/stand transition (rendered lying down). Re-stand any migrated camel so its pose tick is + * rebased onto this shard's game time. + */ + private fun standUpCamels(root: NmsEntity) { + if (root is Camel) runCatching { root.standUpInstantly() } + for (passenger in root.indirectPassengers) { + if (passenger is Camel) runCatching { passenger.standUpInstantly() } + } + } + private fun doubleList(x: Double, y: Double, z: Double) = ListTag().apply { add(DoubleTag.valueOf(x)) add(DoubleTag.valueOf(y)) diff --git a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsEntityBridgeImpl.kt b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsEntityBridgeImpl.kt index 4cd70902..9640a326 100644 --- a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsEntityBridgeImpl.kt +++ b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsEntityBridgeImpl.kt @@ -22,9 +22,11 @@ import net.minecraft.nbt.NbtIo import net.minecraft.server.MinecraftServer import net.minecraft.server.commands.SummonCommand import net.minecraft.util.ProblemReporter +import net.minecraft.world.entity.Entity as NmsEntity import net.minecraft.world.entity.EntityProcessor import net.minecraft.world.entity.EntitySpawnReason import net.minecraft.world.entity.EntityType as NmsEntityType +import net.minecraft.world.entity.animal.camel.Camel import net.minecraft.world.level.storage.TagValueOutput import org.bukkit.World import org.bukkit.entity.Entity @@ -122,6 +124,7 @@ class V26_1SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { if (level.addWithUUID(entity, CreatureSpawnEvent.SpawnReason.MOUNT)) entity else null } ) ?: return false + standUpCamels(root) val directVehicle = if (root.uuid == directVehicleUuid) { root @@ -133,6 +136,62 @@ class V26_1SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { return nmsPlayer.isPassenger } + override fun spawnVehicleTree( + world: World, + nbt: ByteArray, + x: Double, + y: Double, + z: Double, + yaw: Float, + pitch: Float, + ): Boolean { + val level = world.toNms() + + val tag = NbtIo.readCompressed(ByteArrayInputStream(nbt), NbtAccounter.unlimitedHeap()) + tag.put("Pos", doubleList(x, y, z)) + tag.put("Rotation", floatList(yaw, pitch)) + + val root = NmsEntityType.loadEntityRecursive( + tag, + level, + EntitySpawnReason.LOAD, + EntityProcessor { entity -> + if (level.addWithUUID(entity, CreatureSpawnEvent.SpawnReason.MOUNT)) entity else null + } + ) ?: return false + standUpCamels(root) + return true + } + + override fun mountPassengersInOrder(vehicle: Entity, orderedPassengers: List): Boolean { + val nmsVehicle = vehicle.toNms() + + // Clear the current passenger list first so the order is fully controlled by us. + for (passenger in nmsVehicle.passengers.toList()) { + passenger.stopRiding() + } + + // Re-mount in order; the server appends each passenger, so the resulting order matches. + for (passenger in orderedPassengers) { + passenger.toNms().startRiding(nmsVehicle, true, false) + } + + return nmsVehicle.passengers.isNotEmpty() + } + + /** + * Camels encode their (sitting) pose as an absolute game-time tick. After migrating to a shard + * with a different game time that tick lies in the past/future, leaving the camel stuck in a + * sit/stand transition (rendered lying down). Re-stand any migrated camel so its pose tick is + * rebased onto this shard's game time. + */ + private fun standUpCamels(root: NmsEntity) { + if (root is Camel) runCatching { root.standUpInstantly() } + for (passenger in root.indirectPassengers) { + if (passenger is Camel) runCatching { passenger.standUpInstantly() } + } + } + private fun doubleList(x: Double, y: Double, z: Double) = ListTag().apply { add(DoubleTag.valueOf(x)) add(DoubleTag.valueOf(y)) diff --git a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsEntityBridgeImpl.kt b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsEntityBridgeImpl.kt index 7b029fda..24c4a1e9 100644 --- a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsEntityBridgeImpl.kt +++ b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsEntityBridgeImpl.kt @@ -22,10 +22,12 @@ import net.minecraft.nbt.NbtIo import net.minecraft.server.MinecraftServer import net.minecraft.server.commands.SummonCommand import net.minecraft.util.ProblemReporter +import net.minecraft.world.entity.Entity as NmsEntity import net.minecraft.world.entity.EntityProcessor import net.minecraft.world.entity.EntitySpawnReason import net.minecraft.world.entity.EntitySpawnRequest import net.minecraft.world.entity.EntityType as NmsEntityType +import net.minecraft.world.entity.animal.camel.Camel import net.minecraft.world.level.storage.TagValueOutput import org.bukkit.World import org.bukkit.entity.Entity @@ -123,6 +125,7 @@ class V26_2SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { if (level.addWithUUID(entity, CreatureSpawnEvent.SpawnReason.MOUNT)) entity else null } ) ?: return false + standUpCamels(root) val directVehicle = if (root.uuid == directVehicleUuid) { root @@ -134,6 +137,62 @@ class V26_2SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { return nmsPlayer.isPassenger } + override fun spawnVehicleTree( + world: World, + nbt: ByteArray, + x: Double, + y: Double, + z: Double, + yaw: Float, + pitch: Float, + ): Boolean { + val level = world.toNms() + + val tag = NbtIo.readCompressed(ByteArrayInputStream(nbt), NbtAccounter.unlimitedHeap()) + tag.put("Pos", doubleList(x, y, z)) + tag.put("Rotation", floatList(yaw, pitch)) + + val root = NmsEntityType.loadEntityRecursive( + tag, + level, + EntitySpawnRequest(EntitySpawnReason.LOAD, false), + EntityProcessor { entity -> + if (level.addWithUUID(entity, CreatureSpawnEvent.SpawnReason.MOUNT)) entity else null + } + ) ?: return false + standUpCamels(root) + return true + } + + override fun mountPassengersInOrder(vehicle: Entity, orderedPassengers: List): Boolean { + val nmsVehicle = vehicle.toNms() + + // Clear the current passenger list first so the order is fully controlled by us. + for (passenger in nmsVehicle.passengers.toList()) { + passenger.stopRiding() + } + + // Re-mount in order; the server appends each passenger, so the resulting order matches. + for (passenger in orderedPassengers) { + passenger.toNms().startRiding(nmsVehicle, true, false) + } + + return nmsVehicle.passengers.isNotEmpty() + } + + /** + * Camels encode their (sitting) pose as an absolute game-time tick. After migrating to a shard + * with a different game time that tick lies in the past/future, leaving the camel stuck in a + * sit/stand transition (rendered lying down). Re-stand any migrated camel so its pose tick is + * rebased onto this shard's game time. + */ + private fun standUpCamels(root: NmsEntity) { + if (root is Camel) runCatching { root.standUpInstantly() } + for (passenger in root.indirectPassengers) { + if (passenger is Camel) runCatching { passenger.standUpInstantly() } + } + } + private fun doubleList(x: Double, y: Double, z: Double) = ListTag().apply { add(DoubleTag.valueOf(x)) add(DoubleTag.valueOf(y)) diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge.kt index 13b2cd3a..ab25f15b 100644 --- a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge.kt +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge.kt @@ -65,6 +65,44 @@ interface SurfPaperNmsEntityBridge { pitch: Float, ): Boolean + /** + * Recreates a vehicle tree previously captured with [captureVehicleNbt] in [world] at the given + * coordinates / rotation **without mounting anyone**. + * + * This is the spawn-only counterpart of [restoreVehicleAndMount], used when several player + * passengers are migrated as a group: the vehicle must already exist (spawned exactly once) + * before the players are mounted in their original order with [mountPassengersInOrder]. The + * tree keeps its original entity UUIDs (add-with-uuid rejects duplicates), so calling this + * twice for the same group is a no-op for the second call. + * + * Must be called on the owning region/entity tick thread. + * + * @return true if the root entity was spawned (or already present), false on failure + */ + fun spawnVehicleTree( + world: World, + nbt: ByteArray, + x: Double, + y: Double, + z: Double, + yaw: Float, + pitch: Float, + ): Boolean + + /** + * Rebuilds [vehicle]'s passenger list to exactly [orderedPassengers], in that order. + * + * All current passengers are dismounted first, then each entity in [orderedPassengers] is + * force-mounted in turn. Because the server appends each new passenger, the resulting passenger + * order matches the list, which for boats decides the controlling passenger (index 0). This is + * how the original driver and seating order are restored after a multi-passenger migration. + * + * Must be called on the owning region/entity tick thread, with every entity already in-world. + * + * @return true if [vehicle] ended up with at least one passenger + */ + fun mountPassengersInOrder(vehicle: Entity, orderedPassengers: List): Boolean + companion object : SurfPaperNmsEntityBridge by bridge { val INSTANCE get() = bridge } From 7b2f2b6bda43150efd9b690d32fe55fa77ec83fa Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:39:29 +0200 Subject: [PATCH 4/8] fix(vehicle): enhance vehicle tree restoration and spawning logic - improve vehicle tree restoration to ensure idempotency and avoid collisions - add checks for existing UUIDs to prevent partial duplicates during restoration - enhance spawning logic to apply target transformations for safe positioning - refactor vehicle UUID collection and validation methods for clarity and efficiency --- .../V1_21_11SurfPaperNmsEntityBridgeImpl.kt | 226 +++++++++++++++--- .../V1_21_11SurfPaperNmsPlayerBridgeImpl.kt | 8 +- .../V26_1SurfPaperNmsEntityBridgeImpl.kt | 226 +++++++++++++++--- .../V26_1SurfPaperNmsPlayerBridgeImpl.kt | 8 +- .../V26_2SurfPaperNmsEntityBridgeImpl.kt | 226 +++++++++++++++--- .../V26_2SurfPaperNmsPlayerBridgeImpl.kt | 8 +- .../nms/bridges/SurfPaperNmsEntityBridge.kt | 47 ++-- .../nms/bridges/SurfPaperNmsPlayerBridge.kt | 6 +- 8 files changed, 633 insertions(+), 122 deletions(-) diff --git a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsEntityBridgeImpl.kt b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsEntityBridgeImpl.kt index 6b855066..4a9b75b2 100644 --- a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsEntityBridgeImpl.kt +++ b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsEntityBridgeImpl.kt @@ -13,6 +13,7 @@ import dev.slne.surf.api.paper.util.chunkZ import io.papermc.paper.math.FinePosition import net.kyori.adventure.nbt.CompoundBinaryTag import net.kyori.adventure.text.logger.slf4j.ComponentLogger +import net.minecraft.core.UUIDUtil import net.minecraft.nbt.CompoundTag import net.minecraft.nbt.DoubleTag import net.minecraft.nbt.FloatTag @@ -21,6 +22,7 @@ import net.minecraft.nbt.NbtAccounter import net.minecraft.nbt.NbtIo import net.minecraft.server.MinecraftServer import net.minecraft.server.commands.SummonCommand +import net.minecraft.server.level.ServerLevel import net.minecraft.util.ProblemReporter import net.minecraft.world.entity.Entity as NmsEntity import net.minecraft.world.entity.EntityProcessor @@ -108,31 +110,58 @@ class V1_21_11SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { val nmsPlayer = player.toNms() val level = nmsPlayer.level() - val tag = NbtIo.readCompressed(ByteArrayInputStream(nbt), NbtAccounter.unlimitedHeap()) - // Override the captured crossing position with the safe target position so the vehicle (and - // therefore the mounted player) does not re-appear on the border. - tag.put("Pos", doubleList(x, y, z)) - tag.put("Rotation", floatList(yaw, pitch)) + var spawnedRoot: NmsEntity? = null + return try { + val vehicleNbt = readVehicleTreeNbt(nbt) ?: return false + if (directVehicleUuid !in vehicleNbt.entityUuids) return false - val root = NmsEntityType.loadEntityRecursive( - tag, - level, - EntitySpawnReason.LOAD, - EntityProcessor { entity -> - // add-with-uuid rejects entities whose uuid is already present -> built-in dedup. - if (level.addWithUUID(entity, CreatureSpawnEvent.SpawnReason.MOUNT)) entity else null + val existingRoot = getEntityByUuid(level, vehicleNbt.rootUuid) + val root = if (existingRoot != null) { + if (!vehicleTreeContainsAll(existingRoot, vehicleNbt.entityUuids)) return false + existingRoot + } else { + if (hasPartialUuidCollision(level, vehicleNbt.entityUuids, vehicleNbt.rootUuid)) return false + + applyTargetTransform(vehicleNbt.tag, x, y, z, yaw, pitch) + val spawned = spawnVehicleTreeFromTag(level, vehicleNbt.tag) ?: return false + spawnedRoot = spawned + standUpCamels(spawned) + + if (!vehicleTreeContainsAll(spawned, vehicleNbt.entityUuids)) { + discardVehicleTree(spawned) + spawnedRoot = null + return false + } + + spawned } - ) ?: return false - standUpCamels(root) - val directVehicle = if (root.uuid == directVehicleUuid) { - root - } else { - root.indirectPassengers.firstOrNull { it.uuid == directVehicleUuid } ?: root - } + standUpCamels(root) + val directVehicle = findEntityInTree(root, directVehicleUuid) + if (directVehicle == null) { + spawnedRoot?.let(::discardVehicleTree) + spawnedRoot = null + return false + } - nmsPlayer.startRiding(directVehicle, true, false) - return nmsPlayer.isPassenger + if (nmsPlayer.vehicle !== directVehicle) { + if (nmsPlayer.isPassenger) { + nmsPlayer.stopRiding() + } + nmsPlayer.startRiding(directVehicle, true, false) + } + + val mounted = nmsPlayer.vehicle === directVehicle + if (!mounted) { + spawnedRoot?.let(::discardVehicleTree) + spawnedRoot = null + } + + mounted + } catch (_: Exception) { + spawnedRoot?.let(::discardVehicleTree) + false + } } override fun spawnVehicleTree( @@ -146,20 +175,37 @@ class V1_21_11SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { ): Boolean { val level = world.toNms() - val tag = NbtIo.readCompressed(ByteArrayInputStream(nbt), NbtAccounter.unlimitedHeap()) - tag.put("Pos", doubleList(x, y, z)) - tag.put("Rotation", floatList(yaw, pitch)) + var spawnedRoot: NmsEntity? = null + return try { + val vehicleNbt = readVehicleTreeNbt(nbt) ?: return false - val root = NmsEntityType.loadEntityRecursive( - tag, - level, - EntitySpawnReason.LOAD, - EntityProcessor { entity -> - if (level.addWithUUID(entity, CreatureSpawnEvent.SpawnReason.MOUNT)) entity else null + val existingRoot = getEntityByUuid(level, vehicleNbt.rootUuid) + if (existingRoot != null) { + val complete = vehicleTreeContainsAll(existingRoot, vehicleNbt.entityUuids) + if (complete) { + standUpCamels(existingRoot) + } + return complete } - ) ?: return false - standUpCamels(root) - return true + + if (hasPartialUuidCollision(level, vehicleNbt.entityUuids, vehicleNbt.rootUuid)) return false + + applyTargetTransform(vehicleNbt.tag, x, y, z, yaw, pitch) + val root = spawnVehicleTreeFromTag(level, vehicleNbt.tag) ?: return false + spawnedRoot = root + standUpCamels(root) + + val complete = vehicleTreeContainsAll(root, vehicleNbt.entityUuids) + if (!complete) { + discardVehicleTree(root) + spawnedRoot = null + } + + complete + } catch (_: Exception) { + spawnedRoot?.let(::discardVehicleTree) + false + } } override fun mountPassengersInOrder(vehicle: Entity, orderedPassengers: List): Boolean { @@ -178,6 +224,114 @@ class V1_21_11SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { return nmsVehicle.passengers.isNotEmpty() } + private fun readVehicleTreeNbt(nbt: ByteArray): VehicleTreeNbt? { + val tag = try { + NbtIo.readCompressed(ByteArrayInputStream(nbt), NbtAccounter.unlimitedHeap()) + } catch (_: Exception) { + return null + } + + val entityUuids = LinkedHashSet() + if (!collectVehicleUuids(tag, entityUuids)) return null + + return VehicleTreeNbt( + tag = tag, + rootUuid = entityUuids.firstOrNull() ?: return null, + entityUuids = entityUuids, + ) + } + + private fun collectVehicleUuids(tag: CompoundTag, entityUuids: MutableSet): Boolean { + val uuid = tag.entityUuidOrNull() ?: return false + if (!entityUuids.add(uuid)) return false + + val passengers = tag.getList(NmsEntity.TAG_PASSENGERS).orElse(null) + if (passengers == null) { + return !tag.contains(NmsEntity.TAG_PASSENGERS) + } + + for (i in 0 until passengers.size) { + val passengerTag = passengers.getCompound(i).orElse(null) ?: return false + if (!collectVehicleUuids(passengerTag, entityUuids)) return false + } + + return true + } + + private fun CompoundTag.entityUuidOrNull(): UUID? { + val uuid = getIntArray(NmsEntity.TAG_UUID).orElse(null) ?: return null + if (uuid.size != 4) return null + + return UUIDUtil.uuidFromIntArray(uuid) + } + + private fun getEntityByUuid(level: ServerLevel, uuid: UUID): NmsEntity? { + return level.entities.get(uuid) + } + + private fun hasPartialUuidCollision( + level: ServerLevel, + entityUuids: Set, + rootUuid: UUID, + ): Boolean { + for (uuid in entityUuids) { + if (uuid == rootUuid) continue + if (getEntityByUuid(level, uuid) != null) return true + } + + return false + } + + private fun vehicleTreeContainsAll(root: NmsEntity, entityUuids: Set): Boolean { + val existingUuids = LinkedHashSet() + existingUuids.add(root.uuid) + for (passenger in root.indirectPassengers) { + existingUuids.add(passenger.uuid) + } + + return existingUuids.containsAll(entityUuids) + } + + private fun findEntityInTree(root: NmsEntity, uuid: UUID): NmsEntity? { + if (root.uuid == uuid) return root + return root.indirectPassengers.firstOrNull { it.uuid == uuid } + } + + private fun applyTargetTransform( + tag: CompoundTag, + x: Double, + y: Double, + z: Double, + yaw: Float, + pitch: Float, + ) { + // Override the captured crossing position with the safe target position so the vehicle + // does not re-appear on the border. + tag.put(NmsEntity.TAG_POS, doubleList(x, y, z)) + tag.put(NmsEntity.TAG_ROTATION, floatList(yaw, pitch)) + } + + private fun spawnVehicleTreeFromTag(level: ServerLevel, tag: CompoundTag): NmsEntity? { + return NmsEntityType.loadEntityRecursive( + tag, + level, + EntitySpawnReason.LOAD, + EntityProcessor { entity -> + if (level.addWithUUID(entity, CreatureSpawnEvent.SpawnReason.MOUNT)) entity else null + } + ) + } + + private fun discardVehicleTree(root: NmsEntity) { + val entities = ArrayList() + entities.add(root) + entities.addAll(root.indirectPassengers) + + for (entity in entities.asReversed()) { + entity.discard() + } + } + /** * Camels encode their (sitting) pose as an absolute game-time tick. After migrating to a shard * with a different game time that tick lies in the past/future, leaving the camel stuck in a @@ -205,4 +359,10 @@ class V1_21_11SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { companion object { private val VEHICLE_LOGGER = ComponentLogger.logger("SurfPaperNmsEntityBridge Vehicle") } + + private data class VehicleTreeNbt( + val tag: CompoundTag, + val rootUuid: UUID, + val entityUuids: Set, + ) } diff --git a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsPlayerBridgeImpl.kt b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsPlayerBridgeImpl.kt index 4c2c4054..3f6e5a90 100644 --- a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsPlayerBridgeImpl.kt +++ b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsPlayerBridgeImpl.kt @@ -151,7 +151,11 @@ class V1_21_11SurfPaperNmsPlayerBridgeImpl : SurfPaperNmsPlayerBridge { val nmsEntity = entity.toNms() if (nmsEntity === nmsViewer) return false - val tracker = nmsViewer.level().chunkSource.chunkMap.entityMap.get(nmsEntity.id) ?: return false + val viewerLevel = nmsViewer.level() + val entityLevel = nmsEntity.level() + if (entityLevel !== viewerLevel) return false + + val tracker = viewerLevel.chunkSource.chunkMap.entityMap.get(nmsEntity.id) ?: return false return try { if (!tracker.seenBy.contains(connection)) { tracker.seenBy.add(connection) @@ -514,4 +518,4 @@ class V1_21_11SurfPaperNmsPlayerBridgeImpl : SurfPaperNmsPlayerBridge { private val OFFLINE_INVENTORY_EDIT_LOGGER = ComponentLogger.logger("OfflinePlayer Inventory Edit") private val CHAT_LOGGER = ComponentLogger.logger("SurfPaperNmsPlayerBridge Chat") } -} \ No newline at end of file +} diff --git a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsEntityBridgeImpl.kt b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsEntityBridgeImpl.kt index 9640a326..ee63fdc9 100644 --- a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsEntityBridgeImpl.kt +++ b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsEntityBridgeImpl.kt @@ -13,6 +13,7 @@ import dev.slne.surf.api.paper.util.chunkZ import io.papermc.paper.math.FinePosition import net.kyori.adventure.nbt.CompoundBinaryTag import net.kyori.adventure.text.logger.slf4j.ComponentLogger +import net.minecraft.core.UUIDUtil import net.minecraft.nbt.CompoundTag import net.minecraft.nbt.DoubleTag import net.minecraft.nbt.FloatTag @@ -21,6 +22,7 @@ import net.minecraft.nbt.NbtAccounter import net.minecraft.nbt.NbtIo import net.minecraft.server.MinecraftServer import net.minecraft.server.commands.SummonCommand +import net.minecraft.server.level.ServerLevel import net.minecraft.util.ProblemReporter import net.minecraft.world.entity.Entity as NmsEntity import net.minecraft.world.entity.EntityProcessor @@ -109,31 +111,58 @@ class V26_1SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { val nmsPlayer = player.toNms() val level = nmsPlayer.level() - val tag = NbtIo.readCompressed(ByteArrayInputStream(nbt), NbtAccounter.unlimitedHeap()) - // Override the captured crossing position with the safe target position so the vehicle (and - // therefore the mounted player) does not re-appear on the border. - tag.put("Pos", doubleList(x, y, z)) - tag.put("Rotation", floatList(yaw, pitch)) + var spawnedRoot: NmsEntity? = null + return try { + val vehicleNbt = readVehicleTreeNbt(nbt) ?: return false + if (directVehicleUuid !in vehicleNbt.entityUuids) return false - val root = NmsEntityType.loadEntityRecursive( - tag, - level, - EntitySpawnReason.LOAD, - EntityProcessor { entity -> - // add-with-uuid rejects entities whose uuid is already present -> built-in dedup. - if (level.addWithUUID(entity, CreatureSpawnEvent.SpawnReason.MOUNT)) entity else null + val existingRoot = getEntityByUuid(level, vehicleNbt.rootUuid) + val root = if (existingRoot != null) { + if (!vehicleTreeContainsAll(existingRoot, vehicleNbt.entityUuids)) return false + existingRoot + } else { + if (hasPartialUuidCollision(level, vehicleNbt.entityUuids, vehicleNbt.rootUuid)) return false + + applyTargetTransform(vehicleNbt.tag, x, y, z, yaw, pitch) + val spawned = spawnVehicleTreeFromTag(level, vehicleNbt.tag) ?: return false + spawnedRoot = spawned + standUpCamels(spawned) + + if (!vehicleTreeContainsAll(spawned, vehicleNbt.entityUuids)) { + discardVehicleTree(spawned) + spawnedRoot = null + return false + } + + spawned } - ) ?: return false - standUpCamels(root) - val directVehicle = if (root.uuid == directVehicleUuid) { - root - } else { - root.indirectPassengers.firstOrNull { it.uuid == directVehicleUuid } ?: root - } + standUpCamels(root) + val directVehicle = findEntityInTree(root, directVehicleUuid) + if (directVehicle == null) { + spawnedRoot?.let(::discardVehicleTree) + spawnedRoot = null + return false + } - nmsPlayer.startRiding(directVehicle, true, false) - return nmsPlayer.isPassenger + if (nmsPlayer.vehicle !== directVehicle) { + if (nmsPlayer.isPassenger) { + nmsPlayer.stopRiding() + } + nmsPlayer.startRiding(directVehicle, true, false) + } + + val mounted = nmsPlayer.vehicle === directVehicle + if (!mounted) { + spawnedRoot?.let(::discardVehicleTree) + spawnedRoot = null + } + + mounted + } catch (_: Exception) { + spawnedRoot?.let(::discardVehicleTree) + false + } } override fun spawnVehicleTree( @@ -147,20 +176,37 @@ class V26_1SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { ): Boolean { val level = world.toNms() - val tag = NbtIo.readCompressed(ByteArrayInputStream(nbt), NbtAccounter.unlimitedHeap()) - tag.put("Pos", doubleList(x, y, z)) - tag.put("Rotation", floatList(yaw, pitch)) + var spawnedRoot: NmsEntity? = null + return try { + val vehicleNbt = readVehicleTreeNbt(nbt) ?: return false - val root = NmsEntityType.loadEntityRecursive( - tag, - level, - EntitySpawnReason.LOAD, - EntityProcessor { entity -> - if (level.addWithUUID(entity, CreatureSpawnEvent.SpawnReason.MOUNT)) entity else null + val existingRoot = getEntityByUuid(level, vehicleNbt.rootUuid) + if (existingRoot != null) { + val complete = vehicleTreeContainsAll(existingRoot, vehicleNbt.entityUuids) + if (complete) { + standUpCamels(existingRoot) + } + return complete } - ) ?: return false - standUpCamels(root) - return true + + if (hasPartialUuidCollision(level, vehicleNbt.entityUuids, vehicleNbt.rootUuid)) return false + + applyTargetTransform(vehicleNbt.tag, x, y, z, yaw, pitch) + val root = spawnVehicleTreeFromTag(level, vehicleNbt.tag) ?: return false + spawnedRoot = root + standUpCamels(root) + + val complete = vehicleTreeContainsAll(root, vehicleNbt.entityUuids) + if (!complete) { + discardVehicleTree(root) + spawnedRoot = null + } + + complete + } catch (_: Exception) { + spawnedRoot?.let(::discardVehicleTree) + false + } } override fun mountPassengersInOrder(vehicle: Entity, orderedPassengers: List): Boolean { @@ -179,6 +225,114 @@ class V26_1SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { return nmsVehicle.passengers.isNotEmpty() } + private fun readVehicleTreeNbt(nbt: ByteArray): VehicleTreeNbt? { + val tag = try { + NbtIo.readCompressed(ByteArrayInputStream(nbt), NbtAccounter.unlimitedHeap()) + } catch (_: Exception) { + return null + } + + val entityUuids = LinkedHashSet() + if (!collectVehicleUuids(tag, entityUuids)) return null + + return VehicleTreeNbt( + tag = tag, + rootUuid = entityUuids.firstOrNull() ?: return null, + entityUuids = entityUuids, + ) + } + + private fun collectVehicleUuids(tag: CompoundTag, entityUuids: MutableSet): Boolean { + val uuid = tag.entityUuidOrNull() ?: return false + if (!entityUuids.add(uuid)) return false + + val passengers = tag.getList(NmsEntity.TAG_PASSENGERS).orElse(null) + if (passengers == null) { + return !tag.contains(NmsEntity.TAG_PASSENGERS) + } + + for (i in 0 until passengers.size) { + val passengerTag = passengers.getCompound(i).orElse(null) ?: return false + if (!collectVehicleUuids(passengerTag, entityUuids)) return false + } + + return true + } + + private fun CompoundTag.entityUuidOrNull(): UUID? { + val uuid = getIntArray(NmsEntity.TAG_UUID).orElse(null) ?: return null + if (uuid.size != 4) return null + + return UUIDUtil.uuidFromIntArray(uuid) + } + + private fun getEntityByUuid(level: ServerLevel, uuid: UUID): NmsEntity? { + return level.entities.get(uuid) + } + + private fun hasPartialUuidCollision( + level: ServerLevel, + entityUuids: Set, + rootUuid: UUID, + ): Boolean { + for (uuid in entityUuids) { + if (uuid == rootUuid) continue + if (getEntityByUuid(level, uuid) != null) return true + } + + return false + } + + private fun vehicleTreeContainsAll(root: NmsEntity, entityUuids: Set): Boolean { + val existingUuids = LinkedHashSet() + existingUuids.add(root.uuid) + for (passenger in root.indirectPassengers) { + existingUuids.add(passenger.uuid) + } + + return existingUuids.containsAll(entityUuids) + } + + private fun findEntityInTree(root: NmsEntity, uuid: UUID): NmsEntity? { + if (root.uuid == uuid) return root + return root.indirectPassengers.firstOrNull { it.uuid == uuid } + } + + private fun applyTargetTransform( + tag: CompoundTag, + x: Double, + y: Double, + z: Double, + yaw: Float, + pitch: Float, + ) { + // Override the captured crossing position with the safe target position so the vehicle + // does not re-appear on the border. + tag.put(NmsEntity.TAG_POS, doubleList(x, y, z)) + tag.put(NmsEntity.TAG_ROTATION, floatList(yaw, pitch)) + } + + private fun spawnVehicleTreeFromTag(level: ServerLevel, tag: CompoundTag): NmsEntity? { + return NmsEntityType.loadEntityRecursive( + tag, + level, + EntitySpawnReason.LOAD, + EntityProcessor { entity -> + if (level.addWithUUID(entity, CreatureSpawnEvent.SpawnReason.MOUNT)) entity else null + } + ) + } + + private fun discardVehicleTree(root: NmsEntity) { + val entities = ArrayList() + entities.add(root) + entities.addAll(root.indirectPassengers) + + for (entity in entities.asReversed()) { + entity.discard() + } + } + /** * Camels encode their (sitting) pose as an absolute game-time tick. After migrating to a shard * with a different game time that tick lies in the past/future, leaving the camel stuck in a @@ -206,4 +360,10 @@ class V26_1SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { companion object { private val VEHICLE_LOGGER = ComponentLogger.logger("SurfPaperNmsEntityBridge Vehicle") } + + private data class VehicleTreeNbt( + val tag: CompoundTag, + val rootUuid: UUID, + val entityUuids: Set, + ) } diff --git a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsPlayerBridgeImpl.kt b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsPlayerBridgeImpl.kt index acc98cb3..90789dc0 100644 --- a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsPlayerBridgeImpl.kt +++ b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsPlayerBridgeImpl.kt @@ -151,7 +151,11 @@ class V26_1SurfPaperNmsPlayerBridgeImpl : SurfPaperNmsPlayerBridge { val nmsEntity = entity.toNms() if (nmsEntity === nmsViewer) return false - val tracker = nmsViewer.level().chunkSource.chunkMap.entityMap.get(nmsEntity.id) ?: return false + val viewerLevel = nmsViewer.level() + val entityLevel = nmsEntity.level() + if (entityLevel !== viewerLevel) return false + + val tracker = viewerLevel.chunkSource.chunkMap.entityMap.get(nmsEntity.id) ?: return false return try { if (!tracker.seenBy.contains(connection)) { tracker.seenBy.add(connection) @@ -690,4 +694,4 @@ class V26_1SurfPaperNmsPlayerBridgeImpl : SurfPaperNmsPlayerBridge { private val OFFLINE_INVENTORY_EDIT_LOGGER = ComponentLogger.logger("OfflinePlayer Inventory Edit") private val CHAT_LOGGER = ComponentLogger.logger("SurfPaperNmsPlayerBridge Chat") } -} \ No newline at end of file +} diff --git a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsEntityBridgeImpl.kt b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsEntityBridgeImpl.kt index 24c4a1e9..b8d09034 100644 --- a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsEntityBridgeImpl.kt +++ b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsEntityBridgeImpl.kt @@ -13,6 +13,7 @@ import dev.slne.surf.api.paper.util.chunkZ import io.papermc.paper.math.FinePosition import net.kyori.adventure.nbt.CompoundBinaryTag import net.kyori.adventure.text.logger.slf4j.ComponentLogger +import net.minecraft.core.UUIDUtil import net.minecraft.nbt.CompoundTag import net.minecraft.nbt.DoubleTag import net.minecraft.nbt.FloatTag @@ -21,6 +22,7 @@ import net.minecraft.nbt.NbtAccounter import net.minecraft.nbt.NbtIo import net.minecraft.server.MinecraftServer import net.minecraft.server.commands.SummonCommand +import net.minecraft.server.level.ServerLevel import net.minecraft.util.ProblemReporter import net.minecraft.world.entity.Entity as NmsEntity import net.minecraft.world.entity.EntityProcessor @@ -110,31 +112,58 @@ class V26_2SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { val nmsPlayer = player.toNms() val level = nmsPlayer.level() - val tag = NbtIo.readCompressed(ByteArrayInputStream(nbt), NbtAccounter.unlimitedHeap()) - // Override the captured crossing position with the safe target position so the vehicle (and - // therefore the mounted player) does not re-appear on the border. - tag.put("Pos", doubleList(x, y, z)) - tag.put("Rotation", floatList(yaw, pitch)) + var spawnedRoot: NmsEntity? = null + return try { + val vehicleNbt = readVehicleTreeNbt(nbt) ?: return false + if (directVehicleUuid !in vehicleNbt.entityUuids) return false - val root = NmsEntityType.loadEntityRecursive( - tag, - level, - EntitySpawnRequest(EntitySpawnReason.LOAD, false), - EntityProcessor { entity -> - // add-with-uuid rejects entities whose uuid is already present -> built-in dedup. - if (level.addWithUUID(entity, CreatureSpawnEvent.SpawnReason.MOUNT)) entity else null + val existingRoot = getEntityByUuid(level, vehicleNbt.rootUuid) + val root = if (existingRoot != null) { + if (!vehicleTreeContainsAll(existingRoot, vehicleNbt.entityUuids)) return false + existingRoot + } else { + if (hasPartialUuidCollision(level, vehicleNbt.entityUuids, vehicleNbt.rootUuid)) return false + + applyTargetTransform(vehicleNbt.tag, x, y, z, yaw, pitch) + val spawned = spawnVehicleTreeFromTag(level, vehicleNbt.tag) ?: return false + spawnedRoot = spawned + standUpCamels(spawned) + + if (!vehicleTreeContainsAll(spawned, vehicleNbt.entityUuids)) { + discardVehicleTree(spawned) + spawnedRoot = null + return false + } + + spawned } - ) ?: return false - standUpCamels(root) - val directVehicle = if (root.uuid == directVehicleUuid) { - root - } else { - root.indirectPassengers.firstOrNull { it.uuid == directVehicleUuid } ?: root - } + standUpCamels(root) + val directVehicle = findEntityInTree(root, directVehicleUuid) + if (directVehicle == null) { + spawnedRoot?.let(::discardVehicleTree) + spawnedRoot = null + return false + } - nmsPlayer.startRiding(directVehicle, true, false) - return nmsPlayer.isPassenger + if (nmsPlayer.vehicle !== directVehicle) { + if (nmsPlayer.isPassenger) { + nmsPlayer.stopRiding() + } + nmsPlayer.startRiding(directVehicle, true, false) + } + + val mounted = nmsPlayer.vehicle === directVehicle + if (!mounted) { + spawnedRoot?.let(::discardVehicleTree) + spawnedRoot = null + } + + mounted + } catch (_: Exception) { + spawnedRoot?.let(::discardVehicleTree) + false + } } override fun spawnVehicleTree( @@ -148,20 +177,37 @@ class V26_2SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { ): Boolean { val level = world.toNms() - val tag = NbtIo.readCompressed(ByteArrayInputStream(nbt), NbtAccounter.unlimitedHeap()) - tag.put("Pos", doubleList(x, y, z)) - tag.put("Rotation", floatList(yaw, pitch)) + var spawnedRoot: NmsEntity? = null + return try { + val vehicleNbt = readVehicleTreeNbt(nbt) ?: return false - val root = NmsEntityType.loadEntityRecursive( - tag, - level, - EntitySpawnRequest(EntitySpawnReason.LOAD, false), - EntityProcessor { entity -> - if (level.addWithUUID(entity, CreatureSpawnEvent.SpawnReason.MOUNT)) entity else null + val existingRoot = getEntityByUuid(level, vehicleNbt.rootUuid) + if (existingRoot != null) { + val complete = vehicleTreeContainsAll(existingRoot, vehicleNbt.entityUuids) + if (complete) { + standUpCamels(existingRoot) + } + return complete } - ) ?: return false - standUpCamels(root) - return true + + if (hasPartialUuidCollision(level, vehicleNbt.entityUuids, vehicleNbt.rootUuid)) return false + + applyTargetTransform(vehicleNbt.tag, x, y, z, yaw, pitch) + val root = spawnVehicleTreeFromTag(level, vehicleNbt.tag) ?: return false + spawnedRoot = root + standUpCamels(root) + + val complete = vehicleTreeContainsAll(root, vehicleNbt.entityUuids) + if (!complete) { + discardVehicleTree(root) + spawnedRoot = null + } + + complete + } catch (_: Exception) { + spawnedRoot?.let(::discardVehicleTree) + false + } } override fun mountPassengersInOrder(vehicle: Entity, orderedPassengers: List): Boolean { @@ -180,6 +226,114 @@ class V26_2SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { return nmsVehicle.passengers.isNotEmpty() } + private fun readVehicleTreeNbt(nbt: ByteArray): VehicleTreeNbt? { + val tag = try { + NbtIo.readCompressed(ByteArrayInputStream(nbt), NbtAccounter.unlimitedHeap()) + } catch (_: Exception) { + return null + } + + val entityUuids = LinkedHashSet() + if (!collectVehicleUuids(tag, entityUuids)) return null + + return VehicleTreeNbt( + tag = tag, + rootUuid = entityUuids.firstOrNull() ?: return null, + entityUuids = entityUuids, + ) + } + + private fun collectVehicleUuids(tag: CompoundTag, entityUuids: MutableSet): Boolean { + val uuid = tag.entityUuidOrNull() ?: return false + if (!entityUuids.add(uuid)) return false + + val passengers = tag.getList(NmsEntity.TAG_PASSENGERS).orElse(null) + if (passengers == null) { + return !tag.contains(NmsEntity.TAG_PASSENGERS) + } + + for (i in 0 until passengers.size) { + val passengerTag = passengers.getCompound(i).orElse(null) ?: return false + if (!collectVehicleUuids(passengerTag, entityUuids)) return false + } + + return true + } + + private fun CompoundTag.entityUuidOrNull(): UUID? { + val uuid = getIntArray(NmsEntity.TAG_UUID).orElse(null) ?: return null + if (uuid.size != 4) return null + + return UUIDUtil.uuidFromIntArray(uuid) + } + + private fun getEntityByUuid(level: ServerLevel, uuid: UUID): NmsEntity? { + return level.entities.get(uuid) + } + + private fun hasPartialUuidCollision( + level: ServerLevel, + entityUuids: Set, + rootUuid: UUID, + ): Boolean { + for (uuid in entityUuids) { + if (uuid == rootUuid) continue + if (getEntityByUuid(level, uuid) != null) return true + } + + return false + } + + private fun vehicleTreeContainsAll(root: NmsEntity, entityUuids: Set): Boolean { + val existingUuids = LinkedHashSet() + existingUuids.add(root.uuid) + for (passenger in root.indirectPassengers) { + existingUuids.add(passenger.uuid) + } + + return existingUuids.containsAll(entityUuids) + } + + private fun findEntityInTree(root: NmsEntity, uuid: UUID): NmsEntity? { + if (root.uuid == uuid) return root + return root.indirectPassengers.firstOrNull { it.uuid == uuid } + } + + private fun applyTargetTransform( + tag: CompoundTag, + x: Double, + y: Double, + z: Double, + yaw: Float, + pitch: Float, + ) { + // Override the captured crossing position with the safe target position so the vehicle + // does not re-appear on the border. + tag.put(NmsEntity.TAG_POS, doubleList(x, y, z)) + tag.put(NmsEntity.TAG_ROTATION, floatList(yaw, pitch)) + } + + private fun spawnVehicleTreeFromTag(level: ServerLevel, tag: CompoundTag): NmsEntity? { + return NmsEntityType.loadEntityRecursive( + tag, + level, + EntitySpawnRequest(EntitySpawnReason.LOAD, false), + EntityProcessor { entity -> + if (level.addWithUUID(entity, CreatureSpawnEvent.SpawnReason.MOUNT)) entity else null + } + ) + } + + private fun discardVehicleTree(root: NmsEntity) { + val entities = ArrayList() + entities.add(root) + entities.addAll(root.indirectPassengers) + + for (entity in entities.asReversed()) { + entity.discard() + } + } + /** * Camels encode their (sitting) pose as an absolute game-time tick. After migrating to a shard * with a different game time that tick lies in the past/future, leaving the camel stuck in a @@ -207,4 +361,10 @@ class V26_2SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { companion object { private val VEHICLE_LOGGER = ComponentLogger.logger("SurfPaperNmsEntityBridge Vehicle") } + + private data class VehicleTreeNbt( + val tag: CompoundTag, + val rootUuid: UUID, + val entityUuids: Set, + ) } diff --git a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsPlayerBridgeImpl.kt b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsPlayerBridgeImpl.kt index 18e7c2a8..9d8d2621 100644 --- a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsPlayerBridgeImpl.kt +++ b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsPlayerBridgeImpl.kt @@ -151,7 +151,11 @@ class V26_2SurfPaperNmsPlayerBridgeImpl : SurfPaperNmsPlayerBridge { val nmsEntity = entity.toNms() if (nmsEntity === nmsViewer) return false - val tracker = nmsViewer.level().chunkSource.chunkMap.entityMap.get(nmsEntity.id) ?: return false + val viewerLevel = nmsViewer.level() + val entityLevel = nmsEntity.level() + if (entityLevel !== viewerLevel) return false + + val tracker = viewerLevel.chunkSource.chunkMap.entityMap.get(nmsEntity.id) ?: return false return try { if (!tracker.seenBy.contains(connection)) { tracker.seenBy.add(connection) @@ -690,4 +694,4 @@ class V26_2SurfPaperNmsPlayerBridgeImpl : SurfPaperNmsPlayerBridge { private val OFFLINE_INVENTORY_EDIT_LOGGER = ComponentLogger.logger("OfflinePlayer Inventory Edit") private val CHAT_LOGGER = ComponentLogger.logger("SurfPaperNmsPlayerBridge Chat") } -} \ No newline at end of file +} diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge.kt index ab25f15b..0e51260c 100644 --- a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge.kt +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge.kt @@ -40,19 +40,29 @@ interface SurfPaperNmsEntityBridge { fun captureVehicleNbt(rootVehicle: Entity): ByteArray /** - * Recreates a vehicle tree previously captured with [captureVehicleNbt] in [player]'s current - * world at the given coordinates / rotation, then force-mounts [player] onto the entity - * identified by [directVehicleUuid]. + * Ensures that a vehicle tree previously captured with [captureVehicleNbt] exists in + * [player]'s current world at the given coordinates / rotation, then force-mounts [player] + * onto the entity identified by [directVehicleUuid]. * - * The tree is recreated with its original entity UUIDs using add-with-uuid semantics, so a - * duplicate (an entity whose UUID is already present in the world) is rejected instead of - * being spawned a second time. The root position is overridden with [x]/[y]/[z] so the - * vehicle (and therefore the mounted player) appears at a safe, caller-chosen location rather - * than the captured crossing point. + * The tree uses its original entity UUIDs. If the root UUID is already present, the existing + * root tree is reused as an idempotent retry only when it contains all UUIDs captured in the + * NBT. If the root is absent but any other captured UUID already exists in the target world, + * the restore fails to avoid partial duplicate/collision states. Newly spawned trees are + * removed again if a later restore step fails. + * + * The [directVehicleUuid] is strict: it must identify the root or a passenger contained in the + * captured vehicle tree. A missing direct vehicle is an error, returns false, and never falls + * back to mounting on the root. + * + * When spawning is needed, the root position is overridden with [x]/[y]/[z] so the vehicle + * (and therefore the mounted player) appears at a safe, caller-chosen location rather than the + * captured crossing point. * * Must be called on the owning region/entity tick thread, after [player] is already in-world. * - * @return true if [player] ended up mounted on the recreated vehicle, false otherwise + * @return true if the vehicle tree exists, [directVehicleUuid] was found strictly, and [player] + * ended up mounted on that exact entity; false for invalid NBT, UUID collisions, + * partial trees, missing direct vehicle, spawn failure or mount failure */ fun restoreVehicleAndMount( player: Player, @@ -66,18 +76,23 @@ interface SurfPaperNmsEntityBridge { ): Boolean /** - * Recreates a vehicle tree previously captured with [captureVehicleNbt] in [world] at the given - * coordinates / rotation **without mounting anyone**. + * Ensures that a vehicle tree previously captured with [captureVehicleNbt] exists in [world] + * at the given coordinates / rotation **without mounting anyone**. * * This is the spawn-only counterpart of [restoreVehicleAndMount], used when several player * passengers are migrated as a group: the vehicle must already exist (spawned exactly once) - * before the players are mounted in their original order with [mountPassengersInOrder]. The - * tree keeps its original entity UUIDs (add-with-uuid rejects duplicates), so calling this - * twice for the same group is a no-op for the second call. + * before the players are mounted in their original order with [mountPassengersInOrder]. + * + * The tree keeps its original entity UUIDs and is idempotent for retries: if the captured root + * UUID is already present, the call succeeds only when the existing root tree contains every + * UUID captured in the NBT. If the root is absent but any other captured UUID already exists, + * the call fails to avoid partial duplicate/collision states. * * Must be called on the owning region/entity tick thread. * - * @return true if the root entity was spawned (or already present), false on failure + * @return true if the complete captured vehicle tree is present after the call, whether it was + * newly spawned or already present; false for invalid NBT, UUID collisions, partial + * trees or spawn failure */ fun spawnVehicleTree( world: World, @@ -109,4 +124,4 @@ interface SurfPaperNmsEntityBridge { } @OptIn(NmsUseWithCaution::class) -private val bridge = requiredService() \ No newline at end of file +private val bridge = requiredService() diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge.kt index 1eb10b13..9a747efe 100644 --- a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge.kt +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge.kt @@ -64,6 +64,10 @@ interface SurfPaperNmsPlayerBridge { * individual entity whose id drifted after a seamless server move (which otherwise breaks * interaction packets that reference the network id). * + * [viewer] and [entity] must be in the same world/NMS level. Cross-world calls return false + * before consulting the viewer level's tracker map, because numeric entity ids can collide + * between levels. + * * Must be called on the owning region/entity tick thread. * * @param viewer the player that should receive the refreshed entity @@ -166,4 +170,4 @@ interface SurfPaperNmsPlayerBridge { } @NmsUseWithCaution -private val playerBridge = requiredService() \ No newline at end of file +private val playerBridge = requiredService() From 9cf45aea2cc94e09b5e4b7ed8f131008cecbda35 Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:40:00 +0200 Subject: [PATCH 5/8] chore(api): update Kotlin ABI dumps --- .../surf-api-core/api/surf-api-core.api | 3 ++- .../surf-api-paper/api/surf-api-paper.api | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/surf-api-core/surf-api-core/api/surf-api-core.api b/surf-api-core/surf-api-core/api/surf-api-core.api index a30bf824..cdffe3b2 100644 --- a/surf-api-core/surf-api-core/api/surf-api-core.api +++ b/surf-api-core/surf-api-core/api/surf-api-core.api @@ -11059,7 +11059,8 @@ public final class dev/slne/surf/api/core/util/SerializableErrorKt { public final class dev/slne/surf/api/core/util/ServiceUtil { public static final field INSTANCE Ldev/slne/surf/api/core/util/ServiceUtil; - public final fun serviceWithFallback (Ljava/util/ServiceLoader;Ljava/lang/Class;)Ljava/lang/Object; + public final fun serviceWithFallback (Ljava/lang/Class;)Ljava/lang/Object; + public final synthetic fun serviceWithFallback (Ljava/util/ServiceLoader;Ljava/lang/Class;)Ljava/lang/Object; } public abstract class dev/slne/surf/api/core/util/SurfTypeParameterMatcher { diff --git a/surf-api-paper/surf-api-paper/api/surf-api-paper.api b/surf-api-paper/surf-api-paper/api/surf-api-paper.api index ab6040f3..860fdf86 100644 --- a/surf-api-paper/surf-api-paper/api/surf-api-paper.api +++ b/surf-api-paper/surf-api-paper/api/surf-api-paper.api @@ -1671,16 +1671,24 @@ public final class dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsCommonBridge$ public abstract interface class dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge { public static final field Companion Ldev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge$Companion; + public abstract fun captureVehicleNbt (Lorg/bukkit/entity/Entity;)[B public abstract fun createEntityByNbt (Lorg/bukkit/World;Lorg/bukkit/entity/EntityType;Lio/papermc/paper/math/FinePosition;Lnet/kyori/adventure/nbt/CompoundBinaryTag;)V public abstract fun getById (Lorg/bukkit/World;I)Lorg/bukkit/entity/Entity; + public abstract fun mountPassengersInOrder (Lorg/bukkit/entity/Entity;Ljava/util/List;)Z + public abstract fun restoreVehicleAndMount (Lorg/bukkit/entity/Player;[BLjava/util/UUID;DDDFF)Z public abstract fun setId (Lorg/bukkit/entity/Entity;I)V + public abstract fun spawnVehicleTree (Lorg/bukkit/World;[BDDDFF)Z } public final class dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge$Companion : dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge { + public fun captureVehicleNbt (Lorg/bukkit/entity/Entity;)[B public fun createEntityByNbt (Lorg/bukkit/World;Lorg/bukkit/entity/EntityType;Lio/papermc/paper/math/FinePosition;Lnet/kyori/adventure/nbt/CompoundBinaryTag;)V public fun getById (Lorg/bukkit/World;I)Lorg/bukkit/entity/Entity; public final fun getINSTANCE ()Ldev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge; + public fun mountPassengersInOrder (Lorg/bukkit/entity/Entity;Ljava/util/List;)Z + public fun restoreVehicleAndMount (Lorg/bukkit/entity/Player;[BLjava/util/UUID;DDDFF)Z public fun setId (Lorg/bukkit/entity/Entity;I)V + public fun spawnVehicleTree (Lorg/bukkit/World;[BDDDFF)Z } public abstract interface class dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsGlowingBridge { @@ -1747,6 +1755,11 @@ public abstract interface class dev/slne/surf/api/paper/nms/bridges/SurfPaperNms public abstract fun removeAllTrackedPlayers (Lorg/bukkit/entity/Player;Z)V public static synthetic fun removeAllTrackedPlayers$default (Ldev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge;Lorg/bukkit/entity/Player;ZILjava/lang/Object;)V public abstract fun resetPlayerChatState (Lorg/bukkit/entity/Player;Ldev/slne/surf/api/paper/nms/bridges/data/chat/RemoteChatSessionData;)V + public abstract fun resyncEntityForViewer (Lorg/bukkit/entity/Player;Lorg/bukkit/entity/Entity;Z)Z + public static synthetic fun resyncEntityForViewer$default (Ldev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge;Lorg/bukkit/entity/Player;Lorg/bukkit/entity/Entity;ZILjava/lang/Object;)Z + public abstract fun resyncPlayerState (Lorg/bukkit/entity/Player;)V + public abstract fun resyncVehicleState (Lorg/bukkit/entity/Player;Z)I + public static synthetic fun resyncVehicleState$default (Ldev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge;Lorg/bukkit/entity/Player;ZILjava/lang/Object;)I public abstract fun runOnChatMessageChain (Lorg/bukkit/entity/Player;Lkotlinx/coroutines/CoroutineScope;Lkotlin/jvm/functions/Function1;)V public abstract fun sendPlayerChatMessage (Lorg/bukkit/entity/Player;Lnet/kyori/adventure/chat/SignedMessage;Lnet/kyori/adventure/chat/ChatType$Bound;)V public abstract fun sendSignedMessageWithChangedContent (Lorg/bukkit/entity/Player;Lnet/kyori/adventure/chat/SignedMessage;Lnet/kyori/adventure/chat/ChatType$Bound;Lnet/kyori/adventure/text/Component;)V @@ -1768,6 +1781,9 @@ public final class dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge$ public fun removeAllTrackedEntities (Lorg/bukkit/entity/Player;Z)V public fun removeAllTrackedPlayers (Lorg/bukkit/entity/Player;Z)V public fun resetPlayerChatState (Lorg/bukkit/entity/Player;Ldev/slne/surf/api/paper/nms/bridges/data/chat/RemoteChatSessionData;)V + public fun resyncEntityForViewer (Lorg/bukkit/entity/Player;Lorg/bukkit/entity/Entity;Z)Z + public fun resyncPlayerState (Lorg/bukkit/entity/Player;)V + public fun resyncVehicleState (Lorg/bukkit/entity/Player;Z)I public fun runOnChatMessageChain (Lorg/bukkit/entity/Player;Lkotlinx/coroutines/CoroutineScope;Lkotlin/jvm/functions/Function1;)V public fun sendPlayerChatMessage (Lorg/bukkit/entity/Player;Lnet/kyori/adventure/chat/SignedMessage;Lnet/kyori/adventure/chat/ChatType$Bound;)V public fun sendSignedMessageWithChangedContent (Lorg/bukkit/entity/Player;Lnet/kyori/adventure/chat/SignedMessage;Lnet/kyori/adventure/chat/ChatType$Bound;Lnet/kyori/adventure/text/Component;)V @@ -1778,6 +1794,8 @@ public final class dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge$ public static synthetic fun createPlayerChatMessageMirrorFromAdventure$default (Ldev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge;Lnet/kyori/adventure/chat/SignedMessage;Lnet/kyori/adventure/text/Component;ILjava/lang/Object;)Ldev/slne/surf/api/paper/nms/bridges/data/chat/PlayerChatMessageMirror; public static synthetic fun removeAllTrackedEntities$default (Ldev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge;Lorg/bukkit/entity/Player;ZILjava/lang/Object;)V public static synthetic fun removeAllTrackedPlayers$default (Ldev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge;Lorg/bukkit/entity/Player;ZILjava/lang/Object;)V + public static synthetic fun resyncEntityForViewer$default (Ldev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge;Lorg/bukkit/entity/Player;Lorg/bukkit/entity/Entity;ZILjava/lang/Object;)Z + public static synthetic fun resyncVehicleState$default (Ldev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge;Lorg/bukkit/entity/Player;ZILjava/lang/Object;)I } public final class dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge$PlayerInventoryEdit { From 65595af46a3df04420d61658d51122e965d9bd25 Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:53:28 +0200 Subject: [PATCH 6/8] chore(api): update KDoc comments --- .../paper/nms/bridges/SurfPaperNmsEntityBridge.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge.kt index 0e51260c..ca362fff 100644 --- a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge.kt +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge.kt @@ -11,7 +11,7 @@ import org.bukkit.World import org.bukkit.entity.Entity import org.bukkit.entity.EntityType import org.bukkit.entity.Player -import java.util.UUID +import java.util.* @NmsUseWithCaution interface SurfPaperNmsEntityBridge { @@ -41,8 +41,9 @@ interface SurfPaperNmsEntityBridge { /** * Ensures that a vehicle tree previously captured with [captureVehicleNbt] exists in - * [player]'s current world at the given coordinates / rotation, then force-mounts [player] - * onto the entity identified by [directVehicleUuid]. + * [player]'s current world. When spawning is required, the root is spawned at the provided + * coordinates / rotation, then [player] is force-mounted onto the entity identified by + * [directVehicleUuid]. * * The tree uses its original entity UUIDs. If the root UUID is already present, the existing * root tree is reused as an idempotent retry only when it contains all UUIDs captured in the @@ -76,8 +77,9 @@ interface SurfPaperNmsEntityBridge { ): Boolean /** - * Ensures that a vehicle tree previously captured with [captureVehicleNbt] exists in [world] - * at the given coordinates / rotation **without mounting anyone**. + * Ensures that a vehicle tree previously captured with [captureVehicleNbt] exists in [world]. + * When spawning is required, the root is spawned at the provided coordinates / rotation + * **without mounting anyone**. * * This is the spawn-only counterpart of [restoreVehicleAndMount], used when several player * passengers are migrated as a group: the vehicle must already exist (spawned exactly once) From beafec9dad955dab900511c8857af7f95f0a5d26 Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:05:57 +0200 Subject: [PATCH 7/8] =?UTF-8?q?=E2=9C=A8=20feat(vehicle):=20improve=20pass?= =?UTF-8?q?enger=20mounting=20logic=20in=20vehicle=20trees?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - update mountPassengersInOrder to ensure all ordered passengers ride the vehicle - enhance logic to stop riding if already mounted on a different vehicle - return true only if all passengers are successfully mounted --- .../bridges/V1_21_11SurfPaperNmsEntityBridgeImpl.kt | 11 ++++++++--- .../bridges/V26_1SurfPaperNmsEntityBridgeImpl.kt | 11 ++++++++--- .../bridges/V26_2SurfPaperNmsEntityBridgeImpl.kt | 11 ++++++++--- .../api/paper/nms/bridges/SurfPaperNmsEntityBridge.kt | 2 +- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsEntityBridgeImpl.kt b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsEntityBridgeImpl.kt index 4a9b75b2..75589d58 100644 --- a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsEntityBridgeImpl.kt +++ b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsEntityBridgeImpl.kt @@ -210,6 +210,7 @@ class V1_21_11SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { override fun mountPassengersInOrder(vehicle: Entity, orderedPassengers: List): Boolean { val nmsVehicle = vehicle.toNms() + val nmsPassengers = orderedPassengers.map { it.toNms() } // Clear the current passenger list first so the order is fully controlled by us. for (passenger in nmsVehicle.passengers.toList()) { @@ -217,11 +218,15 @@ class V1_21_11SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { } // Re-mount in order; the server appends each passenger, so the resulting order matches. - for (passenger in orderedPassengers) { - passenger.toNms().startRiding(nmsVehicle, true, false) + for (passenger in nmsPassengers) { + if (passenger.vehicle !== null && passenger.vehicle !== nmsVehicle) { + passenger.stopRiding() + } + passenger.startRiding(nmsVehicle, true, false) + if (passenger.vehicle !== nmsVehicle) return false } - return nmsVehicle.passengers.isNotEmpty() + return nmsPassengers.isNotEmpty() && nmsVehicle.passengers.containsAll(nmsPassengers) } private fun readVehicleTreeNbt(nbt: ByteArray): VehicleTreeNbt? { diff --git a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsEntityBridgeImpl.kt b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsEntityBridgeImpl.kt index ee63fdc9..91006c4b 100644 --- a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsEntityBridgeImpl.kt +++ b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsEntityBridgeImpl.kt @@ -211,6 +211,7 @@ class V26_1SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { override fun mountPassengersInOrder(vehicle: Entity, orderedPassengers: List): Boolean { val nmsVehicle = vehicle.toNms() + val nmsPassengers = orderedPassengers.map { it.toNms() } // Clear the current passenger list first so the order is fully controlled by us. for (passenger in nmsVehicle.passengers.toList()) { @@ -218,11 +219,15 @@ class V26_1SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { } // Re-mount in order; the server appends each passenger, so the resulting order matches. - for (passenger in orderedPassengers) { - passenger.toNms().startRiding(nmsVehicle, true, false) + for (passenger in nmsPassengers) { + if (passenger.vehicle !== null && passenger.vehicle !== nmsVehicle) { + passenger.stopRiding() + } + passenger.startRiding(nmsVehicle, true, false) + if (passenger.vehicle !== nmsVehicle) return false } - return nmsVehicle.passengers.isNotEmpty() + return nmsPassengers.isNotEmpty() && nmsVehicle.passengers.containsAll(nmsPassengers) } private fun readVehicleTreeNbt(nbt: ByteArray): VehicleTreeNbt? { diff --git a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsEntityBridgeImpl.kt b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsEntityBridgeImpl.kt index b8d09034..e61c6293 100644 --- a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsEntityBridgeImpl.kt +++ b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsEntityBridgeImpl.kt @@ -212,6 +212,7 @@ class V26_2SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { override fun mountPassengersInOrder(vehicle: Entity, orderedPassengers: List): Boolean { val nmsVehicle = vehicle.toNms() + val nmsPassengers = orderedPassengers.map { it.toNms() } // Clear the current passenger list first so the order is fully controlled by us. for (passenger in nmsVehicle.passengers.toList()) { @@ -219,11 +220,15 @@ class V26_2SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { } // Re-mount in order; the server appends each passenger, so the resulting order matches. - for (passenger in orderedPassengers) { - passenger.toNms().startRiding(nmsVehicle, true, false) + for (passenger in nmsPassengers) { + if (passenger.vehicle !== null && passenger.vehicle !== nmsVehicle) { + passenger.stopRiding() + } + passenger.startRiding(nmsVehicle, true, false) + if (passenger.vehicle !== nmsVehicle) return false } - return nmsVehicle.passengers.isNotEmpty() + return nmsPassengers.isNotEmpty() && nmsVehicle.passengers.containsAll(nmsPassengers) } private fun readVehicleTreeNbt(nbt: ByteArray): VehicleTreeNbt? { diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge.kt index ca362fff..f0956bc7 100644 --- a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge.kt +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge.kt @@ -116,7 +116,7 @@ interface SurfPaperNmsEntityBridge { * * Must be called on the owning region/entity tick thread, with every entity already in-world. * - * @return true if [vehicle] ended up with at least one passenger + * @return true only if every entity in [orderedPassengers] ended up directly riding [vehicle] */ fun mountPassengersInOrder(vehicle: Entity, orderedPassengers: List): Boolean From a121698799b992a8afc43e038c8cab5d924d5e95 Mon Sep 17 00:00:00 2001 From: twisti-dev <76837088+twisti-dev@users.noreply.github.com> Date: Sat, 27 Jun 2026 09:57:23 +0200 Subject: [PATCH 8/8] =?UTF-8?q?=E2=9C=A8=20feat(vehicle):=20enhance=20vehi?= =?UTF-8?q?cle=20resync=20logic=20for=20player=20state=20and=20passenger?= =?UTF-8?q?=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - update vehicle resync logic to use ObjectLinkedOpenHashSet for improved performance - remove redundant checks and streamline passenger re-pairing process - update version to 3.30.0 in gradle.properties --- .../main/kotlin/core-convention.gradle.kts | 9 +- gradle.properties | 2 +- .../V1_21_11SurfPaperNmsEntityBridgeImpl.kt | 341 +++++------------ .../V1_21_11SurfPaperNmsPlayerBridgeImpl.kt | 45 +-- .../V26_1SurfPaperNmsEntityBridgeImpl.kt | 349 +++++------------- .../V26_1SurfPaperNmsPlayerBridgeImpl.kt | 45 +-- .../V26_2SurfPaperNmsEntityBridgeImpl.kt | 348 ++++------------- .../V26_2SurfPaperNmsPlayerBridgeImpl.kt | 45 +-- .../surf-api-paper/api/surf-api-paper.api | 12 +- .../nms/bridges/SurfPaperNmsEntityBridge.kt | 91 +---- .../nms/bridges/SurfPaperNmsPlayerBridge.kt | 58 --- 11 files changed, 267 insertions(+), 1078 deletions(-) diff --git a/buildSrc/src/main/kotlin/core-convention.gradle.kts b/buildSrc/src/main/kotlin/core-convention.gradle.kts index 3c8730c3..72838850 100644 --- a/buildSrc/src/main/kotlin/core-convention.gradle.kts +++ b/buildSrc/src/main/kotlin/core-convention.gradle.kts @@ -61,14 +61,15 @@ configurations { } } -tasks.withType { - duplicatesStrategy = DuplicatesStrategy.EXCLUDE +tasks.withType().configureEach { + if (this !is com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + } } tasks { shadowJar { mergeServiceFiles() - duplicatesStrategy = DuplicatesStrategy.EXCLUDE val relocationPrefix: String by project relocate("net.kyori.adventure.nbt", "$relocationPrefix.kyori.nbt") { @@ -78,8 +79,10 @@ tasks { } javadoc { + isFailOnError = false val options = options as StandardJavadocDocletOptions options.use() options.tags("apiNote:a:API Note:") + options.addStringOption("Xdoclint:none", "-quiet") } } diff --git a/gradle.properties b/gradle.properties index 7323dd41..45d9a9d4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,6 +7,6 @@ org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled javaVersion=25 mcVersion=26.2 group=dev.slne.surf.api -version=3.29.0 +version=3.30.0 relocationPrefix=dev.slne.surf.api.libs snapshot=false diff --git a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsEntityBridgeImpl.kt b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsEntityBridgeImpl.kt index 75589d58..ccc7cfa2 100644 --- a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsEntityBridgeImpl.kt +++ b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsEntityBridgeImpl.kt @@ -1,5 +1,6 @@ package dev.slne.surf.api.paper.server.nms.v1_21_11.bridges +import ca.spottedleaf.moonrise.common.PlatformHooks import ca.spottedleaf.moonrise.common.util.TickThread import com.mojang.brigadier.exceptions.CommandSyntaxException import dev.jorel.commandapi.exceptions.WrapperCommandSyntaxException @@ -13,31 +14,29 @@ import dev.slne.surf.api.paper.util.chunkZ import io.papermc.paper.math.FinePosition import net.kyori.adventure.nbt.CompoundBinaryTag import net.kyori.adventure.text.logger.slf4j.ComponentLogger -import net.minecraft.core.UUIDUtil +import net.minecraft.SharedConstants import net.minecraft.nbt.CompoundTag -import net.minecraft.nbt.DoubleTag -import net.minecraft.nbt.FloatTag -import net.minecraft.nbt.ListTag import net.minecraft.nbt.NbtAccounter import net.minecraft.nbt.NbtIo +import net.minecraft.nbt.NbtUtils import net.minecraft.server.MinecraftServer import net.minecraft.server.commands.SummonCommand -import net.minecraft.server.level.ServerLevel import net.minecraft.util.ProblemReporter -import net.minecraft.world.entity.Entity as NmsEntity -import net.minecraft.world.entity.EntityProcessor +import net.minecraft.util.datafix.fixes.References import net.minecraft.world.entity.EntitySpawnReason -import net.minecraft.world.entity.EntityType as NmsEntityType -import net.minecraft.world.entity.animal.camel.Camel +import net.minecraft.world.entity.Pose.CODEC +import net.minecraft.world.level.storage.TagValueInput import net.minecraft.world.level.storage.TagValueOutput import org.bukkit.World import org.bukkit.entity.Entity import org.bukkit.entity.EntityType -import org.bukkit.entity.Player -import org.bukkit.event.entity.CreatureSpawnEvent import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream -import java.util.UUID +import java.io.IOException +import java.util.* +import kotlin.jvm.optionals.getOrNull +import net.minecraft.world.entity.Entity as NmsEntity +import net.minecraft.world.entity.EntityType as NmsEntityType @NmsUseWithCaution class V1_21_11SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { @@ -83,282 +82,114 @@ class V1_21_11SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { override fun captureVehicleNbt(rootVehicle: Entity): ByteArray { val nmsEntity = rootVehicle.toNms() - val level = nmsEntity.level() + TickThread.ensureTickThread(nmsEntity, "Cannot capture vehicle NBT asynchronously") - val tag = CompoundTag() - ProblemReporter.ScopedCollector(VEHICLE_LOGGER).use { reporter -> - val output = TagValueOutput.createWrappingWithContext(reporter, level.registryAccess(), tag) - // Saves "id" + Pos/Motion/Rotation/UUID + non-player passengers; players are excluded. + return ProblemReporter.ScopedCollector(VEHICLE_LOGGER).use { reporter -> + val output = TagValueOutput.createWithContext(reporter, nmsEntity.registryAccess()) nmsEntity.save(output) - } - val out = ByteArrayOutputStream() - NbtIo.writeCompressed(tag, out) - return out.toByteArray() - } + val additions = output.child("surf-api-addtions") + nmsEntity.passengersAndSelf + .filter { it.type.canSerialize() } + .forEach { entity -> + val tag = additions.child(entity.uuid.toString()) - override fun restoreVehicleAndMount( - player: Player, - nbt: ByteArray, - directVehicleUuid: UUID, - x: Double, - y: Double, - z: Double, - yaw: Float, - pitch: Float, - ): Boolean { - val nmsPlayer = player.toNms() - val level = nmsPlayer.level() + tag.store("Pose", CODEC, entity.pose) - var spawnedRoot: NmsEntity? = null - return try { - val vehicleNbt = readVehicleTreeNbt(nbt) ?: return false - if (directVehicleUuid !in vehicleNbt.entityUuids) return false - - val existingRoot = getEntityByUuid(level, vehicleNbt.rootUuid) - val root = if (existingRoot != null) { - if (!vehicleTreeContainsAll(existingRoot, vehicleNbt.entityUuids)) return false - existingRoot - } else { - if (hasPartialUuidCollision(level, vehicleNbt.entityUuids, vehicleNbt.rootUuid)) return false - - applyTargetTransform(vehicleNbt.tag, x, y, z, yaw, pitch) - val spawned = spawnVehicleTreeFromTag(level, vehicleNbt.tag) ?: return false - spawnedRoot = spawned - standUpCamels(spawned) - - if (!vehicleTreeContainsAll(spawned, vehicleNbt.entityUuids)) { - discardVehicleTree(spawned) - spawnedRoot = null - return false - } - - spawned - } - - standUpCamels(root) - val directVehicle = findEntityInTree(root, directVehicleUuid) - if (directVehicle == null) { - spawnedRoot?.let(::discardVehicleTree) - spawnedRoot = null - return false - } - - if (nmsPlayer.vehicle !== directVehicle) { - if (nmsPlayer.isPassenger) { - nmsPlayer.stopRiding() + val living = entity.asLivingEntity() + if (living != null) { + tag.putInt("ArrowCount", living.arrowCount) + tag.putInt("Stingers", living.stingerCount) + } } - nmsPlayer.startRiding(directVehicle, true, false) - } - val mounted = nmsPlayer.vehicle === directVehicle - if (!mounted) { - spawnedRoot?.let(::discardVehicleTree) - spawnedRoot = null - } - - mounted - } catch (_: Exception) { - spawnedRoot?.let(::discardVehicleTree) - false + serializeNbtToBytes(output.buildResult()) } } - override fun spawnVehicleTree( + override fun restoreVehicle( world: World, nbt: ByteArray, x: Double, y: Double, z: Double, yaw: Float, - pitch: Float, - ): Boolean { + pitch: Float + ): Entity? { val level = world.toNms() + TickThread.ensureTickThread(level, x, z, "Cannot restore vehicle asynchronously") - var spawnedRoot: NmsEntity? = null - return try { - val vehicleNbt = readVehicleTreeNbt(nbt) ?: return false - - val existingRoot = getEntityByUuid(level, vehicleNbt.rootUuid) - if (existingRoot != null) { - val complete = vehicleTreeContainsAll(existingRoot, vehicleNbt.entityUuids) - if (complete) { - standUpCamels(existingRoot) - } - return complete - } - - if (hasPartialUuidCollision(level, vehicleNbt.entityUuids, vehicleNbt.rootUuid)) return false - - applyTargetTransform(vehicleNbt.tag, x, y, z, yaw, pitch) - val root = spawnVehicleTreeFromTag(level, vehicleNbt.tag) ?: return false - spawnedRoot = root - standUpCamels(root) - - val complete = vehicleTreeContainsAll(root, vehicleNbt.entityUuids) - if (!complete) { - discardVehicleTree(root) - spawnedRoot = null - } - - complete - } catch (_: Exception) { - spawnedRoot?.let(::discardVehicleTree) - false - } - } - - override fun mountPassengersInOrder(vehicle: Entity, orderedPassengers: List): Boolean { - val nmsVehicle = vehicle.toNms() - val nmsPassengers = orderedPassengers.map { it.toNms() } - - // Clear the current passenger list first so the order is fully controlled by us. - for (passenger in nmsVehicle.passengers.toList()) { - passenger.stopRiding() - } + var tag = deserializeNbtFromBytes(nbt) + val dataVersion = NbtUtils.getDataVersion(tag, 0) + tag = PlatformHooks.get().convertNBT( + References.ENTITY, + MinecraftServer.getServer().fixerUpper, + tag, + dataVersion, + SharedConstants.getCurrentVersion().dataVersion().version + ) - // Re-mount in order; the server appends each passenger, so the resulting order matches. - for (passenger in nmsPassengers) { - if (passenger.vehicle !== null && passenger.vehicle !== nmsVehicle) { - passenger.stopRiding() + val entity = ProblemReporter.ScopedCollector(VEHICLE_LOGGER).use { reporter -> + val input = TagValueInput.create(reporter, level.registryAccess(), tag) + val additions = input.child("surf-api-addtions").map { additionsInput -> + require(additionsInput is TagValueInput) + additionsInput.input.keySet() + .mapNotNull(fun(uuidStr: String): Pair Unit>? { + val uuid = runCatching { UUID.fromString(uuidStr) }.getOrNull() ?: return null + val addition = additionsInput.child(uuidStr).getOrNull() ?: return null + + val pose = addition.read("Pose", CODEC).getOrNull() + val arrowCount = addition.getInt("ArrowCount").getOrNull() + val stingerCount = addition.getInt("Stingers").getOrNull() + + return uuid to { entity -> + if (pose != null) entity.pose = pose + if (arrowCount != null) entity.asLivingEntity()?.arrowCount = arrowCount + if (stingerCount != null) entity.asLivingEntity()?.stingerCount = stingerCount + } + }) + .toMap() + }.getOrNull().orEmpty() + + NmsEntityType.loadEntityRecursive( + input, + level, + EntitySpawnReason.LOAD + ) { entity -> + additions[entity.uuid]?.invoke(entity) + entity } - passenger.startRiding(nmsVehicle, true, false) - if (passenger.vehicle !== nmsVehicle) return false - } + } ?: return null - return nmsPassengers.isNotEmpty() && nmsVehicle.passengers.containsAll(nmsPassengers) - } + entity.snapTo(x, y, z, yaw, pitch) - private fun readVehicleTreeNbt(nbt: ByteArray): VehicleTreeNbt? { - val tag = try { - NbtIo.readCompressed(ByteArrayInputStream(nbt), NbtAccounter.unlimitedHeap()) - } catch (_: Exception) { + if (!level.tryAddFreshEntityWithPassengers(entity)) { return null } - val entityUuids = LinkedHashSet() - if (!collectVehicleUuids(tag, entityUuids)) return null - - return VehicleTreeNbt( - tag = tag, - rootUuid = entityUuids.firstOrNull() ?: return null, - entityUuids = entityUuids, - ) - } - - private fun collectVehicleUuids(tag: CompoundTag, entityUuids: MutableSet): Boolean { - val uuid = tag.entityUuidOrNull() ?: return false - if (!entityUuids.add(uuid)) return false - - val passengers = tag.getList(NmsEntity.TAG_PASSENGERS).orElse(null) - if (passengers == null) { - return !tag.contains(NmsEntity.TAG_PASSENGERS) - } - - for (i in 0 until passengers.size) { - val passengerTag = passengers.getCompound(i).orElse(null) ?: return false - if (!collectVehicleUuids(passengerTag, entityUuids)) return false - } - - return true - } - - private fun CompoundTag.entityUuidOrNull(): UUID? { - val uuid = getIntArray(NmsEntity.TAG_UUID).orElse(null) ?: return null - if (uuid.size != 4) return null - - return UUIDUtil.uuidFromIntArray(uuid) - } - - private fun getEntityByUuid(level: ServerLevel, uuid: UUID): NmsEntity? { - return level.entities.get(uuid) - } - - private fun hasPartialUuidCollision( - level: ServerLevel, - entityUuids: Set, - rootUuid: UUID, - ): Boolean { - for (uuid in entityUuids) { - if (uuid == rootUuid) continue - if (getEntityByUuid(level, uuid) != null) return true - } - - return false + return entity.bukkitEntity } - private fun vehicleTreeContainsAll(root: NmsEntity, entityUuids: Set): Boolean { - val existingUuids = LinkedHashSet() - existingUuids.add(root.uuid) - for (passenger in root.indirectPassengers) { - existingUuids.add(passenger.uuid) + private fun deserializeNbtFromBytes(data: ByteArray): CompoundTag { + val compound: CompoundTag + try { + compound = NbtIo.readCompressed(ByteArrayInputStream(data), NbtAccounter.unlimitedHeap()) + } catch (ex: IOException) { + throw RuntimeException(ex) } - return existingUuids.containsAll(entityUuids) - } - - private fun findEntityInTree(root: NmsEntity, uuid: UUID): NmsEntity? { - if (root.uuid == uuid) return root - return root.indirectPassengers.firstOrNull { it.uuid == uuid } + return compound } - private fun applyTargetTransform( - tag: CompoundTag, - x: Double, - y: Double, - z: Double, - yaw: Float, - pitch: Float, - ) { - // Override the captured crossing position with the safe target position so the vehicle - // does not re-appear on the border. - tag.put(NmsEntity.TAG_POS, doubleList(x, y, z)) - tag.put(NmsEntity.TAG_ROTATION, floatList(yaw, pitch)) - } - - private fun spawnVehicleTreeFromTag(level: ServerLevel, tag: CompoundTag): NmsEntity? { - return NmsEntityType.loadEntityRecursive( - tag, - level, - EntitySpawnReason.LOAD, - EntityProcessor { entity -> - if (level.addWithUUID(entity, CreatureSpawnEvent.SpawnReason.MOUNT)) entity else null - } - ) - } - - private fun discardVehicleTree(root: NmsEntity) { - val entities = ArrayList() - entities.add(root) - entities.addAll(root.indirectPassengers) - - for (entity in entities.asReversed()) { - entity.discard() - } - } - - /** - * Camels encode their (sitting) pose as an absolute game-time tick. After migrating to a shard - * with a different game time that tick lies in the past/future, leaving the camel stuck in a - * sit/stand transition (rendered lying down). Re-stand any migrated camel so its pose tick is - * rebased onto this shard's game time. - */ - private fun standUpCamels(root: NmsEntity) { - if (root is Camel) runCatching { root.standUpInstantly() } - for (passenger in root.indirectPassengers) { - if (passenger is Camel) runCatching { passenger.standUpInstantly() } + private fun serializeNbtToBytes(compound: CompoundTag): ByteArray { + val baos = ByteArrayOutputStream() + try { + NbtIo.writeCompressed(compound, baos) + } catch (ex: IOException) { + throw RuntimeException(ex) } - } - - private fun doubleList(x: Double, y: Double, z: Double) = ListTag().apply { - add(DoubleTag.valueOf(x)) - add(DoubleTag.valueOf(y)) - add(DoubleTag.valueOf(z)) - } - private fun floatList(yaw: Float, pitch: Float) = ListTag().apply { - add(FloatTag.valueOf(yaw)) - add(FloatTag.valueOf(pitch)) + return baos.toByteArray() } companion object { diff --git a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsPlayerBridgeImpl.kt b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsPlayerBridgeImpl.kt index 3f6e5a90..b03a5c9a 100644 --- a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsPlayerBridgeImpl.kt +++ b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v1-21-11/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v1_21_11/bridges/V1_21_11SurfPaperNmsPlayerBridgeImpl.kt @@ -14,6 +14,7 @@ import dev.slne.surf.api.paper.nms.common.dummy.DummyEntityEquipment import dev.slne.surf.api.paper.server.nms.v1_21_11.extensions.toNms import dev.slne.surf.api.paper.server.nms.v1_21_11.reflection.V1_21_11NmsReflections import io.papermc.paper.adventure.PaperAdventure +import it.unimi.dsi.fastutil.objects.ObjectLinkedOpenHashSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -34,7 +35,6 @@ import net.minecraft.util.ProblemReporter import net.minecraft.util.ProblemReporter.ScopedCollector import net.minecraft.util.Util import net.minecraft.world.ItemStackWithSlot -import net.minecraft.world.entity.Entity as NmsEntity import net.minecraft.world.entity.EntityEquipment import net.minecraft.world.entity.LivingEntity import net.minecraft.world.entity.npc.InventoryCarrier @@ -43,7 +43,6 @@ import net.minecraft.world.entity.player.ProfilePublicKey import net.minecraft.world.level.storage.* import org.bukkit.craftbukkit.CraftEquipmentSlot import org.bukkit.craftbukkit.inventory.CraftItemStack -import org.bukkit.entity.Entity import org.bukkit.entity.Player import org.bukkit.inventory.EquipmentSlot import org.bukkit.inventory.ItemStack @@ -101,27 +100,18 @@ class V1_21_11SurfPaperNmsPlayerBridgeImpl : SurfPaperNmsPlayerBridge { val root = nmsPlayer.rootVehicle if (root === nmsPlayer && nmsPlayer.passengers.isEmpty()) { - // Player is neither riding anything nor carrying passengers -> nothing to reconcile. return 0 } - // The whole connected vehicle tree, in a stable order (root first). - val chain = LinkedHashSet() - chain.add(root) - for (passenger in root.indirectPassengers) { - chain.add(passenger) - } + val chain = ObjectLinkedOpenHashSet(root.passengersAndSelf.iterator()) + chain.addFirst(root) - // Pass 1: re-pair every entity of the tree (except the player itself) so the client gets a - // clean copy carrying the server's current network id, metadata, equipment and links. var resynced = 0 for (entity in chain) { if (entity === nmsPlayer) continue val tracker = chunkMap.entityMap.get(entity.id) ?: continue try { - if (!tracker.seenBy.contains(connection)) { - tracker.seenBy.add(connection) - } + tracker.seenBy.add(connection) tracker.serverEntity.removePairing(nmsPlayer) tracker.serverEntity.addPairing(nmsPlayer) resynced++ @@ -130,8 +120,6 @@ class V1_21_11SurfPaperNmsPlayerBridgeImpl : SurfPaperNmsPlayerBridge { } } - // Pass 2: re-assert every passenger link now that all involved entities are guaranteed to - // exist on the client. This re-mounts the player and resolves stacked vehicles in order. for (entity in chain) { if (entity.passengers.isEmpty()) continue try { @@ -144,31 +132,6 @@ class V1_21_11SurfPaperNmsPlayerBridgeImpl : SurfPaperNmsPlayerBridge { return resynced } - @Suppress("USELESS_ELVIS") - override fun resyncEntityForViewer(viewer: Player, entity: Entity, swallowExceptions: Boolean): Boolean { - val nmsViewer = viewer.toNms() - val connection = nmsViewer.connection ?: return false - val nmsEntity = entity.toNms() - if (nmsEntity === nmsViewer) return false - - val viewerLevel = nmsViewer.level() - val entityLevel = nmsEntity.level() - if (entityLevel !== viewerLevel) return false - - val tracker = viewerLevel.chunkSource.chunkMap.entityMap.get(nmsEntity.id) ?: return false - return try { - if (!tracker.seenBy.contains(connection)) { - tracker.seenBy.add(connection) - } - tracker.serverEntity.removePairing(nmsViewer) - tracker.serverEntity.addPairing(nmsViewer) - true - } catch (e: Throwable) { - if (!swallowExceptions) throw e - false - } - } - @Suppress("USELESS_ELVIS") override fun resyncPlayerState(player: Player) { val nmsPlayer = player.toNms() diff --git a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsEntityBridgeImpl.kt b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsEntityBridgeImpl.kt index 91006c4b..0d747717 100644 --- a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsEntityBridgeImpl.kt +++ b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsEntityBridgeImpl.kt @@ -1,5 +1,6 @@ package dev.slne.surf.api.paper.server.nms.v26_1.bridges +import ca.spottedleaf.moonrise.common.PlatformHooks import ca.spottedleaf.moonrise.common.util.TickThread import com.mojang.brigadier.exceptions.CommandSyntaxException import dev.jorel.commandapi.exceptions.WrapperCommandSyntaxException @@ -13,31 +14,29 @@ import dev.slne.surf.api.paper.util.chunkZ import io.papermc.paper.math.FinePosition import net.kyori.adventure.nbt.CompoundBinaryTag import net.kyori.adventure.text.logger.slf4j.ComponentLogger -import net.minecraft.core.UUIDUtil +import net.minecraft.SharedConstants import net.minecraft.nbt.CompoundTag -import net.minecraft.nbt.DoubleTag -import net.minecraft.nbt.FloatTag -import net.minecraft.nbt.ListTag import net.minecraft.nbt.NbtAccounter import net.minecraft.nbt.NbtIo +import net.minecraft.nbt.NbtUtils import net.minecraft.server.MinecraftServer import net.minecraft.server.commands.SummonCommand -import net.minecraft.server.level.ServerLevel import net.minecraft.util.ProblemReporter -import net.minecraft.world.entity.Entity as NmsEntity -import net.minecraft.world.entity.EntityProcessor +import net.minecraft.util.datafix.fixes.References import net.minecraft.world.entity.EntitySpawnReason -import net.minecraft.world.entity.EntityType as NmsEntityType -import net.minecraft.world.entity.animal.camel.Camel +import net.minecraft.world.entity.Pose.CODEC +import net.minecraft.world.level.storage.TagValueInput import net.minecraft.world.level.storage.TagValueOutput import org.bukkit.World import org.bukkit.entity.Entity import org.bukkit.entity.EntityType -import org.bukkit.entity.Player -import org.bukkit.event.entity.CreatureSpawnEvent import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream -import java.util.UUID +import java.io.IOException +import java.util.* +import kotlin.jvm.optionals.getOrNull +import net.minecraft.world.entity.Entity as NmsEntity +import net.minecraft.world.entity.EntityType as NmsEntityType @NmsUseWithCaution @Suppress("ClassName") @@ -84,291 +83,113 @@ class V26_1SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { override fun captureVehicleNbt(rootVehicle: Entity): ByteArray { val nmsEntity = rootVehicle.toNms() - val level = nmsEntity.level() + TickThread.ensureTickThread(nmsEntity, "Cannot capture vehicle NBT asynchronously") - val tag = CompoundTag() - ProblemReporter.ScopedCollector(VEHICLE_LOGGER).use { reporter -> - val output = TagValueOutput.createWrappingWithContext(reporter, level.registryAccess(), tag) - // Saves "id" + Pos/Motion/Rotation/UUID + non-player passengers; players are excluded. + return ProblemReporter.ScopedCollector(VEHICLE_LOGGER).use { reporter -> + val output = TagValueOutput.createWithContext(reporter, nmsEntity.registryAccess()) nmsEntity.save(output) - } - val out = ByteArrayOutputStream() - NbtIo.writeCompressed(tag, out) - return out.toByteArray() - } + val additions = output.child("surf-api-addtions") + nmsEntity.passengersAndSelf + .filter { it.type.canSerialize() } + .forEach { entity -> + val tag = additions.child(entity.uuid.toString()) - override fun restoreVehicleAndMount( - player: Player, - nbt: ByteArray, - directVehicleUuid: UUID, - x: Double, - y: Double, - z: Double, - yaw: Float, - pitch: Float, - ): Boolean { - val nmsPlayer = player.toNms() - val level = nmsPlayer.level() + tag.store("Pose", CODEC, entity.pose) - var spawnedRoot: NmsEntity? = null - return try { - val vehicleNbt = readVehicleTreeNbt(nbt) ?: return false - if (directVehicleUuid !in vehicleNbt.entityUuids) return false - - val existingRoot = getEntityByUuid(level, vehicleNbt.rootUuid) - val root = if (existingRoot != null) { - if (!vehicleTreeContainsAll(existingRoot, vehicleNbt.entityUuids)) return false - existingRoot - } else { - if (hasPartialUuidCollision(level, vehicleNbt.entityUuids, vehicleNbt.rootUuid)) return false - - applyTargetTransform(vehicleNbt.tag, x, y, z, yaw, pitch) - val spawned = spawnVehicleTreeFromTag(level, vehicleNbt.tag) ?: return false - spawnedRoot = spawned - standUpCamels(spawned) - - if (!vehicleTreeContainsAll(spawned, vehicleNbt.entityUuids)) { - discardVehicleTree(spawned) - spawnedRoot = null - return false - } - - spawned - } - - standUpCamels(root) - val directVehicle = findEntityInTree(root, directVehicleUuid) - if (directVehicle == null) { - spawnedRoot?.let(::discardVehicleTree) - spawnedRoot = null - return false - } - - if (nmsPlayer.vehicle !== directVehicle) { - if (nmsPlayer.isPassenger) { - nmsPlayer.stopRiding() + val living = entity.asLivingEntity() + if (living != null) { + tag.putInt("ArrowCount", living.arrowCount) + tag.putInt("Stingers", living.stingerCount) + } } - nmsPlayer.startRiding(directVehicle, true, false) - } - val mounted = nmsPlayer.vehicle === directVehicle - if (!mounted) { - spawnedRoot?.let(::discardVehicleTree) - spawnedRoot = null - } - - mounted - } catch (_: Exception) { - spawnedRoot?.let(::discardVehicleTree) - false + serializeTagToBytes(output.buildResult()) } } - override fun spawnVehicleTree( + override fun restoreVehicle( world: World, nbt: ByteArray, x: Double, y: Double, z: Double, yaw: Float, - pitch: Float, - ): Boolean { + pitch: Float + ): Entity? { val level = world.toNms() + TickThread.ensureTickThread(level, x, z, "Cannot restore vehicle asynchronously") - var spawnedRoot: NmsEntity? = null - return try { - val vehicleNbt = readVehicleTreeNbt(nbt) ?: return false - - val existingRoot = getEntityByUuid(level, vehicleNbt.rootUuid) - if (existingRoot != null) { - val complete = vehicleTreeContainsAll(existingRoot, vehicleNbt.entityUuids) - if (complete) { - standUpCamels(existingRoot) - } - return complete - } - - if (hasPartialUuidCollision(level, vehicleNbt.entityUuids, vehicleNbt.rootUuid)) return false - - applyTargetTransform(vehicleNbt.tag, x, y, z, yaw, pitch) - val root = spawnVehicleTreeFromTag(level, vehicleNbt.tag) ?: return false - spawnedRoot = root - standUpCamels(root) - - val complete = vehicleTreeContainsAll(root, vehicleNbt.entityUuids) - if (!complete) { - discardVehicleTree(root) - spawnedRoot = null - } - - complete - } catch (_: Exception) { - spawnedRoot?.let(::discardVehicleTree) - false - } - } - - override fun mountPassengersInOrder(vehicle: Entity, orderedPassengers: List): Boolean { - val nmsVehicle = vehicle.toNms() - val nmsPassengers = orderedPassengers.map { it.toNms() } - - // Clear the current passenger list first so the order is fully controlled by us. - for (passenger in nmsVehicle.passengers.toList()) { - passenger.stopRiding() - } + var tag = deserializeTagFromBytes(nbt) + val dataVersion = NbtUtils.getDataVersion(tag, 0) + tag = PlatformHooks.get().convertNBT( + References.ENTITY, + MinecraftServer.getServer().fixerUpper, + tag, + dataVersion, + SharedConstants.getCurrentVersion().dataVersion().version + ) - // Re-mount in order; the server appends each passenger, so the resulting order matches. - for (passenger in nmsPassengers) { - if (passenger.vehicle !== null && passenger.vehicle !== nmsVehicle) { - passenger.stopRiding() + val entity = ProblemReporter.ScopedCollector(VEHICLE_LOGGER).use { reporter -> + val input = TagValueInput.create(reporter, level.registryAccess(), tag) + val additions = input.child("surf-api-addtions").map { additionsInput -> + require(additionsInput is TagValueInput) + additionsInput.input.keySet() + .mapNotNull(fun(uuidStr: String): Pair Unit>? { + val uuid = runCatching { UUID.fromString(uuidStr) }.getOrNull() ?: return null + val addition = additionsInput.child(uuidStr).getOrNull() ?: return null + + val pose = addition.read("Pose", CODEC).getOrNull() + val arrowCount = addition.getInt("ArrowCount").getOrNull() + val stingerCount = addition.getInt("Stingers").getOrNull() + + return uuid to { entity -> + if (pose != null) entity.pose = pose + if (arrowCount != null) entity.asLivingEntity()?.arrowCount = arrowCount + if (stingerCount != null) entity.asLivingEntity()?.stingerCount = stingerCount + } + }) + .toMap() + }.getOrNull().orEmpty() + + NmsEntityType.loadEntityRecursive( + input, + level, + EntitySpawnReason.LOAD + ) { entity -> + additions[entity.uuid]?.invoke(entity) + entity } - passenger.startRiding(nmsVehicle, true, false) - if (passenger.vehicle !== nmsVehicle) return false - } + } ?: return null - return nmsPassengers.isNotEmpty() && nmsVehicle.passengers.containsAll(nmsPassengers) - } + entity.snapTo(x, y, z, yaw, pitch) - private fun readVehicleTreeNbt(nbt: ByteArray): VehicleTreeNbt? { - val tag = try { - NbtIo.readCompressed(ByteArrayInputStream(nbt), NbtAccounter.unlimitedHeap()) - } catch (_: Exception) { + if (!level.tryAddFreshEntityWithPassengers(entity)) { return null } - val entityUuids = LinkedHashSet() - if (!collectVehicleUuids(tag, entityUuids)) return null - - return VehicleTreeNbt( - tag = tag, - rootUuid = entityUuids.firstOrNull() ?: return null, - entityUuids = entityUuids, - ) - } - - private fun collectVehicleUuids(tag: CompoundTag, entityUuids: MutableSet): Boolean { - val uuid = tag.entityUuidOrNull() ?: return false - if (!entityUuids.add(uuid)) return false - - val passengers = tag.getList(NmsEntity.TAG_PASSENGERS).orElse(null) - if (passengers == null) { - return !tag.contains(NmsEntity.TAG_PASSENGERS) - } - - for (i in 0 until passengers.size) { - val passengerTag = passengers.getCompound(i).orElse(null) ?: return false - if (!collectVehicleUuids(passengerTag, entityUuids)) return false - } - - return true - } - - private fun CompoundTag.entityUuidOrNull(): UUID? { - val uuid = getIntArray(NmsEntity.TAG_UUID).orElse(null) ?: return null - if (uuid.size != 4) return null - - return UUIDUtil.uuidFromIntArray(uuid) - } - - private fun getEntityByUuid(level: ServerLevel, uuid: UUID): NmsEntity? { - return level.entities.get(uuid) - } - - private fun hasPartialUuidCollision( - level: ServerLevel, - entityUuids: Set, - rootUuid: UUID, - ): Boolean { - for (uuid in entityUuids) { - if (uuid == rootUuid) continue - if (getEntityByUuid(level, uuid) != null) return true - } - - return false - } - - private fun vehicleTreeContainsAll(root: NmsEntity, entityUuids: Set): Boolean { - val existingUuids = LinkedHashSet() - existingUuids.add(root.uuid) - for (passenger in root.indirectPassengers) { - existingUuids.add(passenger.uuid) - } - - return existingUuids.containsAll(entityUuids) - } - - private fun findEntityInTree(root: NmsEntity, uuid: UUID): NmsEntity? { - if (root.uuid == uuid) return root - return root.indirectPassengers.firstOrNull { it.uuid == uuid } + return entity.bukkitEntity } - private fun applyTargetTransform( - tag: CompoundTag, - x: Double, - y: Double, - z: Double, - yaw: Float, - pitch: Float, - ) { - // Override the captured crossing position with the safe target position so the vehicle - // does not re-appear on the border. - tag.put(NmsEntity.TAG_POS, doubleList(x, y, z)) - tag.put(NmsEntity.TAG_ROTATION, floatList(yaw, pitch)) - } - - private fun spawnVehicleTreeFromTag(level: ServerLevel, tag: CompoundTag): NmsEntity? { - return NmsEntityType.loadEntityRecursive( - tag, - level, - EntitySpawnReason.LOAD, - EntityProcessor { entity -> - if (level.addWithUUID(entity, CreatureSpawnEvent.SpawnReason.MOUNT)) entity else null - } - ) - } - - private fun discardVehicleTree(root: NmsEntity) { - val entities = ArrayList() - entities.add(root) - entities.addAll(root.indirectPassengers) - - for (entity in entities.asReversed()) { - entity.discard() + private fun deserializeTagFromBytes(data: ByteArray): CompoundTag { + try { + return NbtIo.readCompressed(ByteArrayInputStream(data), NbtAccounter.unlimitedHeap()) + } catch (ex: IOException) { + throw RuntimeException(ex) } } - /** - * Camels encode their (sitting) pose as an absolute game-time tick. After migrating to a shard - * with a different game time that tick lies in the past/future, leaving the camel stuck in a - * sit/stand transition (rendered lying down). Re-stand any migrated camel so its pose tick is - * rebased onto this shard's game time. - */ - private fun standUpCamels(root: NmsEntity) { - if (root is Camel) runCatching { root.standUpInstantly() } - for (passenger in root.indirectPassengers) { - if (passenger is Camel) runCatching { passenger.standUpInstantly() } + private fun serializeTagToBytes(compound: CompoundTag): ByteArray { + try { + val baos = ByteArrayOutputStream() + NbtIo.writeCompressed(compound, baos) + return baos.toByteArray() + } catch (ex: IOException) { + throw RuntimeException(ex) } } - private fun doubleList(x: Double, y: Double, z: Double) = ListTag().apply { - add(DoubleTag.valueOf(x)) - add(DoubleTag.valueOf(y)) - add(DoubleTag.valueOf(z)) - } - - private fun floatList(yaw: Float, pitch: Float) = ListTag().apply { - add(FloatTag.valueOf(yaw)) - add(FloatTag.valueOf(pitch)) - } - companion object { - private val VEHICLE_LOGGER = ComponentLogger.logger("SurfPaperNmsEntityBridge Vehicle") + private val VEHICLE_LOGGER = ComponentLogger.logger("V26_1SurfPaperNmsEntityBridgeImpl Vehicle") } - - private data class VehicleTreeNbt( - val tag: CompoundTag, - val rootUuid: UUID, - val entityUuids: Set, - ) } diff --git a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsPlayerBridgeImpl.kt b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsPlayerBridgeImpl.kt index 90789dc0..7cea44eb 100644 --- a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsPlayerBridgeImpl.kt +++ b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-1/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_1/bridges/V26_1SurfPaperNmsPlayerBridgeImpl.kt @@ -12,6 +12,7 @@ import dev.slne.surf.api.paper.nms.common.dummy.DummyEntityEquipment import dev.slne.surf.api.paper.server.nms.v26_1.extensions.toNms import dev.slne.surf.api.paper.server.nms.v26_1.reflection.V26_1NmsReflections import io.papermc.paper.adventure.PaperAdventure +import it.unimi.dsi.fastutil.objects.ObjectLinkedOpenHashSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -33,7 +34,6 @@ import net.minecraft.util.ProblemReporter import net.minecraft.util.ProblemReporter.ScopedCollector import net.minecraft.util.Util import net.minecraft.world.ItemStackWithSlot -import net.minecraft.world.entity.Entity as NmsEntity import net.minecraft.world.entity.EntityEquipment import net.minecraft.world.entity.LivingEntity import net.minecraft.world.entity.npc.InventoryCarrier @@ -42,7 +42,6 @@ import net.minecraft.world.entity.player.ProfilePublicKey import net.minecraft.world.level.storage.* import org.bukkit.craftbukkit.CraftEquipmentSlot import org.bukkit.craftbukkit.inventory.CraftItemStack -import org.bukkit.entity.Entity import org.bukkit.entity.Player import org.bukkit.inventory.EquipmentSlot import org.bukkit.inventory.ItemStack @@ -101,27 +100,18 @@ class V26_1SurfPaperNmsPlayerBridgeImpl : SurfPaperNmsPlayerBridge { val root = nmsPlayer.rootVehicle if (root === nmsPlayer && nmsPlayer.passengers.isEmpty()) { - // Player is neither riding anything nor carrying passengers -> nothing to reconcile. return 0 } - // The whole connected vehicle tree, in a stable order (root first). - val chain = LinkedHashSet() - chain.add(root) - for (passenger in root.indirectPassengers) { - chain.add(passenger) - } + val chain = ObjectLinkedOpenHashSet(root.passengersAndSelf.iterator()) + chain.addFirst(root) - // Pass 1: re-pair every entity of the tree (except the player itself) so the client gets a - // clean copy carrying the server's current network id, metadata, equipment and links. var resynced = 0 for (entity in chain) { if (entity === nmsPlayer) continue val tracker = chunkMap.entityMap.get(entity.id) ?: continue try { - if (!tracker.seenBy.contains(connection)) { - tracker.seenBy.add(connection) - } + tracker.seenBy.add(connection) tracker.serverEntity.removePairing(nmsPlayer) tracker.serverEntity.addPairing(nmsPlayer) resynced++ @@ -130,8 +120,6 @@ class V26_1SurfPaperNmsPlayerBridgeImpl : SurfPaperNmsPlayerBridge { } } - // Pass 2: re-assert every passenger link now that all involved entities are guaranteed to - // exist on the client. This re-mounts the player and resolves stacked vehicles in order. for (entity in chain) { if (entity.passengers.isEmpty()) continue try { @@ -144,31 +132,6 @@ class V26_1SurfPaperNmsPlayerBridgeImpl : SurfPaperNmsPlayerBridge { return resynced } - @Suppress("USELESS_ELVIS") - override fun resyncEntityForViewer(viewer: Player, entity: Entity, swallowExceptions: Boolean): Boolean { - val nmsViewer = viewer.toNms() - val connection = nmsViewer.connection ?: return false - val nmsEntity = entity.toNms() - if (nmsEntity === nmsViewer) return false - - val viewerLevel = nmsViewer.level() - val entityLevel = nmsEntity.level() - if (entityLevel !== viewerLevel) return false - - val tracker = viewerLevel.chunkSource.chunkMap.entityMap.get(nmsEntity.id) ?: return false - return try { - if (!tracker.seenBy.contains(connection)) { - tracker.seenBy.add(connection) - } - tracker.serverEntity.removePairing(nmsViewer) - tracker.serverEntity.addPairing(nmsViewer) - true - } catch (e: Throwable) { - if (!swallowExceptions) throw e - false - } - } - @Suppress("USELESS_ELVIS") override fun resyncPlayerState(player: Player) { val nmsPlayer = player.toNms() diff --git a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsEntityBridgeImpl.kt b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsEntityBridgeImpl.kt index e61c6293..e5d12970 100644 --- a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsEntityBridgeImpl.kt +++ b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsEntityBridgeImpl.kt @@ -1,5 +1,6 @@ package dev.slne.surf.api.paper.server.nms.v26_2.bridges +import ca.spottedleaf.moonrise.common.PlatformHooks import ca.spottedleaf.moonrise.common.util.TickThread import com.mojang.brigadier.exceptions.CommandSyntaxException import dev.jorel.commandapi.exceptions.WrapperCommandSyntaxException @@ -11,34 +12,27 @@ import dev.slne.surf.api.paper.server.nms.v26_2.extensions.toNmsHolder import dev.slne.surf.api.paper.util.chunkX import dev.slne.surf.api.paper.util.chunkZ import io.papermc.paper.math.FinePosition +import io.papermc.paper.util.MCUtil import net.kyori.adventure.nbt.CompoundBinaryTag import net.kyori.adventure.text.logger.slf4j.ComponentLogger -import net.minecraft.core.UUIDUtil -import net.minecraft.nbt.CompoundTag -import net.minecraft.nbt.DoubleTag -import net.minecraft.nbt.FloatTag -import net.minecraft.nbt.ListTag -import net.minecraft.nbt.NbtAccounter -import net.minecraft.nbt.NbtIo +import net.minecraft.SharedConstants +import net.minecraft.nbt.NbtUtils import net.minecraft.server.MinecraftServer import net.minecraft.server.commands.SummonCommand -import net.minecraft.server.level.ServerLevel import net.minecraft.util.ProblemReporter -import net.minecraft.world.entity.Entity as NmsEntity -import net.minecraft.world.entity.EntityProcessor +import net.minecraft.util.datafix.fixes.References import net.minecraft.world.entity.EntitySpawnReason import net.minecraft.world.entity.EntitySpawnRequest -import net.minecraft.world.entity.EntityType as NmsEntityType -import net.minecraft.world.entity.animal.camel.Camel +import net.minecraft.world.entity.Pose.CODEC +import net.minecraft.world.level.storage.TagValueInput import net.minecraft.world.level.storage.TagValueOutput import org.bukkit.World import org.bukkit.entity.Entity import org.bukkit.entity.EntityType -import org.bukkit.entity.Player -import org.bukkit.event.entity.CreatureSpawnEvent -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.util.UUID +import java.util.* +import kotlin.jvm.optionals.getOrNull +import net.minecraft.world.entity.Entity as NmsEntity +import net.minecraft.world.entity.EntityType as NmsEntityType @NmsUseWithCaution @Suppress("ClassName") @@ -85,291 +79,95 @@ class V26_2SurfPaperNmsEntityBridgeImpl : SurfPaperNmsEntityBridge { override fun captureVehicleNbt(rootVehicle: Entity): ByteArray { val nmsEntity = rootVehicle.toNms() - val level = nmsEntity.level() + TickThread.ensureTickThread(nmsEntity, "Cannot capture vehicle NBT asynchronously") - val tag = CompoundTag() - ProblemReporter.ScopedCollector(VEHICLE_LOGGER).use { reporter -> - val output = TagValueOutput.createWrappingWithContext(reporter, level.registryAccess(), tag) - // Saves "id" + Pos/Motion/Rotation/UUID + non-player passengers; players are excluded. + return ProblemReporter.ScopedCollector(VEHICLE_LOGGER).use { reporter -> + val output = TagValueOutput.createWithContext(reporter, nmsEntity.registryAccess()) nmsEntity.save(output) - } - - val out = ByteArrayOutputStream() - NbtIo.writeCompressed(tag, out) - return out.toByteArray() - } - - override fun restoreVehicleAndMount( - player: Player, - nbt: ByteArray, - directVehicleUuid: UUID, - x: Double, - y: Double, - z: Double, - yaw: Float, - pitch: Float, - ): Boolean { - val nmsPlayer = player.toNms() - val level = nmsPlayer.level() - - var spawnedRoot: NmsEntity? = null - return try { - val vehicleNbt = readVehicleTreeNbt(nbt) ?: return false - if (directVehicleUuid !in vehicleNbt.entityUuids) return false - val existingRoot = getEntityByUuid(level, vehicleNbt.rootUuid) - val root = if (existingRoot != null) { - if (!vehicleTreeContainsAll(existingRoot, vehicleNbt.entityUuids)) return false - existingRoot - } else { - if (hasPartialUuidCollision(level, vehicleNbt.entityUuids, vehicleNbt.rootUuid)) return false + val additions = output.child("surf-api-addtions") + nmsEntity.passengersAndSelf + .filter { it.type.canSerialize() } + .forEach { entity -> + val tag = additions.child(entity.uuid.toString()) - applyTargetTransform(vehicleNbt.tag, x, y, z, yaw, pitch) - val spawned = spawnVehicleTreeFromTag(level, vehicleNbt.tag) ?: return false - spawnedRoot = spawned - standUpCamels(spawned) + tag.store("Pose", CODEC, entity.pose) - if (!vehicleTreeContainsAll(spawned, vehicleNbt.entityUuids)) { - discardVehicleTree(spawned) - spawnedRoot = null - return false + val living = entity.asLivingEntity() + if (living != null) { + tag.putInt("ArrowCount", living.arrowCount) + tag.putInt("Stingers", living.stingerCount) + } } - spawned - } - - standUpCamels(root) - val directVehicle = findEntityInTree(root, directVehicleUuid) - if (directVehicle == null) { - spawnedRoot?.let(::discardVehicleTree) - spawnedRoot = null - return false - } - - if (nmsPlayer.vehicle !== directVehicle) { - if (nmsPlayer.isPassenger) { - nmsPlayer.stopRiding() - } - nmsPlayer.startRiding(directVehicle, true, false) - } - - val mounted = nmsPlayer.vehicle === directVehicle - if (!mounted) { - spawnedRoot?.let(::discardVehicleTree) - spawnedRoot = null - } - - mounted - } catch (_: Exception) { - spawnedRoot?.let(::discardVehicleTree) - false + MCUtil.serializeTagToBytes(output.buildResult()) } } - override fun spawnVehicleTree( + override fun restoreVehicle( world: World, nbt: ByteArray, x: Double, y: Double, z: Double, yaw: Float, - pitch: Float, - ): Boolean { + pitch: Float + ): Entity? { val level = world.toNms() + TickThread.ensureTickThread(level, x, z, "Cannot restore vehicle asynchronously") - var spawnedRoot: NmsEntity? = null - return try { - val vehicleNbt = readVehicleTreeNbt(nbt) ?: return false - - val existingRoot = getEntityByUuid(level, vehicleNbt.rootUuid) - if (existingRoot != null) { - val complete = vehicleTreeContainsAll(existingRoot, vehicleNbt.entityUuids) - if (complete) { - standUpCamels(existingRoot) - } - return complete - } - - if (hasPartialUuidCollision(level, vehicleNbt.entityUuids, vehicleNbt.rootUuid)) return false - - applyTargetTransform(vehicleNbt.tag, x, y, z, yaw, pitch) - val root = spawnVehicleTreeFromTag(level, vehicleNbt.tag) ?: return false - spawnedRoot = root - standUpCamels(root) - - val complete = vehicleTreeContainsAll(root, vehicleNbt.entityUuids) - if (!complete) { - discardVehicleTree(root) - spawnedRoot = null - } - - complete - } catch (_: Exception) { - spawnedRoot?.let(::discardVehicleTree) - false - } - } - - override fun mountPassengersInOrder(vehicle: Entity, orderedPassengers: List): Boolean { - val nmsVehicle = vehicle.toNms() - val nmsPassengers = orderedPassengers.map { it.toNms() } - - // Clear the current passenger list first so the order is fully controlled by us. - for (passenger in nmsVehicle.passengers.toList()) { - passenger.stopRiding() - } - - // Re-mount in order; the server appends each passenger, so the resulting order matches. - for (passenger in nmsPassengers) { - if (passenger.vehicle !== null && passenger.vehicle !== nmsVehicle) { - passenger.stopRiding() - } - passenger.startRiding(nmsVehicle, true, false) - if (passenger.vehicle !== nmsVehicle) return false - } - - return nmsPassengers.isNotEmpty() && nmsVehicle.passengers.containsAll(nmsPassengers) - } - - private fun readVehicleTreeNbt(nbt: ByteArray): VehicleTreeNbt? { - val tag = try { - NbtIo.readCompressed(ByteArrayInputStream(nbt), NbtAccounter.unlimitedHeap()) - } catch (_: Exception) { - return null - } - - val entityUuids = LinkedHashSet() - if (!collectVehicleUuids(tag, entityUuids)) return null - - return VehicleTreeNbt( - tag = tag, - rootUuid = entityUuids.firstOrNull() ?: return null, - entityUuids = entityUuids, - ) - } - - private fun collectVehicleUuids(tag: CompoundTag, entityUuids: MutableSet): Boolean { - val uuid = tag.entityUuidOrNull() ?: return false - if (!entityUuids.add(uuid)) return false - - val passengers = tag.getList(NmsEntity.TAG_PASSENGERS).orElse(null) - if (passengers == null) { - return !tag.contains(NmsEntity.TAG_PASSENGERS) - } - - for (i in 0 until passengers.size) { - val passengerTag = passengers.getCompound(i).orElse(null) ?: return false - if (!collectVehicleUuids(passengerTag, entityUuids)) return false - } - - return true - } - - private fun CompoundTag.entityUuidOrNull(): UUID? { - val uuid = getIntArray(NmsEntity.TAG_UUID).orElse(null) ?: return null - if (uuid.size != 4) return null - - return UUIDUtil.uuidFromIntArray(uuid) - } - - private fun getEntityByUuid(level: ServerLevel, uuid: UUID): NmsEntity? { - return level.entities.get(uuid) - } - - private fun hasPartialUuidCollision( - level: ServerLevel, - entityUuids: Set, - rootUuid: UUID, - ): Boolean { - for (uuid in entityUuids) { - if (uuid == rootUuid) continue - if (getEntityByUuid(level, uuid) != null) return true - } - - return false - } - - private fun vehicleTreeContainsAll(root: NmsEntity, entityUuids: Set): Boolean { - val existingUuids = LinkedHashSet() - existingUuids.add(root.uuid) - for (passenger in root.indirectPassengers) { - existingUuids.add(passenger.uuid) - } - - return existingUuids.containsAll(entityUuids) - } - - private fun findEntityInTree(root: NmsEntity, uuid: UUID): NmsEntity? { - if (root.uuid == uuid) return root - return root.indirectPassengers.firstOrNull { it.uuid == uuid } - } - - private fun applyTargetTransform( - tag: CompoundTag, - x: Double, - y: Double, - z: Double, - yaw: Float, - pitch: Float, - ) { - // Override the captured crossing position with the safe target position so the vehicle - // does not re-appear on the border. - tag.put(NmsEntity.TAG_POS, doubleList(x, y, z)) - tag.put(NmsEntity.TAG_ROTATION, floatList(yaw, pitch)) - } - - private fun spawnVehicleTreeFromTag(level: ServerLevel, tag: CompoundTag): NmsEntity? { - return NmsEntityType.loadEntityRecursive( + var tag = MCUtil.deserializeTagFromBytes(nbt) + val dataVersion = NbtUtils.getDataVersion(tag, 0) + tag = PlatformHooks.get().convertNBT( + References.ENTITY, + MinecraftServer.getServer().fixerUpper, tag, - level, - EntitySpawnRequest(EntitySpawnReason.LOAD, false), - EntityProcessor { entity -> - if (level.addWithUUID(entity, CreatureSpawnEvent.SpawnReason.MOUNT)) entity else null - } + dataVersion, + SharedConstants.getCurrentVersion().dataVersion().version ) - } - private fun discardVehicleTree(root: NmsEntity) { - val entities = ArrayList() - entities.add(root) - entities.addAll(root.indirectPassengers) + val entity = ProblemReporter.ScopedCollector(VEHICLE_LOGGER).use { reporter -> + val input = TagValueInput.create(reporter, level.registryAccess(), tag) + val additions = input.child("surf-api-addtions").map { additionsInput -> + require(additionsInput is TagValueInput) + additionsInput.input.keySet() + .mapNotNull(fun(uuidStr: String): Pair Unit>? { + val uuid = runCatching { UUID.fromString(uuidStr) }.getOrNull() ?: return null + val addition = additionsInput.child(uuidStr).getOrNull() ?: return null + + val pose = addition.read("Pose", CODEC).getOrNull() + val arrowCount = addition.getInt("ArrowCount").getOrNull() + val stingerCount = addition.getInt("Stingers").getOrNull() + + return uuid to { entity -> + if (pose != null) entity.pose = pose + if (arrowCount != null) entity.asLivingEntity()?.arrowCount = arrowCount + if (stingerCount != null) entity.asLivingEntity()?.stingerCount = stingerCount + } + }) + .toMap() + }.getOrNull().orEmpty() + + NmsEntityType.loadEntityRecursive( + input, + level, + EntitySpawnRequest(EntitySpawnReason.LOAD, false) + ) { entity -> + additions[entity.uuid]?.invoke(entity) + entity + } + } ?: return null - for (entity in entities.asReversed()) { - entity.discard() - } - } + entity.snapTo(x, y, z, yaw, pitch) - /** - * Camels encode their (sitting) pose as an absolute game-time tick. After migrating to a shard - * with a different game time that tick lies in the past/future, leaving the camel stuck in a - * sit/stand transition (rendered lying down). Re-stand any migrated camel so its pose tick is - * rebased onto this shard's game time. - */ - private fun standUpCamels(root: NmsEntity) { - if (root is Camel) runCatching { root.standUpInstantly() } - for (passenger in root.indirectPassengers) { - if (passenger is Camel) runCatching { passenger.standUpInstantly() } + if (!level.tryAddFreshEntityWithPassengers(entity)) { + return null } - } - private fun doubleList(x: Double, y: Double, z: Double) = ListTag().apply { - add(DoubleTag.valueOf(x)) - add(DoubleTag.valueOf(y)) - add(DoubleTag.valueOf(z)) - } - - private fun floatList(yaw: Float, pitch: Float) = ListTag().apply { - add(FloatTag.valueOf(yaw)) - add(FloatTag.valueOf(pitch)) + return entity.bukkitEntity } companion object { - private val VEHICLE_LOGGER = ComponentLogger.logger("SurfPaperNmsEntityBridge Vehicle") + private val VEHICLE_LOGGER = ComponentLogger.logger("V26_2SurfPaperNmsEntityBridgeImpl Vehicle") } - - private data class VehicleTreeNbt( - val tag: CompoundTag, - val rootUuid: UUID, - val entityUuids: Set, - ) } diff --git a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsPlayerBridgeImpl.kt b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsPlayerBridgeImpl.kt index 9d8d2621..80a3731d 100644 --- a/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsPlayerBridgeImpl.kt +++ b/surf-api-paper/surf-api-paper-nms/surf-api-paper-nms-v26-2/src/main/kotlin/dev/slne/surf/api/paper/server/nms/v26_2/bridges/V26_2SurfPaperNmsPlayerBridgeImpl.kt @@ -12,6 +12,7 @@ import dev.slne.surf.api.paper.nms.common.dummy.DummyEntityEquipment import dev.slne.surf.api.paper.server.nms.v26_2.extensions.toNms import dev.slne.surf.api.paper.server.nms.v26_2.reflection.V26_2NmsReflections import io.papermc.paper.adventure.PaperAdventure +import it.unimi.dsi.fastutil.objects.ObjectLinkedOpenHashSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -33,7 +34,6 @@ import net.minecraft.util.ProblemReporter import net.minecraft.util.ProblemReporter.ScopedCollector import net.minecraft.util.Util import net.minecraft.world.ItemStackWithSlot -import net.minecraft.world.entity.Entity as NmsEntity import net.minecraft.world.entity.EntityEquipment import net.minecraft.world.entity.LivingEntity import net.minecraft.world.entity.npc.InventoryCarrier @@ -42,7 +42,6 @@ import net.minecraft.world.entity.player.ProfilePublicKey import net.minecraft.world.level.storage.* import org.bukkit.craftbukkit.CraftEquipmentSlot import org.bukkit.craftbukkit.inventory.CraftItemStack -import org.bukkit.entity.Entity import org.bukkit.entity.Player import org.bukkit.inventory.EquipmentSlot import org.bukkit.inventory.ItemStack @@ -101,27 +100,18 @@ class V26_2SurfPaperNmsPlayerBridgeImpl : SurfPaperNmsPlayerBridge { val root = nmsPlayer.rootVehicle if (root === nmsPlayer && nmsPlayer.passengers.isEmpty()) { - // Player is neither riding anything nor carrying passengers -> nothing to reconcile. return 0 } - // The whole connected vehicle tree, in a stable order (root first). - val chain = LinkedHashSet() - chain.add(root) - for (passenger in root.indirectPassengers) { - chain.add(passenger) - } + val chain = ObjectLinkedOpenHashSet(root.passengersAndSelf.iterator()) + chain.addFirst(root) - // Pass 1: re-pair every entity of the tree (except the player itself) so the client gets a - // clean copy carrying the server's current network id, metadata, equipment and links. var resynced = 0 for (entity in chain) { if (entity === nmsPlayer) continue val tracker = chunkMap.entityMap.get(entity.id) ?: continue try { - if (!tracker.seenBy.contains(connection)) { - tracker.seenBy.add(connection) - } + tracker.seenBy.add(connection) tracker.serverEntity.removePairing(nmsPlayer) tracker.serverEntity.addPairing(nmsPlayer) resynced++ @@ -130,8 +120,6 @@ class V26_2SurfPaperNmsPlayerBridgeImpl : SurfPaperNmsPlayerBridge { } } - // Pass 2: re-assert every passenger link now that all involved entities are guaranteed to - // exist on the client. This re-mounts the player and resolves stacked vehicles in order. for (entity in chain) { if (entity.passengers.isEmpty()) continue try { @@ -144,31 +132,6 @@ class V26_2SurfPaperNmsPlayerBridgeImpl : SurfPaperNmsPlayerBridge { return resynced } - @Suppress("USELESS_ELVIS") - override fun resyncEntityForViewer(viewer: Player, entity: Entity, swallowExceptions: Boolean): Boolean { - val nmsViewer = viewer.toNms() - val connection = nmsViewer.connection ?: return false - val nmsEntity = entity.toNms() - if (nmsEntity === nmsViewer) return false - - val viewerLevel = nmsViewer.level() - val entityLevel = nmsEntity.level() - if (entityLevel !== viewerLevel) return false - - val tracker = viewerLevel.chunkSource.chunkMap.entityMap.get(nmsEntity.id) ?: return false - return try { - if (!tracker.seenBy.contains(connection)) { - tracker.seenBy.add(connection) - } - tracker.serverEntity.removePairing(nmsViewer) - tracker.serverEntity.addPairing(nmsViewer) - true - } catch (e: Throwable) { - if (!swallowExceptions) throw e - false - } - } - @Suppress("USELESS_ELVIS") override fun resyncPlayerState(player: Player) { val nmsPlayer = player.toNms() diff --git a/surf-api-paper/surf-api-paper/api/surf-api-paper.api b/surf-api-paper/surf-api-paper/api/surf-api-paper.api index 860fdf86..b4ee7f2e 100644 --- a/surf-api-paper/surf-api-paper/api/surf-api-paper.api +++ b/surf-api-paper/surf-api-paper/api/surf-api-paper.api @@ -1674,10 +1674,8 @@ public abstract interface class dev/slne/surf/api/paper/nms/bridges/SurfPaperNms public abstract fun captureVehicleNbt (Lorg/bukkit/entity/Entity;)[B public abstract fun createEntityByNbt (Lorg/bukkit/World;Lorg/bukkit/entity/EntityType;Lio/papermc/paper/math/FinePosition;Lnet/kyori/adventure/nbt/CompoundBinaryTag;)V public abstract fun getById (Lorg/bukkit/World;I)Lorg/bukkit/entity/Entity; - public abstract fun mountPassengersInOrder (Lorg/bukkit/entity/Entity;Ljava/util/List;)Z - public abstract fun restoreVehicleAndMount (Lorg/bukkit/entity/Player;[BLjava/util/UUID;DDDFF)Z + public abstract fun restoreVehicle (Lorg/bukkit/World;[BDDDFF)Lorg/bukkit/entity/Entity; public abstract fun setId (Lorg/bukkit/entity/Entity;I)V - public abstract fun spawnVehicleTree (Lorg/bukkit/World;[BDDDFF)Z } public final class dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge$Companion : dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge { @@ -1685,10 +1683,8 @@ public final class dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge$ public fun createEntityByNbt (Lorg/bukkit/World;Lorg/bukkit/entity/EntityType;Lio/papermc/paper/math/FinePosition;Lnet/kyori/adventure/nbt/CompoundBinaryTag;)V public fun getById (Lorg/bukkit/World;I)Lorg/bukkit/entity/Entity; public final fun getINSTANCE ()Ldev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge; - public fun mountPassengersInOrder (Lorg/bukkit/entity/Entity;Ljava/util/List;)Z - public fun restoreVehicleAndMount (Lorg/bukkit/entity/Player;[BLjava/util/UUID;DDDFF)Z + public fun restoreVehicle (Lorg/bukkit/World;[BDDDFF)Lorg/bukkit/entity/Entity; public fun setId (Lorg/bukkit/entity/Entity;I)V - public fun spawnVehicleTree (Lorg/bukkit/World;[BDDDFF)Z } public abstract interface class dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsGlowingBridge { @@ -1755,8 +1751,6 @@ public abstract interface class dev/slne/surf/api/paper/nms/bridges/SurfPaperNms public abstract fun removeAllTrackedPlayers (Lorg/bukkit/entity/Player;Z)V public static synthetic fun removeAllTrackedPlayers$default (Ldev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge;Lorg/bukkit/entity/Player;ZILjava/lang/Object;)V public abstract fun resetPlayerChatState (Lorg/bukkit/entity/Player;Ldev/slne/surf/api/paper/nms/bridges/data/chat/RemoteChatSessionData;)V - public abstract fun resyncEntityForViewer (Lorg/bukkit/entity/Player;Lorg/bukkit/entity/Entity;Z)Z - public static synthetic fun resyncEntityForViewer$default (Ldev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge;Lorg/bukkit/entity/Player;Lorg/bukkit/entity/Entity;ZILjava/lang/Object;)Z public abstract fun resyncPlayerState (Lorg/bukkit/entity/Player;)V public abstract fun resyncVehicleState (Lorg/bukkit/entity/Player;Z)I public static synthetic fun resyncVehicleState$default (Ldev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge;Lorg/bukkit/entity/Player;ZILjava/lang/Object;)I @@ -1781,7 +1775,6 @@ public final class dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge$ public fun removeAllTrackedEntities (Lorg/bukkit/entity/Player;Z)V public fun removeAllTrackedPlayers (Lorg/bukkit/entity/Player;Z)V public fun resetPlayerChatState (Lorg/bukkit/entity/Player;Ldev/slne/surf/api/paper/nms/bridges/data/chat/RemoteChatSessionData;)V - public fun resyncEntityForViewer (Lorg/bukkit/entity/Player;Lorg/bukkit/entity/Entity;Z)Z public fun resyncPlayerState (Lorg/bukkit/entity/Player;)V public fun resyncVehicleState (Lorg/bukkit/entity/Player;Z)I public fun runOnChatMessageChain (Lorg/bukkit/entity/Player;Lkotlinx/coroutines/CoroutineScope;Lkotlin/jvm/functions/Function1;)V @@ -1794,7 +1787,6 @@ public final class dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge$ public static synthetic fun createPlayerChatMessageMirrorFromAdventure$default (Ldev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge;Lnet/kyori/adventure/chat/SignedMessage;Lnet/kyori/adventure/text/Component;ILjava/lang/Object;)Ldev/slne/surf/api/paper/nms/bridges/data/chat/PlayerChatMessageMirror; public static synthetic fun removeAllTrackedEntities$default (Ldev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge;Lorg/bukkit/entity/Player;ZILjava/lang/Object;)V public static synthetic fun removeAllTrackedPlayers$default (Ldev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge;Lorg/bukkit/entity/Player;ZILjava/lang/Object;)V - public static synthetic fun resyncEntityForViewer$default (Ldev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge;Lorg/bukkit/entity/Player;Lorg/bukkit/entity/Entity;ZILjava/lang/Object;)Z public static synthetic fun resyncVehicleState$default (Ldev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge;Lorg/bukkit/entity/Player;ZILjava/lang/Object;)I } diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge.kt index f0956bc7..5f98092e 100644 --- a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge.kt +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsEntityBridge.kt @@ -10,8 +10,6 @@ import net.kyori.adventure.nbt.CompoundBinaryTag import org.bukkit.World import org.bukkit.entity.Entity import org.bukkit.entity.EntityType -import org.bukkit.entity.Player -import java.util.* @NmsUseWithCaution interface SurfPaperNmsEntityBridge { @@ -23,80 +21,9 @@ interface SurfPaperNmsEntityBridge { fun getById(world: World, id: Int): Entity? - /** - * Serializes [rootVehicle] – which must be the *root* of a ride tree – together with its - * non-player passenger subtree into a portable, compressed NBT blob that can be migrated to - * another server. - * - * Player passengers are excluded automatically (players are not serialized as passengers). - * Entity UUIDs, position, rotation, velocity, metadata and any non-player passengers are - * preserved so the tree can be faithfully recreated elsewhere with [restoreVehicleAndMount]. - * - * Must be called on the owning region/entity tick thread. - * - * @param rootVehicle the root entity of the ride tree (e.g. the boat the player sits in) - * @return the gzip-compressed NBT representation of the tree - */ fun captureVehicleNbt(rootVehicle: Entity): ByteArray - /** - * Ensures that a vehicle tree previously captured with [captureVehicleNbt] exists in - * [player]'s current world. When spawning is required, the root is spawned at the provided - * coordinates / rotation, then [player] is force-mounted onto the entity identified by - * [directVehicleUuid]. - * - * The tree uses its original entity UUIDs. If the root UUID is already present, the existing - * root tree is reused as an idempotent retry only when it contains all UUIDs captured in the - * NBT. If the root is absent but any other captured UUID already exists in the target world, - * the restore fails to avoid partial duplicate/collision states. Newly spawned trees are - * removed again if a later restore step fails. - * - * The [directVehicleUuid] is strict: it must identify the root or a passenger contained in the - * captured vehicle tree. A missing direct vehicle is an error, returns false, and never falls - * back to mounting on the root. - * - * When spawning is needed, the root position is overridden with [x]/[y]/[z] so the vehicle - * (and therefore the mounted player) appears at a safe, caller-chosen location rather than the - * captured crossing point. - * - * Must be called on the owning region/entity tick thread, after [player] is already in-world. - * - * @return true if the vehicle tree exists, [directVehicleUuid] was found strictly, and [player] - * ended up mounted on that exact entity; false for invalid NBT, UUID collisions, - * partial trees, missing direct vehicle, spawn failure or mount failure - */ - fun restoreVehicleAndMount( - player: Player, - nbt: ByteArray, - directVehicleUuid: UUID, - x: Double, - y: Double, - z: Double, - yaw: Float, - pitch: Float, - ): Boolean - - /** - * Ensures that a vehicle tree previously captured with [captureVehicleNbt] exists in [world]. - * When spawning is required, the root is spawned at the provided coordinates / rotation - * **without mounting anyone**. - * - * This is the spawn-only counterpart of [restoreVehicleAndMount], used when several player - * passengers are migrated as a group: the vehicle must already exist (spawned exactly once) - * before the players are mounted in their original order with [mountPassengersInOrder]. - * - * The tree keeps its original entity UUIDs and is idempotent for retries: if the captured root - * UUID is already present, the call succeeds only when the existing root tree contains every - * UUID captured in the NBT. If the root is absent but any other captured UUID already exists, - * the call fails to avoid partial duplicate/collision states. - * - * Must be called on the owning region/entity tick thread. - * - * @return true if the complete captured vehicle tree is present after the call, whether it was - * newly spawned or already present; false for invalid NBT, UUID collisions, partial - * trees or spawn failure - */ - fun spawnVehicleTree( + fun restoreVehicle( world: World, nbt: ByteArray, x: Double, @@ -104,21 +31,7 @@ interface SurfPaperNmsEntityBridge { z: Double, yaw: Float, pitch: Float, - ): Boolean - - /** - * Rebuilds [vehicle]'s passenger list to exactly [orderedPassengers], in that order. - * - * All current passengers are dismounted first, then each entity in [orderedPassengers] is - * force-mounted in turn. Because the server appends each new passenger, the resulting passenger - * order matches the list, which for boats decides the controlling passenger (index 0). This is - * how the original driver and seating order are restored after a multi-passenger migration. - * - * Must be called on the owning region/entity tick thread, with every entity already in-world. - * - * @return true only if every entity in [orderedPassengers] ended up directly riding [vehicle] - */ - fun mountPassengersInOrder(vehicle: Entity, orderedPassengers: List): Boolean + ): Entity? companion object : SurfPaperNmsEntityBridge by bridge { val INSTANCE get() = bridge diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge.kt index 9a747efe..321f5429 100644 --- a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge.kt +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/nms/bridges/SurfPaperNmsPlayerBridge.kt @@ -11,7 +11,6 @@ import kotlinx.coroutines.CoroutineScope import net.kyori.adventure.chat.ChatType import net.kyori.adventure.chat.SignedMessage import net.kyori.adventure.text.Component -import org.bukkit.entity.Entity import org.bukkit.entity.Player import org.bukkit.inventory.EntityEquipment import org.bukkit.inventory.ItemStack @@ -26,64 +25,7 @@ interface SurfPaperNmsPlayerBridge { fun removeAllTrackedEntities(player: Player, swallowExceptions: Boolean = true) fun removeAllTrackedPlayers(player: Player, swallowExceptions: Boolean = true) - /** - * Re-sends the full client-side tracking and passenger state for [player]'s own vehicle - * chain so that the client and the server agree again on what the player is riding. - * - * This exists for *seamless* server moves (e.g. shard transfers) where the client keeps its - * existing PLAY-state world because the login/respawn/reconfiguration packets are suppressed - * upstream. On the target server the vehicle is re-created from the player's persisted - * `RootVehicle` data, but with a **fresh network entity id** and a freshly initialised entity - * tracker. The relationship packet ([net.minecraft.network.protocol.game.ClientboundSetPassengersPacket]) - * is normally only emitted when the passenger list *changes*, so after a seamless move the - * client can be left believing it is (not) riding while the server believes the opposite. - * - * For every entity in the player's vehicle tree (the root vehicle and all of its - * (indirect) passengers, except the player itself) this destroys and immediately re-creates - * the client pairing — i.e. it sends a remove packet followed by the full add-pairing bundle - * (spawn, metadata, equipment, attributes and the passenger links). Afterwards it re-asserts - * every passenger link once more, after all involved entities are guaranteed to exist on the - * client, so that stacked vehicles resolve in the correct order. - * - * Must be called on the owning region/entity tick thread. - * - * @param player the transferred player whose riding state should be reconciled - * @param swallowExceptions when true, per-entity failures are ignored instead of propagated - * @return the number of vehicle-chain entities that were resynced; `0` when the player is - * neither riding anything nor carrying any passengers (nothing to do) - */ fun resyncVehicleState(player: Player, swallowExceptions: Boolean = true): Int - - /** - * Re-pairs a single [entity] for [viewer] so the viewer's client holds the entity with the - * server's *current* network id, metadata and passenger links. - * - * This destroys the existing client pairing and re-creates it (remove packet followed by the - * full add-pairing bundle). If the viewer was not yet part of the entity tracker's seen-by - * set it is added, so subsequent incremental updates keep flowing. Useful to repair an - * individual entity whose id drifted after a seamless server move (which otherwise breaks - * interaction packets that reference the network id). - * - * [viewer] and [entity] must be in the same world/NMS level. Cross-world calls return false - * before consulting the viewer level's tracker map, because numeric entity ids can collide - * between levels. - * - * Must be called on the owning region/entity tick thread. - * - * @param viewer the player that should receive the refreshed entity - * @param entity the entity to re-pair for the viewer - * @param swallowExceptions when true, failures are ignored instead of propagated - * @return true if the entity had an active tracker and was resynced, false otherwise - */ - fun resyncEntityForViewer(viewer: Player, entity: Entity, swallowExceptions: Boolean = true): Boolean - - /** - * Re-sends the connection-bound player state that a normal respawn would refresh but that is - * lost during a seamless server move, namely the player's abilities (fly/allow-fly/walk and - * fly speed). Safe to call repeatedly; it is a no-op when the player has no connection. - * - * Must be called on the owning region/entity tick thread. - */ fun resyncPlayerState(player: Player) fun getRemoteChatSessionData(player: Player): RemoteChatSessionData?