diff --git a/patches/api/0011-add-replay-api.patch b/patches/api/0011-add-replay-api.patch new file mode 100644 index 00000000..c25e6a92 --- /dev/null +++ b/patches/api/0011-add-replay-api.patch @@ -0,0 +1,140 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: alazeprt +Date: Sun, 15 Sep 2024 19:48:42 +0800 +Subject: [PATCH] add replay api + + +diff --git a/src/main/java/org/bukkit/Bukkit.java b/src/main/java/org/bukkit/Bukkit.java +index ebf04da015cfe394e9c5d3a3c432674df979f93c..54e5190af7f4eed4a82dd4f6b3fa9554c074dec2 100644 +--- a/src/main/java/org/bukkit/Bukkit.java ++++ b/src/main/java/org/bukkit/Bukkit.java +@@ -3111,4 +3111,10 @@ public final class Bukkit { + server.clearBlockHighlights(); + } + // Purpur end ++ ++ // Leaves start - Photographer API ++ public static @NotNull org.leavesmc.leaves.entity.PhotographerManager getPhotographerManager() { ++ return server.getPhotographerManager(); ++ } ++ // Leaves end - Photographer API + } +diff --git a/src/main/java/org/bukkit/Server.java b/src/main/java/org/bukkit/Server.java +index ab6e3eb18b9c36fdd8f56f491332085aa2ec0c37..7de6a91ef573e0a944207242518785d11381c640 100644 +--- a/src/main/java/org/bukkit/Server.java ++++ b/src/main/java/org/bukkit/Server.java +@@ -62,6 +62,7 @@ import org.jetbrains.annotations.ApiStatus; + import org.jetbrains.annotations.Contract; + import org.jetbrains.annotations.NotNull; + import org.jetbrains.annotations.Nullable; ++import org.leavesmc.leaves.entity.PhotographerManager; + + /** + * Represents a server implementation. +@@ -2753,4 +2754,8 @@ public interface Server extends PluginMessageRecipient, net.kyori.adventure.audi + */ + void clearBlockHighlights(); + // Purpur end ++ ++ // Leaves start - Photographer API ++ @NotNull PhotographerManager getPhotographerManager(); ++ // Leaves end - Photographer API + } +diff --git a/src/main/java/org/leavesmc/leaves/entity/Photographer.java b/src/main/java/org/leavesmc/leaves/entity/Photographer.java +new file mode 100644 +index 0000000000000000000000000000000000000000..5b564dfd8aa882d0dc8b1833a4b46e1bba699876 +--- /dev/null ++++ b/src/main/java/org/leavesmc/leaves/entity/Photographer.java +@@ -0,0 +1,27 @@ ++package org.leavesmc.leaves.entity; ++ ++import org.bukkit.entity.Player; ++import org.jetbrains.annotations.NotNull; ++import org.jetbrains.annotations.Nullable; ++ ++import java.io.File; ++ ++public interface Photographer extends Player { ++ ++ @NotNull ++ public String getId(); ++ ++ public void setRecordFile(@NotNull File file); ++ ++ public void stopRecording(); ++ ++ public void stopRecording(boolean async); ++ ++ public void stopRecording(boolean async, boolean save); ++ ++ public void pauseRecording(); ++ ++ public void resumeRecording(); ++ ++ public void setFollowPlayer(@Nullable Player player); ++} +\ No newline at end of file +diff --git a/src/main/java/org/leavesmc/leaves/entity/PhotographerManager.java b/src/main/java/org/leavesmc/leaves/entity/PhotographerManager.java +new file mode 100644 +index 0000000000000000000000000000000000000000..ccb19e75748803eb9ad356ffcd0ccfd5145ed776 +--- /dev/null ++++ b/src/main/java/org/leavesmc/leaves/entity/PhotographerManager.java +@@ -0,0 +1,32 @@ ++package org.leavesmc.leaves.entity; ++ ++import org.bukkit.Location; ++import org.bukkit.util.Consumer; ++import org.jetbrains.annotations.NotNull; ++import org.jetbrains.annotations.Nullable; ++import org.leavesmc.leaves.replay.BukkitRecorderOption; ++ ++import java.util.Collection; ++import java.util.UUID; ++ ++public interface PhotographerManager { ++ @Nullable ++ public Photographer getPhotographer(@NotNull UUID uuid); ++ ++ @Nullable ++ public Photographer getPhotographer(@NotNull String id); ++ ++ @Nullable ++ public Photographer createPhotographer(@NotNull String id, @NotNull Location location); ++ ++ @Nullable ++ public Photographer createPhotographer(@NotNull String id, @NotNull Location location, @NotNull BukkitRecorderOption recorderOption); ++ ++ public void removePhotographer(@NotNull String id); ++ ++ public void removePhotographer(@NotNull UUID uuid); ++ ++ public void removeAllPhotographers(); ++ ++ public Collection getPhotographers(); ++} +\ No newline at end of file +diff --git a/src/main/java/org/leavesmc/leaves/replay/BukkitRecorderOption.java b/src/main/java/org/leavesmc/leaves/replay/BukkitRecorderOption.java +new file mode 100644 +index 0000000000000000000000000000000000000000..c985721bdf6be0b8a154e7abfd50e0168965a8d1 +--- /dev/null ++++ b/src/main/java/org/leavesmc/leaves/replay/BukkitRecorderOption.java +@@ -0,0 +1,18 @@ ++package org.leavesmc.leaves.replay; ++ ++public class BukkitRecorderOption { ++ ++ // public int recordDistance = -1; ++ public String serverName = "Leaf"; ++ public BukkitRecordWeather forceWeather = BukkitRecordWeather.NULL; ++ public int forceDayTime = -1; ++ public boolean ignoreChat = false; ++ // public boolean ignoreItem = false; ++ ++ public enum BukkitRecordWeather { ++ CLEAR, ++ RAIN, ++ THUNDER, ++ NULL ++ } ++} +\ No newline at end of file diff --git a/patches/server/0102-add-replay-api.patch b/patches/server/0102-add-replay-api.patch new file mode 100644 index 00000000..decb27a6 --- /dev/null +++ b/patches/server/0102-add-replay-api.patch @@ -0,0 +1,1694 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: alazeprt +Date: Sun, 15 Sep 2024 20:15:37 +0800 +Subject: [PATCH] add replay api + + +diff --git a/src/main/java/io/papermc/paper/plugin/manager/PaperEventManager.java b/src/main/java/io/papermc/paper/plugin/manager/PaperEventManager.java +index 2622a82b6e34cb636eaad239d8e6e30dc8cce589..cf5c2aabe2842ff9fc97823dff5011407ac43021 100644 +--- a/src/main/java/io/papermc/paper/plugin/manager/PaperEventManager.java ++++ b/src/main/java/io/papermc/paper/plugin/manager/PaperEventManager.java +@@ -50,6 +50,11 @@ class PaperEventManager { + // Leaf end - petal - Multithreaded tracker + throw new IllegalStateException(event.getEventName() + " may only be triggered synchronously."); + } ++ // Leaves start - skip photographer ++ if (event instanceof org.bukkit.event.player.PlayerEvent playerEvent && playerEvent.getPlayer() instanceof org.leavesmc.leaves.entity.Photographer) { ++ return; ++ } ++ // Leaves end - skip photographer + + for (RegisteredListener registration : listeners) { + if (!registration.getPlugin().isEnabled()) { +diff --git a/src/main/java/net/minecraft/commands/arguments/EntityArgument.java b/src/main/java/net/minecraft/commands/arguments/EntityArgument.java +index 716d37ea7c398c4f15f362c7759daca9d3fe32cb..2b586b222bdeddfb5a6a746fa4e60501fb73e847 100644 +--- a/src/main/java/net/minecraft/commands/arguments/EntityArgument.java ++++ b/src/main/java/net/minecraft/commands/arguments/EntityArgument.java +@@ -154,6 +154,7 @@ public class EntityArgument implements ArgumentType { + if (icompletionprovider instanceof CommandSourceStack commandSourceStack && commandSourceStack.getEntity() instanceof ServerPlayer sourcePlayer) { + collection = new java.util.ArrayList<>(); + for (final ServerPlayer player : commandSourceStack.getServer().getPlayerList().getPlayers()) { ++ if (player instanceof org.leavesmc.leaves.replay.ServerPhotographer) continue; // Leaves - skip photographer + if (sourcePlayer.getBukkitEntity().canSee(player.getBukkitEntity())) { + collection.add(player.getGameProfile().getName()); + } +diff --git a/src/main/java/net/minecraft/commands/arguments/selector/EntitySelector.java b/src/main/java/net/minecraft/commands/arguments/selector/EntitySelector.java +index b455c7e9d18bac3654daa8510f85cc21202e254b..e636520549c024b28e9eabc545e79bd37ac6f3fc 100644 +--- a/src/main/java/net/minecraft/commands/arguments/selector/EntitySelector.java ++++ b/src/main/java/net/minecraft/commands/arguments/selector/EntitySelector.java +@@ -117,6 +117,7 @@ public class EntitySelector { + return this.findPlayers(source); + } else if (this.playerName != null) { + ServerPlayer entityplayer = source.getServer().getPlayerList().getPlayerByName(this.playerName); ++ entityplayer = entityplayer instanceof org.leavesmc.leaves.replay.ServerPhotographer ? null : entityplayer; // Leaves - skip photographer + + return entityplayer == null ? List.of() : List.of(entityplayer); + } else if (this.entityUUID != null) { +@@ -126,7 +127,7 @@ public class EntitySelector { + ServerLevel worldserver = (ServerLevel) iterator.next(); + Entity entity = worldserver.getEntity(this.entityUUID); + +- if (entity != null) { ++ if (entity != null && !(entity instanceof org.leavesmc.leaves.replay.ServerPhotographer)) { + if (entity.getType().isEnabled(source.enabledFeatures())) { + return List.of(entity); + } +@@ -142,7 +143,7 @@ public class EntitySelector { + + if (this.currentEntity) { + predicate = this.getPredicate(vec3d, axisalignedbb, (FeatureFlagSet) null); +- return source.getEntity() != null && predicate.test(source.getEntity()) ? List.of(source.getEntity()) : List.of(); ++ return source.getEntity() != null && !(source.getEntity() instanceof org.leavesmc.leaves.replay.ServerPhotographer) && predicate.test(source.getEntity()) ? List.of(source.getEntity()) : List.of(); // Leaves - skip photographer + } else { + predicate = this.getPredicate(vec3d, axisalignedbb, source.enabledFeatures()); + List list = new ObjectArrayList(); +@@ -158,6 +159,7 @@ public class EntitySelector { + this.addEntities(list, worldserver1, axisalignedbb, predicate); + } + } ++ list.removeIf(entity -> entity instanceof org.leavesmc.leaves.replay.ServerPhotographer); // Leaves - skip photographer + + return this.sortAndLimit(vec3d, list); + } +@@ -198,9 +200,11 @@ public class EntitySelector { + + if (this.playerName != null) { + entityplayer = source.getServer().getPlayerList().getPlayerByName(this.playerName); ++ entityplayer = entityplayer instanceof org.leavesmc.leaves.replay.ServerPhotographer ? null : entityplayer; // Leaves - skip photographer + return entityplayer == null || !canSee(source, entityplayer) ? List.of() : List.of(entityplayer); // Purpur + } else if (this.entityUUID != null) { + entityplayer = source.getServer().getPlayerList().getPlayer(this.entityUUID); ++ entityplayer = entityplayer instanceof org.leavesmc.leaves.replay.ServerPhotographer ? null : entityplayer; // Leaves - skip photographer + return entityplayer == null || !canSee(source, entityplayer) ? List.of() : List.of(entityplayer); // Purpur + } else { + Vec3 vec3d = (Vec3) this.position.apply(source.getPosition()); +@@ -213,7 +217,7 @@ public class EntitySelector { + if (entity instanceof ServerPlayer) { + ServerPlayer entityplayer1 = (ServerPlayer) entity; + +- if (predicate.test(entityplayer1)) { ++ if (predicate.test(entityplayer1) && !(entityplayer1 instanceof org.leavesmc.leaves.replay.ServerPhotographer)) { // Leaves - skip photographer + return !canSee(source, entityplayer1) ? List.of() : List.of(entityplayer1); // Purpur + } + } +@@ -224,7 +228,7 @@ public class EntitySelector { + Object object; + + if (this.isWorldLimited()) { +- object = source.getLevel().getPlayers(predicate, i); ++ object = source.getLevel().getPlayers((entityplayer3 -> !(entityplayer3 instanceof org.leavesmc.leaves.replay.ServerPhotographer) && predicate.test(entityplayer3)), i); // Leaves - skip photographer + ((List) object).removeIf(entityplayer3 -> !canSee(source, (ServerPlayer) entityplayer3)); // Purpur + } else { + object = new ObjectArrayList(); +@@ -233,7 +237,7 @@ public class EntitySelector { + while (iterator.hasNext()) { + ServerPlayer entityplayer2 = (ServerPlayer) iterator.next(); + +- if (predicate.test(entityplayer2) && canSee(source, entityplayer2)) { // Purpur ++ if (predicate.test(entityplayer2) && canSee(source, entityplayer2) && !(entityplayer2 instanceof org.leavesmc.leaves.replay.ServerPhotographer)) { // Purpur // Leaves - skip photographer + ((List) object).add(entityplayer2); + if (((List) object).size() >= i) { + return (List) object; +diff --git a/src/main/java/net/minecraft/server/MinecraftServer.java b/src/main/java/net/minecraft/server/MinecraftServer.java +index 0255d5a23e2f5a1a67af3ff1588b608521e4c133..cb171f28a4d678ce717f8b21ea4d71cc49a81306 100644 +--- a/src/main/java/net/minecraft/server/MinecraftServer.java ++++ b/src/main/java/net/minecraft/server/MinecraftServer.java +@@ -1705,7 +1705,7 @@ public abstract class MinecraftServer extends ReentrantBlockableEventLoop list = this.playerList.getPlayers(); ++ List list = this.playerList.realPlayers; + int i = this.getMaxPlayers(); + + if (this.hidesOnlinePlayers()) { +diff --git a/src/main/java/net/minecraft/server/PlayerAdvancements.java b/src/main/java/net/minecraft/server/PlayerAdvancements.java +index ffea0dc3acc91b9d65ad0dc5482fee7115dec03a..cbb4adcf1120f5f0f1b754710fb8c7fd5b311c69 100644 +--- a/src/main/java/net/minecraft/server/PlayerAdvancements.java ++++ b/src/main/java/net/minecraft/server/PlayerAdvancements.java +@@ -223,6 +223,11 @@ public class PlayerAdvancements { + + public boolean award(AdvancementHolder advancement, String criterionName) { + boolean flag = false; ++ // Leaves start - photographer can't get advancement ++ if (player instanceof org.leavesmc.leaves.replay.ServerPhotographer) { ++ return false; ++ } ++ // Leaves end - photographer can't get advancement + AdvancementProgress advancementprogress = this.getOrStartProgress(advancement); + boolean flag1 = advancementprogress.isDone(); + +diff --git a/src/main/java/net/minecraft/server/commands/OpCommand.java b/src/main/java/net/minecraft/server/commands/OpCommand.java +index e7b444a10b244828827b3c66c53465206ea8e0ec..2cbbeb0dedd06e5431f05aa699983582900e3402 100644 +--- a/src/main/java/net/minecraft/server/commands/OpCommand.java ++++ b/src/main/java/net/minecraft/server/commands/OpCommand.java +@@ -25,7 +25,7 @@ public class OpCommand { + (context, builder) -> { + PlayerList playerList = context.getSource().getServer().getPlayerList(); + return SharedSuggestionProvider.suggest( +- playerList.getPlayers() ++ playerList.realPlayers + .stream() + .filter(player -> !playerList.isOp(player.getGameProfile())) + .map(player -> player.getGameProfile().getName()), +diff --git a/src/main/java/net/minecraft/server/level/ServerLevel.java b/src/main/java/net/minecraft/server/level/ServerLevel.java +index 44f128f7d6741d47f9a4bbd92e147b4011447a50..588337638d27bbdbc1ff54e458d82a50486d5e8e 100644 +--- a/src/main/java/net/minecraft/server/level/ServerLevel.java ++++ b/src/main/java/net/minecraft/server/level/ServerLevel.java +@@ -230,6 +230,7 @@ public class ServerLevel extends Level implements WorldGenLevel, ca.spottedleaf. + public boolean hasEntityMoveEvent; // Paper - Add EntityMoveEvent + private final alternate.current.wire.WireHandler wireHandler = new alternate.current.wire.WireHandler(this); // Paper - optimize redstone (Alternate Current) + public boolean hasRidableMoveEvent = false; // Purpur ++ final List realPlayers; // Leaves - skip + + public LevelChunk getChunkIfLoaded(int x, int z) { + return this.chunkSource.getChunkAtIfLoadedImmediately(x, z); // Paper - Use getChunkIfLoadedImmediately +@@ -611,6 +612,7 @@ public class ServerLevel extends Level implements WorldGenLevel, ca.spottedleaf. + this.chunkTaskScheduler = new ca.spottedleaf.moonrise.patches.chunk_system.scheduling.ChunkTaskScheduler((ServerLevel)(Object)this, ca.spottedleaf.moonrise.common.util.MoonriseCommon.WORKER_POOL); + // Paper end - rewrite chunk system + this.getCraftServer().addWorld(this.getWorld()); // CraftBukkit ++ this.realPlayers = Lists.newArrayList(); // Leaves - skip + this.preciseTime = this.serverLevelData.getDayTime(); // Purpur + } + +@@ -2546,6 +2548,11 @@ public class ServerLevel extends Level implements WorldGenLevel, ca.spottedleaf. + // ServerLevel.this.getChunkSource().addEntity(entity); // Paper - ignore and warn about illegal addEntity calls instead of crashing server; moved down below valid=true + if (entity instanceof ServerPlayer entityplayer) { + ServerLevel.this.players.add(entityplayer); ++ // Leaves start - skip ++ if (!(entityplayer instanceof org.leavesmc.leaves.replay.ServerPhotographer)) { ++ ServerLevel.this.realPlayers.add(entityplayer); ++ } ++ // Leaves end - skip + ServerLevel.this.updateSleepingPlayerList(); + } + +@@ -2627,6 +2634,11 @@ public class ServerLevel extends Level implements WorldGenLevel, ca.spottedleaf. + ServerLevel.this.getChunkSource().removeEntity(entity); + if (entity instanceof ServerPlayer entityplayer) { + ServerLevel.this.players.remove(entityplayer); ++ // Leaves start - skip ++ if (!(entityplayer instanceof org.leavesmc.leaves.replay.ServerPhotographer)) { ++ ServerLevel.this.realPlayers.remove(entityplayer); ++ } ++ // Leaves end - skip + ServerLevel.this.updateSleepingPlayerList(); + } + +diff --git a/src/main/java/net/minecraft/server/players/PlayerList.java b/src/main/java/net/minecraft/server/players/PlayerList.java +index d17a6c897342273dfa202efe1bc03d66c3ecc11a..b4e800cbd78795b91cfb6a6613fc72acff588564 100644 +--- a/src/main/java/net/minecraft/server/players/PlayerList.java ++++ b/src/main/java/net/minecraft/server/players/PlayerList.java +@@ -104,6 +104,7 @@ import net.minecraft.world.scores.DisplaySlot; + import net.minecraft.world.scores.Objective; + import net.minecraft.world.scores.PlayerTeam; + import org.galemc.gale.configuration.GaleGlobalConfiguration; ++import org.leavesmc.leaves.replay.ServerPhotographer; + import org.slf4j.Logger; + + // CraftBukkit start +@@ -158,6 +159,7 @@ public abstract class PlayerList { + private boolean allowCommandsForAllPlayers; + private static final boolean ALLOW_LOGOUTIVATOR = false; + private int sendAllPlayerInfoIn; ++ public final List realPlayers = new java.util.concurrent.CopyOnWriteArrayList(); // Leaves - replay api + + // CraftBukkit start + private CraftServer cserver; +@@ -184,6 +186,105 @@ public abstract class PlayerList { + } + abstract public void loadAndSaveFiles(); // Paper - fix converting txt to json file; moved from DedicatedPlayerList constructor + ++ // Leaves start - replay api ++ public void placeNewPhotographer(Connection connection, ServerPhotographer player, ServerLevel worldserver, Location location) { ++ player.isRealPlayer = true; // Paper ++ player.loginTime = System.currentTimeMillis(); // Paper ++ ++ ServerLevel worldserver1 = worldserver; ++ ++ player.setServerLevel(worldserver1); ++ player.spawnIn(worldserver1); ++ player.gameMode.setLevel((ServerLevel) player.level()); ++ ++ LevelData worlddata = worldserver1.getLevelData(); ++ ++ player.loadGameTypes(null); ++ ServerGamePacketListenerImpl playerconnection = new ServerGamePacketListenerImpl(this.server, connection, player, CommonListenerCookie.createInitial(player.gameProfile, false)); ++ GameRules gamerules = worldserver1.getGameRules(); ++ boolean flag = gamerules.getBoolean(GameRules.RULE_DO_IMMEDIATE_RESPAWN); ++ boolean flag1 = gamerules.getBoolean(GameRules.RULE_REDUCEDDEBUGINFO); ++ boolean flag2 = gamerules.getBoolean(GameRules.RULE_LIMITED_CRAFTING); ++ ++ playerconnection.send(new ClientboundLoginPacket(player.getId(), worlddata.isHardcore(), this.server.levelKeys(), this.getMaxPlayers(), worldserver1.getWorld().getSendViewDistance(), worldserver1.getWorld().getSimulationDistance(), flag1, !flag, flag2, player.createCommonSpawnInfo(worldserver1), this.server.enforceSecureProfile())); // Paper - replace old player chunk management ++ player.getBukkitEntity().sendSupportedChannels(); // CraftBukkit ++ playerconnection.send(new ClientboundChangeDifficultyPacket(worlddata.getDifficulty(), worlddata.isDifficultyLocked())); ++ playerconnection.send(new ClientboundPlayerAbilitiesPacket(player.getAbilities())); ++ playerconnection.send(new ClientboundSetCarriedItemPacket(player.getInventory().selected)); ++ playerconnection.send(new ClientboundUpdateRecipesPacket(this.server.getRecipeManager().getRecipes())); ++ this.sendPlayerPermissionLevel(player); ++ player.getStats().markAllDirty(); ++ player.getRecipeBook().sendInitialRecipeBook(player); ++ this.updateEntireScoreboard(worldserver1.getScoreboard(), player); ++ this.server.invalidateStatus(); ++ ++ playerconnection.teleport(player.getX(), player.getY(), player.getZ(), player.getYRot(), player.getXRot()); ++ ServerStatus serverping = this.server.getStatus(); ++ ++ if (serverping != null) { ++ player.sendServerStatus(serverping); ++ } ++ ++ this.players.add(player); ++ this.playersByName.put(player.getScoreboardName().toLowerCase(java.util.Locale.ROOT), player); // Spigot ++ this.playersByUUID.put(player.getUUID(), player); ++ ++ player.supressTrackerForLogin = true; ++ worldserver1.addNewPlayer(player); ++ this.server.getCustomBossEvents().onPlayerConnect(player); ++ mountSavedVehicle(player, worldserver1, Optional.empty()); ++ CraftPlayer bukkitPlayer = player.getBukkitEntity(); ++ ++ player.containerMenu.transferTo(player.containerMenu, bukkitPlayer); ++ if (!player.connection.isAcceptingMessages()) { ++ return; ++ } ++ ++ // org.leavesmc.leaves.protocol.core.LeavesProtocolManager.handlePlayerJoin(player); // Leaves - protocol ++ ++ final List onlinePlayers = Lists.newArrayListWithExpectedSize(this.players.size() - 1); ++ for (int i = 0; i < this.players.size(); ++i) { ++ ServerPlayer entityplayer1 = this.players.get(i); ++ ++ if (entityplayer1 == player || !bukkitPlayer.canSee(entityplayer1.getBukkitEntity())) { ++ continue; ++ } ++ ++ onlinePlayers.add(entityplayer1); ++ } ++ if (!onlinePlayers.isEmpty()) { ++ player.connection.send(ClientboundPlayerInfoUpdatePacket.createPlayerInitializing(onlinePlayers, player)); ++ } ++ ++ player.sentListPacket = true; ++ player.supressTrackerForLogin = false; ++ ((ServerLevel)player.level()).getChunkSource().chunkMap.addEntity(player); ++ ++ this.sendLevelInfo(player, worldserver1); ++ ++ if (player.level() == worldserver1 && !worldserver1.players().contains(player)) { ++ worldserver1.addNewPlayer(player); ++ this.server.getCustomBossEvents().onPlayerConnect(player); ++ } ++ ++ worldserver1 = player.serverLevel(); ++ Iterator iterator = player.getActiveEffects().iterator(); ++ while (iterator.hasNext()) { ++ MobEffectInstance mobeffect = (MobEffectInstance) iterator.next(); ++ playerconnection.send(new ClientboundUpdateMobEffectPacket(player.getId(), mobeffect, false)); ++ } ++ ++ if (player.isDeadOrDying()) { ++ net.minecraft.core.Holder plains = worldserver1.registryAccess().registryOrThrow(net.minecraft.core.registries.Registries.BIOME) ++ .getHolderOrThrow(net.minecraft.world.level.biome.Biomes.PLAINS); ++ player.connection.send(new net.minecraft.network.protocol.game.ClientboundLevelChunkWithLightPacket( ++ new net.minecraft.world.level.chunk.EmptyLevelChunk(worldserver1, player.chunkPosition(), plains), ++ worldserver1.getLightEngine(), (java.util.BitSet)null, (java.util.BitSet) null, false) ++ ); ++ } ++ } ++ // Leaves end - replay api ++ + public void placeNewPlayer(Connection connection, ServerPlayer player, CommonListenerCookie clientData) { + player.isRealPlayer = true; // Paper + player.loginTime = System.currentTimeMillis(); // Paper - Replace OfflinePlayer#getLastPlayed +@@ -344,6 +445,7 @@ public abstract class PlayerList { + + // entityplayer.connection.send(ClientboundPlayerInfoUpdatePacket.createPlayerInitializing(this.players)); // CraftBukkit - replaced with loop below + this.players.add(player); ++ this.realPlayers.add(player); // Leaves - replay api + this.addToSendAllPlayerInfoBuckets(player); // Gale - Purpur - spread out sending all player info + this.playersByName.put(player.getScoreboardName().toLowerCase(java.util.Locale.ROOT), player); // Spigot + this.playersByUUID.put(player.getUUID(), player); +@@ -403,6 +505,12 @@ public abstract class PlayerList { + continue; + } + ++ // Leaves start - skip photographer ++ if (entityplayer1 instanceof ServerPhotographer) { ++ continue; ++ } ++ // Leaves end - skip photographer ++ + onlinePlayers.add(entityplayer1); // Paper - Use single player info update packet on join + } + // Paper start - Use single player info update packet on join +@@ -614,6 +722,43 @@ public abstract class PlayerList { + + } + ++ // Leaves start - replay mod api ++ public void removePhotographer(ServerPhotographer entityplayer) { ++ ServerLevel worldserver = entityplayer.serverLevel(); ++ ++ entityplayer.awardStat(Stats.LEAVE_GAME); ++ ++ if (entityplayer.containerMenu != entityplayer.inventoryMenu) { ++ entityplayer.closeContainer(org.bukkit.event.inventory.InventoryCloseEvent.Reason.DISCONNECT); ++ } ++ ++ if (server.isSameThread()) entityplayer.doTick(); ++ ++ if (this.collideRuleTeamName != null) { ++ final net.minecraft.world.scores.Scoreboard scoreBoard = this.server.getLevel(Level.OVERWORLD).getScoreboard(); ++ final PlayerTeam team = scoreBoard.getPlayersTeam(this.collideRuleTeamName); ++ if (entityplayer.getTeam() == team && team != null) { ++ scoreBoard.removePlayerFromTeam(entityplayer.getScoreboardName(), team); ++ } ++ } ++ ++ worldserver.removePlayerImmediately(entityplayer, Entity.RemovalReason.UNLOADED_WITH_PLAYER); ++ entityplayer.retireScheduler(); ++ entityplayer.getAdvancements().stopListening(); ++ this.players.remove(entityplayer); ++ this.playersByName.remove(entityplayer.getScoreboardName().toLowerCase(java.util.Locale.ROOT)); ++ this.server.getCustomBossEvents().onPlayerDisconnect(entityplayer); ++ UUID uuid = entityplayer.getUUID(); ++ ServerPlayer entityplayer1 = this.playersByUUID.get(uuid); ++ ++ if (entityplayer1 == entityplayer) { ++ this.playersByUUID.remove(uuid); ++ } ++ ++ this.cserver.getScoreboardManager().removePlayer(entityplayer.getBukkitEntity()); ++ } ++ // Leaves stop - replay mod api ++ + public net.kyori.adventure.text.Component remove(ServerPlayer entityplayer) { // CraftBukkit - return string // Paper - return Component + // Paper start - Fix kick event leave message not being sent + return this.remove(entityplayer, net.kyori.adventure.text.Component.translatable("multiplayer.player.left", net.kyori.adventure.text.format.NamedTextColor.YELLOW, io.papermc.paper.configuration.GlobalConfiguration.get().messages.useDisplayNameInQuitMessage ? entityplayer.getBukkitEntity().displayName() : io.papermc.paper.adventure.PaperAdventure.asAdventure(entityplayer.getDisplayName()))); +@@ -684,6 +829,7 @@ public abstract class PlayerList { + entityplayer.retireScheduler(); // Paper - Folia schedulers + entityplayer.getAdvancements().stopListening(); + this.players.remove(entityplayer); ++ this.realPlayers.remove(entityplayer); // Leaves - replay api + this.removeFromSendAllPlayerInfoBuckets(entityplayer); // Gale - Purpur - spread out sending all player info + this.playersByName.remove(entityplayer.getScoreboardName().toLowerCase(java.util.Locale.ROOT)); // Spigot + this.server.getCustomBossEvents().onPlayerDisconnect(entityplayer); +@@ -779,7 +925,7 @@ public abstract class PlayerList { + event.disallow(PlayerLoginEvent.Result.KICK_BANNED, io.papermc.paper.adventure.PaperAdventure.asAdventure(ichatmutablecomponent)); // Paper - Adventure + } else { + // return this.players.size() >= this.maxPlayers && !this.canBypassPlayerLimit(gameprofile) ? IChatBaseComponent.translatable("multiplayer.disconnect.server_full") : null; +- if (this.players.size() >= this.maxPlayers && !(player.hasPermission("purpur.joinfullserver") || this.canBypassPlayerLimit(gameprofile))) { // Purpur ++ if (this.realPlayers.size() >= this.maxPlayers && !(player.hasPermission("purpur.joinfullserver") || this.canBypassPlayerLimit(gameprofile))) { // Purpur + event.disallow(PlayerLoginEvent.Result.KICK_FULL, net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer.legacySection().deserialize(org.spigotmc.SpigotConfig.serverFullMessage)); // Spigot // Paper - Adventure + } + } +diff --git a/src/main/java/org/bukkit/craftbukkit/CraftServer.java b/src/main/java/org/bukkit/craftbukkit/CraftServer.java +index 89672b8f729e0286157a0db2277529dc663d7fc8..c55f4b87202d1a1af3d7b41e1eea12f27aaf2b73 100644 +--- a/src/main/java/org/bukkit/craftbukkit/CraftServer.java ++++ b/src/main/java/org/bukkit/craftbukkit/CraftServer.java +@@ -320,6 +320,8 @@ public final class CraftServer implements Server { + private final io.papermc.paper.threadedregions.scheduler.FoliaAsyncScheduler asyncScheduler = new io.papermc.paper.threadedregions.scheduler.FoliaAsyncScheduler(); + private final io.papermc.paper.threadedregions.scheduler.FoliaGlobalRegionScheduler globalRegionScheduler = new io.papermc.paper.threadedregions.scheduler.FoliaGlobalRegionScheduler(); + ++ private final org.leavesmc.leaves.entity.CraftPhotographerManager photographerManager = new org.leavesmc.leaves.entity.CraftPhotographerManager(); // Leaves ++ + @Override + public final io.papermc.paper.threadedregions.scheduler.RegionScheduler getRegionScheduler() { + return this.regionizedScheduler; +@@ -396,7 +398,7 @@ public final class CraftServer implements Server { + public CraftServer(DedicatedServer console, PlayerList playerList) { + this.console = console; + this.playerList = (DedicatedPlayerList) playerList; +- this.playerView = Collections.unmodifiableList(Lists.transform(playerList.players, new Function() { ++ this.playerView = Collections.unmodifiableList(Lists.transform(playerList.realPlayers, new Function() { + @Override + public CraftPlayer apply(ServerPlayer player) { + return player.getBukkitEntity(); +@@ -3384,4 +3386,11 @@ public final class CraftServer implements Server { + return getServer().lagging; + } + // Purpur end ++ ++ // Leaves start - replay mod api ++ @Override ++ public org.leavesmc.leaves.entity.CraftPhotographerManager getPhotographerManager() { ++ return photographerManager; ++ } ++ // Leaves end - replay mod api + } +diff --git a/src/main/java/org/bukkit/craftbukkit/entity/CraftEntity.java b/src/main/java/org/bukkit/craftbukkit/entity/CraftEntity.java +index 9019a98a082b9d72e5581ca3f50f45aadcc320e7..37515fe797a1a3e19e8554266bfe79717748d894 100644 +--- a/src/main/java/org/bukkit/craftbukkit/entity/CraftEntity.java ++++ b/src/main/java/org/bukkit/craftbukkit/entity/CraftEntity.java +@@ -112,6 +112,8 @@ public abstract class CraftEntity implements org.bukkit.entity.Entity { + return new CraftHumanEntity(server, (net.minecraft.world.entity.player.Player) entity); + } + ++ if (entity instanceof org.leavesmc.leaves.replay.ServerPhotographer photographer) { return new org.leavesmc.leaves.entity.CraftPhotographer(server, photographer); } ++ + // Special case complex part, since there is no extra entity type for them + if (entity instanceof EnderDragonPart complexPart) { + if (complexPart.parentMob instanceof EnderDragon) { +diff --git a/src/main/java/org/bukkit/craftbukkit/entity/CraftPlayer.java b/src/main/java/org/bukkit/craftbukkit/entity/CraftPlayer.java +index d8845be63adbc17764a57c0630aa63b69b473753..0ef581ae18b0b272f36d64c1d58146d3c8f01ad1 100644 +--- a/src/main/java/org/bukkit/craftbukkit/entity/CraftPlayer.java ++++ b/src/main/java/org/bukkit/craftbukkit/entity/CraftPlayer.java +@@ -2260,7 +2260,7 @@ public class CraftPlayer extends CraftHumanEntity implements Player { + + @Override + public boolean canSee(Player player) { +- return this.canSee((org.bukkit.entity.Entity) player); ++ return !(player instanceof org.leavesmc.leaves.entity.Photographer) && this.canSee((org.bukkit.entity.Entity) player); // Leaves - skip photographer + } + + @Override +diff --git a/src/main/java/org/leavesmc/leaves/entity/CraftPhotographer.java b/src/main/java/org/leavesmc/leaves/entity/CraftPhotographer.java +new file mode 100644 +index 0000000000000000000000000000000000000000..23c2f699f3ae12d1a36efc7860e869facea9c175 +--- /dev/null ++++ b/src/main/java/org/leavesmc/leaves/entity/CraftPhotographer.java +@@ -0,0 +1,73 @@ ++package org.leavesmc.leaves.entity; ++ ++import net.minecraft.server.level.ServerPlayer; ++import org.bukkit.craftbukkit.CraftServer; ++import org.bukkit.craftbukkit.entity.CraftPlayer; ++import org.bukkit.entity.Player; ++import org.jetbrains.annotations.NotNull; ++import org.jetbrains.annotations.Nullable; ++import org.leavesmc.leaves.replay.ServerPhotographer; ++ ++import java.io.File; ++ ++public class CraftPhotographer extends CraftPlayer implements Photographer { ++ ++ public CraftPhotographer(CraftServer server, ServerPhotographer entity) { ++ super(server, entity); ++ } ++ ++ @Override ++ public void stopRecording() { ++ this.stopRecording(true); ++ } ++ ++ @Override ++ public void stopRecording(boolean async) { ++ this.stopRecording(async, true); ++ } ++ ++ @Override ++ public void stopRecording(boolean async, boolean save) { ++ this.getHandle().remove(async, save); ++ } ++ ++ @Override ++ public void pauseRecording() { ++ this.getHandle().pauseRecording(); ++ } ++ ++ @Override ++ public void resumeRecording() { ++ this.getHandle().resumeRecording(); ++ } ++ ++ @Override ++ public void setRecordFile(@NotNull File file) { ++ this.getHandle().setSaveFile(file); ++ } ++ ++ @Override ++ public void setFollowPlayer(@Nullable Player player) { ++ ServerPlayer serverPlayer = player != null ? ((CraftPlayer) player).getHandle() : null; ++ this.getHandle().setFollowPlayer(serverPlayer); ++ } ++ ++ @Override ++ public @NotNull String getId() { ++ return this.getHandle().createState.id; ++ } ++ ++ @Override ++ public ServerPhotographer getHandle() { ++ return (ServerPhotographer) entity; ++ } ++ ++ public void setHandle(final ServerPhotographer entity) { ++ super.setHandle(entity); ++ } ++ ++ @Override ++ public String toString() { ++ return "CraftPhotographer{" + "name=" + getName() + '}'; ++ } ++} +\ No newline at end of file +diff --git a/src/main/java/org/leavesmc/leaves/entity/CraftPhotographerManager.java b/src/main/java/org/leavesmc/leaves/entity/CraftPhotographerManager.java +new file mode 100644 +index 0000000000000000000000000000000000000000..b1bbe2127a8673c4ca82473e11c9c0130a037d64 +--- /dev/null ++++ b/src/main/java/org/leavesmc/leaves/entity/CraftPhotographerManager.java +@@ -0,0 +1,83 @@ ++package org.leavesmc.leaves.entity; ++ ++import com.google.common.collect.Lists; ++import org.bukkit.Location; ++import org.bukkit.util.Consumer; ++import org.jetbrains.annotations.NotNull; ++import org.jetbrains.annotations.Nullable; ++import org.leavesmc.leaves.replay.BukkitRecorderOption; ++import org.leavesmc.leaves.replay.RecorderOption; ++import org.leavesmc.leaves.replay.ServerPhotographer; ++ ++import java.util.Collection; ++import java.util.Collections; ++import java.util.UUID; ++ ++public class CraftPhotographerManager implements PhotographerManager { ++ ++ private final Collection photographerViews = Collections.unmodifiableList(Lists.transform(ServerPhotographer.getPhotographers(), ServerPhotographer::getBukkitPlayer)); ++ ++ @Override ++ public @Nullable Photographer getPhotographer(@NotNull UUID uuid) { ++ ServerPhotographer photographer = ServerPhotographer.getPhotographer(uuid); ++ if (photographer != null) { ++ return photographer.getBukkitPlayer(); ++ } ++ return null; ++ } ++ ++ @Override ++ public @Nullable Photographer getPhotographer(@NotNull String id) { ++ ServerPhotographer photographer = ServerPhotographer.getPhotographer(id); ++ if (photographer != null) { ++ return photographer.getBukkitPlayer(); ++ } ++ return null; ++ } ++ ++ @Override ++ public @Nullable Photographer createPhotographer(@NotNull String id, @NotNull Location location) { ++ ServerPhotographer photographer = new ServerPhotographer.PhotographerCreateState(location, id, RecorderOption.createDefaultOption()).createSync(); ++ if (photographer != null) { ++ return photographer.getBukkitPlayer(); ++ } ++ return null; ++ } ++ ++ @Override ++ public @Nullable Photographer createPhotographer(@NotNull String id, @NotNull Location location, @NotNull BukkitRecorderOption recorderOption) { ++ ServerPhotographer photographer = new ServerPhotographer.PhotographerCreateState(location, id, RecorderOption.createFromBukkit(recorderOption)).createSync(); ++ if (photographer != null) { ++ return photographer.getBukkitPlayer(); ++ } ++ return null; ++ } ++ ++ @Override ++ public void removePhotographer(@NotNull String id) { ++ ServerPhotographer photographer = ServerPhotographer.getPhotographer(id); ++ if (photographer != null) { ++ photographer.remove(true); ++ } ++ } ++ ++ @Override ++ public void removePhotographer(@NotNull UUID uuid) { ++ ServerPhotographer photographer = ServerPhotographer.getPhotographer(uuid); ++ if (photographer != null) { ++ photographer.remove(true); ++ } ++ } ++ ++ @Override ++ public void removeAllPhotographers() { ++ for (ServerPhotographer photographer : ServerPhotographer.getPhotographers()) { ++ photographer.remove(true); ++ } ++ } ++ ++ @Override ++ public Collection getPhotographers() { ++ return photographerViews; ++ } ++} +\ No newline at end of file +diff --git a/src/main/java/org/leavesmc/leaves/replay/DigestOutputStream.java b/src/main/java/org/leavesmc/leaves/replay/DigestOutputStream.java +new file mode 100644 +index 0000000000000000000000000000000000000000..ffb753377aea3a2c9fd653da8245d5e733fee0cf +--- /dev/null ++++ b/src/main/java/org/leavesmc/leaves/replay/DigestOutputStream.java +@@ -0,0 +1,46 @@ ++package org.leavesmc.leaves.replay; ++ ++import org.jetbrains.annotations.NotNull; ++ ++import java.io.IOException; ++import java.io.OutputStream; ++import java.util.zip.Checksum; ++ ++public class DigestOutputStream extends OutputStream { ++ ++ private final Checksum sum; ++ private final OutputStream out; ++ ++ public DigestOutputStream(OutputStream out, Checksum sum) { ++ this.out = out; ++ this.sum = sum; ++ } ++ ++ @Override ++ public void close() throws IOException { ++ out.close(); ++ } ++ ++ @Override ++ public void flush() throws IOException { ++ out.flush(); ++ } ++ ++ @Override ++ public void write(int b) throws IOException { ++ sum.update(b); ++ out.write(b); ++ } ++ ++ @Override ++ public void write(byte @NotNull [] b) throws IOException { ++ sum.update(b); ++ out.write(b); ++ } ++ ++ @Override ++ public void write(byte @NotNull [] b, int off, int len) throws IOException { ++ sum.update(b, off, len); ++ out.write(b, off, len); ++ } ++} +\ No newline at end of file +diff --git a/src/main/java/org/leavesmc/leaves/replay/RecordMetaData.java b/src/main/java/org/leavesmc/leaves/replay/RecordMetaData.java +new file mode 100644 +index 0000000000000000000000000000000000000000..f6b9d5d47dd957d30f725c2daad596226e21af32 +--- /dev/null ++++ b/src/main/java/org/leavesmc/leaves/replay/RecordMetaData.java +@@ -0,0 +1,23 @@ ++package org.leavesmc.leaves.replay; ++ ++import java.util.HashSet; ++import java.util.Set; ++import java.util.UUID; ++ ++public class RecordMetaData { ++ ++ public static final int CURRENT_FILE_FORMAT_VERSION = 14; ++ ++ public boolean singleplayer = false; ++ public String serverName = "Leaf"; ++ public int duration = 0; ++ public long date; ++ public String mcversion; ++ public String fileFormat = "MCPR"; ++ public int fileFormatVersion; ++ public int protocol; ++ public String generator; ++ public int selfId = -1; ++ ++ public Set players = new HashSet<>(); ++} +\ No newline at end of file +diff --git a/src/main/java/org/leavesmc/leaves/replay/Recorder.java b/src/main/java/org/leavesmc/leaves/replay/Recorder.java +new file mode 100644 +index 0000000000000000000000000000000000000000..255261aa57780e622b7f4026c6f0ec0402eaee53 +--- /dev/null ++++ b/src/main/java/org/leavesmc/leaves/replay/Recorder.java +@@ -0,0 +1,288 @@ ++package org.leavesmc.leaves.replay; ++ ++import com.mojang.serialization.DynamicOps; ++import io.netty.channel.local.LocalChannel; ++import net.minecraft.SharedConstants; ++import net.minecraft.core.LayeredRegistryAccess; ++import net.minecraft.core.RegistrySynchronization; ++import net.minecraft.nbt.NbtOps; ++import net.minecraft.nbt.Tag; ++import net.minecraft.network.Connection; ++import net.minecraft.network.ConnectionProtocol; ++import net.minecraft.network.PacketSendListener; ++import net.minecraft.network.protocol.BundlePacket; ++import net.minecraft.network.protocol.Packet; ++import net.minecraft.network.protocol.PacketFlow; ++import net.minecraft.network.protocol.common.ClientboundCustomPayloadPacket; ++import net.minecraft.network.protocol.common.ClientboundDisconnectPacket; ++import net.minecraft.network.protocol.common.ClientboundResourcePackPushPacket; ++import net.minecraft.network.protocol.common.ClientboundUpdateTagsPacket; ++import net.minecraft.network.protocol.common.custom.BrandPayload; ++import net.minecraft.network.protocol.configuration.ClientboundFinishConfigurationPacket; ++import net.minecraft.network.protocol.configuration.ClientboundRegistryDataPacket; ++import net.minecraft.network.protocol.configuration.ClientboundSelectKnownPacks; ++import net.minecraft.network.protocol.configuration.ClientboundUpdateEnabledFeaturesPacket; ++import net.minecraft.network.protocol.game.ClientboundAddEntityPacket; ++import net.minecraft.network.protocol.game.ClientboundGameEventPacket; ++import net.minecraft.network.protocol.game.ClientboundPlayerChatPacket; ++import net.minecraft.network.protocol.game.ClientboundSetTimePacket; ++import net.minecraft.network.protocol.game.ClientboundSystemChatPacket; ++import net.minecraft.network.protocol.login.ClientboundGameProfilePacket; ++import net.minecraft.server.MinecraftServer; ++import net.minecraft.server.RegistryLayer; ++import net.minecraft.server.packs.repository.KnownPack; ++import net.minecraft.tags.TagNetworkSerialization; ++import net.minecraft.world.entity.EntityType; ++import net.minecraft.world.flag.FeatureFlags; ++import org.jetbrains.annotations.NotNull; ++import org.jetbrains.annotations.Nullable; ++import org.leavesmc.leaves.LeavesLogger; ++ ++import java.io.File; ++import java.io.IOException; ++import java.util.List; ++import java.util.Optional; ++import java.util.Set; ++import java.util.concurrent.CompletableFuture; ++import java.util.concurrent.CompletionException; ++import java.util.concurrent.ExecutorService; ++import java.util.concurrent.Executors; ++import java.util.concurrent.TimeUnit; ++ ++public class Recorder extends Connection { ++ ++ private static final LeavesLogger LOGGER = LeavesLogger.LOGGER; ++ ++ private final ReplayFile replayFile; ++ private final ServerPhotographer photographer; ++ private final RecorderOption recorderOption; ++ private final RecordMetaData metaData; ++ ++ private final ExecutorService saveService = Executors.newSingleThreadExecutor(); ++ ++ private boolean stopped = false; ++ private boolean paused = false; ++ private boolean resumeOnNextPacket = true; ++ ++ private long startTime; ++ private long lastPacket; ++ private long timeShift = 0; ++ ++ private boolean isSaved; ++ private boolean isSaving; ++ private ConnectionProtocol state = ConnectionProtocol.LOGIN; ++ ++ public Recorder(ServerPhotographer photographer, RecorderOption recorderOption, File replayFile) throws IOException { ++ super(PacketFlow.CLIENTBOUND); ++ ++ this.photographer = photographer; ++ this.recorderOption = recorderOption; ++ this.metaData = new RecordMetaData(); ++ this.replayFile = new ReplayFile(replayFile); ++ this.channel = new LocalChannel(); ++ } ++ ++ public void start() { ++ startTime = System.currentTimeMillis(); ++ ++ metaData.singleplayer = false; ++ metaData.serverName = recorderOption.serverName; ++ metaData.generator = "leaves"; ++ metaData.date = startTime; ++ metaData.mcversion = SharedConstants.getCurrentVersion().getName(); ++ ++ // TODO start event ++ savePacket(new ClientboundGameProfilePacket(photographer.getGameProfile(), true), ConnectionProtocol.LOGIN); ++ startConfiguration(); ++ ++ if (recorderOption.forceWeather != null) { ++ setWeather(recorderOption.forceWeather); ++ } ++ } ++ ++ public void startConfiguration() { ++ state = ConnectionProtocol.CONFIGURATION; ++ MinecraftServer server = MinecraftServer.getServer(); ++ savePacket(new ClientboundCustomPayloadPacket(new BrandPayload(server.getServerModName())), ConnectionProtocol.CONFIGURATION); ++ ++ savePacket(new ClientboundUpdateEnabledFeaturesPacket(FeatureFlags.REGISTRY.toNames(server.getWorldData().enabledFeatures())), ConnectionProtocol.CONFIGURATION); ++ ++ List knownPackslist = server.getResourceManager().listPacks().flatMap((iresourcepack) -> iresourcepack.location().knownPackInfo().stream()).toList(); ++ LayeredRegistryAccess layeredregistryaccess = server.registries(); ++ ++ savePacket(new ClientboundSelectKnownPacks(knownPackslist), ConnectionProtocol.CONFIGURATION); ++ DynamicOps dynamicOps = layeredregistryaccess.compositeAccess().createSerializationContext(NbtOps.INSTANCE); ++ RegistrySynchronization.packRegistries( ++ dynamicOps, ++ layeredregistryaccess.getAccessFrom(RegistryLayer.WORLDGEN), ++ Set.copyOf(knownPackslist), ++ (key, entries) -> savePacket(new ClientboundRegistryDataPacket(key, entries), ConnectionProtocol.CONFIGURATION) ++ ); ++ savePacket(new ClientboundUpdateTagsPacket(TagNetworkSerialization.serializeTagsToNetwork(layeredregistryaccess)), ConnectionProtocol.CONFIGURATION); ++ ++ server.getServerResourcePack().ifPresent((info) -> { ++ savePacket(new ClientboundResourcePackPushPacket(info.id(), info.url(), info.hash(), info.isRequired(), Optional.ofNullable(info.prompt())), ConnectionProtocol.CONFIGURATION); ++ }); ++ ++ savePacket(ClientboundFinishConfigurationPacket.INSTANCE, ConnectionProtocol.CONFIGURATION); ++ state = ConnectionProtocol.PLAY; ++ } ++ ++ @Override ++ public void flushChannel() { ++ } ++ ++ public void stop() { ++ stopped = true; ++ } ++ ++ public void pauseRecording() { ++ resumeOnNextPacket = false; ++ paused = true; ++ } ++ ++ public void resumeRecording() { ++ resumeOnNextPacket = true; ++ } ++ ++ public void setWeather(RecorderOption.RecordWeather weather) { ++ weather.getPackets().forEach(this::savePacket); ++ } ++ ++ public long getRecordedTime() { ++ final long base = System.currentTimeMillis() - startTime; ++ return base - timeShift; ++ } ++ ++ private synchronized long getCurrentTimeAndUpdate() { ++ long now = getRecordedTime(); ++ if (paused) { ++ if (resumeOnNextPacket) { ++ paused = false; ++ } ++ timeShift += now - lastPacket; ++ return lastPacket; ++ } ++ return lastPacket = now; ++ } ++ ++ @Override ++ public boolean isConnected() { ++ return true; ++ } ++ ++ @Override ++ public void send(@NotNull Packet packet, @Nullable PacketSendListener callbacks, boolean flush) { ++ if (!stopped) { ++ if (packet instanceof BundlePacket packet1) { ++ packet1.subPackets().forEach(subPacket -> { ++ send(subPacket, null); ++ }); ++ return; ++ } ++ ++ if (packet instanceof ClientboundAddEntityPacket packet1) { ++ if (packet1.getType() == EntityType.PLAYER) { ++ metaData.players.add(packet1.getUUID()); ++ saveMetadata(); ++ } ++ } ++ ++ if (packet instanceof ClientboundDisconnectPacket) { ++ return; ++ } ++ ++ if (recorderOption.forceDayTime != -1 && packet instanceof ClientboundSetTimePacket packet1) { ++ packet = new ClientboundSetTimePacket(packet1.getDayTime(), recorderOption.forceDayTime, false); ++ } ++ ++ if (recorderOption.forceWeather != null && packet instanceof ClientboundGameEventPacket packet1) { ++ ClientboundGameEventPacket.Type type = packet1.getEvent(); ++ if (type == ClientboundGameEventPacket.START_RAINING || type == ClientboundGameEventPacket.STOP_RAINING || type == ClientboundGameEventPacket.RAIN_LEVEL_CHANGE || type == ClientboundGameEventPacket.THUNDER_LEVEL_CHANGE) { ++ return; ++ } ++ } ++ ++ if (recorderOption.ignoreChat && (packet instanceof ClientboundSystemChatPacket || packet instanceof ClientboundPlayerChatPacket)) { ++ return; ++ } ++ ++ savePacket(packet); ++ } ++ } ++ ++ private void saveMetadata() { ++ saveService.submit(() -> { ++ try { ++ replayFile.saveMetaData(metaData); ++ } catch (IOException e) { ++ e.printStackTrace(); ++ } ++ }); ++ } ++ ++ private void savePacket(Packet packet) { ++ this.savePacket(packet, state); ++ } ++ ++ private void savePacket(Packet packet, final ConnectionProtocol protocol) { ++ try { ++ final long timestamp = getCurrentTimeAndUpdate(); ++ saveService.submit(() -> { ++ try { ++ replayFile.savePacket(timestamp, packet, protocol); ++ } catch (Exception e) { ++ LOGGER.severe("Error saving packet"); ++ e.printStackTrace(); ++ } ++ }); ++ } catch (Exception e) { ++ LOGGER.severe("Error saving packet"); ++ e.printStackTrace(); ++ } ++ } ++ ++ public boolean isSaved() { ++ return isSaved; ++ } ++ ++ public CompletableFuture saveRecording(File dest, boolean save) { ++ isSaved = true; ++ if (!isSaving) { ++ isSaving = true; ++ metaData.duration = (int) lastPacket; ++ return CompletableFuture.runAsync(() -> { ++ saveMetadata(); ++ saveService.shutdown(); ++ boolean interrupted = false; ++ try { ++ saveService.awaitTermination(10, TimeUnit.SECONDS); ++ } catch (InterruptedException e) { ++ interrupted = true; ++ } ++ try { ++ if (save) { ++ replayFile.closeAndSave(dest); ++ } else { ++ replayFile.closeNotSave(); ++ } ++ } catch (IOException e) { ++ e.printStackTrace(); ++ throw new CompletionException(e); ++ } finally { ++ if (interrupted) { ++ Thread.currentThread().interrupt(); ++ } ++ } ++ }, runnable -> { ++ final Thread thread = new Thread(runnable, "Recording file save thread"); ++ thread.start(); ++ }); ++ } else { ++ LOGGER.warning("saveRecording() called twice"); ++ return CompletableFuture.supplyAsync(() -> { ++ throw new IllegalStateException("saveRecording() called twice"); ++ }); ++ } ++ } ++} +\ No newline at end of file +diff --git a/src/main/java/org/leavesmc/leaves/replay/RecorderOption.java b/src/main/java/org/leavesmc/leaves/replay/RecorderOption.java +new file mode 100644 +index 0000000000000000000000000000000000000000..7183f7fc3eb4fc12aa90b94661b652f476de396b +--- /dev/null ++++ b/src/main/java/org/leavesmc/leaves/replay/RecorderOption.java +@@ -0,0 +1,57 @@ ++package org.leavesmc.leaves.replay; ++ ++import net.minecraft.network.protocol.Packet; ++import net.minecraft.network.protocol.game.ClientboundGameEventPacket; ++import org.jetbrains.annotations.Contract; ++import org.jetbrains.annotations.NotNull; ++ ++import java.util.List; ++ ++public class RecorderOption { ++ ++ public int recordDistance = -1; ++ public String serverName = "Leaf"; ++ public RecordWeather forceWeather = null; ++ public int forceDayTime = -1; ++ public boolean ignoreChat = false; ++ public boolean ignoreItem = false; ++ ++ @NotNull ++ @Contract(" -> new") ++ public static RecorderOption createDefaultOption() { ++ return new RecorderOption(); ++ } ++ ++ @NotNull ++ public static RecorderOption createFromBukkit(@NotNull BukkitRecorderOption bukkitRecorderOption) { ++ RecorderOption recorderOption = new RecorderOption(); ++ // recorderOption.recordDistance = bukkitRecorderOption.recordDistance; ++ // recorderOption.ignoreItem = bukkitRecorderOption.ignoreItem; ++ recorderOption.serverName = bukkitRecorderOption.serverName; ++ recorderOption.ignoreChat = bukkitRecorderOption.ignoreChat; ++ recorderOption.forceDayTime = bukkitRecorderOption.forceDayTime; ++ recorderOption.forceWeather = switch (bukkitRecorderOption.forceWeather) { ++ case RAIN -> RecordWeather.RAIN; ++ case CLEAR -> RecordWeather.CLEAR; ++ case THUNDER -> RecordWeather.THUNDER; ++ case NULL -> null; ++ }; ++ return recorderOption; ++ } ++ ++ public enum RecordWeather { ++ CLEAR(new ClientboundGameEventPacket(ClientboundGameEventPacket.STOP_RAINING, 0), new ClientboundGameEventPacket(ClientboundGameEventPacket.RAIN_LEVEL_CHANGE, 0), new ClientboundGameEventPacket(ClientboundGameEventPacket.THUNDER_LEVEL_CHANGE, 0)), ++ RAIN(new ClientboundGameEventPacket(ClientboundGameEventPacket.START_RAINING, 0), new ClientboundGameEventPacket(ClientboundGameEventPacket.RAIN_LEVEL_CHANGE, 1), new ClientboundGameEventPacket(ClientboundGameEventPacket.THUNDER_LEVEL_CHANGE, 0)), ++ THUNDER(new ClientboundGameEventPacket(ClientboundGameEventPacket.START_RAINING, 0), new ClientboundGameEventPacket(ClientboundGameEventPacket.RAIN_LEVEL_CHANGE, 1), new ClientboundGameEventPacket(ClientboundGameEventPacket.THUNDER_LEVEL_CHANGE, 1)); ++ ++ private final List> packets; ++ ++ private RecordWeather(Packet... packets) { ++ this.packets = List.of(packets); ++ } ++ ++ public List> getPackets() { ++ return packets; ++ } ++ } ++} +\ No newline at end of file +diff --git a/src/main/java/org/leavesmc/leaves/replay/ReplayFile.java b/src/main/java/org/leavesmc/leaves/replay/ReplayFile.java +new file mode 100644 +index 0000000000000000000000000000000000000000..f41657d4796a6bf60665d1ba4c1a8f9295a2e1d5 +--- /dev/null ++++ b/src/main/java/org/leavesmc/leaves/replay/ReplayFile.java +@@ -0,0 +1,199 @@ ++package org.leavesmc.leaves.replay; ++ ++import com.google.gson.Gson; ++import com.google.gson.GsonBuilder; ++import io.netty.buffer.ByteBuf; ++import io.netty.buffer.Unpooled; ++import net.minecraft.SharedConstants; ++import net.minecraft.network.ConnectionProtocol; ++import net.minecraft.network.FriendlyByteBuf; ++import net.minecraft.network.ProtocolInfo; ++import net.minecraft.network.RegistryFriendlyByteBuf; ++import net.minecraft.network.codec.StreamCodec; ++import net.minecraft.network.protocol.Packet; ++import net.minecraft.network.protocol.PacketFlow; ++import net.minecraft.network.protocol.configuration.ConfigurationProtocols; ++import net.minecraft.network.protocol.game.GameProtocols; ++import net.minecraft.network.protocol.handshake.HandshakeProtocols; ++import net.minecraft.network.protocol.login.LoginProtocols; ++import net.minecraft.network.protocol.status.StatusProtocols; ++import net.minecraft.server.MinecraftServer; ++import org.jetbrains.annotations.NotNull; ++import org.leavesmc.leaves.util.UUIDSerializer; ++ ++import java.io.BufferedOutputStream; ++import java.io.DataOutputStream; ++import java.io.File; ++import java.io.FileInputStream; ++import java.io.FileOutputStream; ++import java.io.IOException; ++import java.io.InputStream; ++import java.io.OutputStream; ++import java.io.OutputStreamWriter; ++import java.io.Writer; ++import java.nio.charset.StandardCharsets; ++import java.nio.file.Files; ++import java.util.List; ++import java.util.Map; ++import java.util.UUID; ++import java.util.zip.CRC32; ++import java.util.zip.ZipEntry; ++import java.util.zip.ZipOutputStream; ++ ++public class ReplayFile { ++ ++ private static final String RECORDING_FILE = "recording.tmcpr"; ++ private static final String RECORDING_FILE_CRC32 = "recording.tmcpr.crc32"; ++ private static final String MARKER_FILE = "markers.json"; ++ private static final String META_FILE = "metaData.json"; ++ ++ private static final Gson MARKER_GSON = new GsonBuilder().registerTypeAdapter(ReplayMarker.class, new ReplayMarker.Serializer()).create(); ++ private static final Gson META_GSON = new GsonBuilder().registerTypeAdapter(UUID.class, new UUIDSerializer()).create(); ++ ++ private final File tmpDir; ++ private final DataOutputStream packetStream; ++ private final CRC32 crc32 = new CRC32(); ++ ++ private final File markerFile; ++ private final File metaFile; ++ ++ private final Map> protocols; ++ ++ public ReplayFile(@NotNull File name) throws IOException { ++ this.tmpDir = new File(name.getParentFile(), name.getName() + ".tmp"); ++ if (tmpDir.exists()) { ++ if (!ReplayFile.deleteDir(tmpDir)) { ++ throw new IOException("Recording file " + name + " already exists!"); ++ } ++ } ++ ++ if (!tmpDir.mkdirs()) { ++ throw new IOException("Failed to create temp directory for recording " + tmpDir); ++ } ++ ++ File packetFile = new File(tmpDir, RECORDING_FILE); ++ this.metaFile = new File(tmpDir, META_FILE); ++ this.markerFile = new File(tmpDir, MARKER_FILE); ++ ++ this.packetStream = new DataOutputStream(new DigestOutputStream(new BufferedOutputStream(new FileOutputStream(packetFile)), crc32)); ++ ++ this.protocols = Map.of( ++ ConnectionProtocol.STATUS, StatusProtocols.CLIENTBOUND, ++ ConnectionProtocol.LOGIN, LoginProtocols.CLIENTBOUND, ++ ConnectionProtocol.CONFIGURATION, ConfigurationProtocols.CLIENTBOUND, ++ ConnectionProtocol.PLAY, GameProtocols.CLIENTBOUND_TEMPLATE.bind(RegistryFriendlyByteBuf.decorator(MinecraftServer.getServer().registryAccess())) ++ ); ++ } ++ ++ private byte @NotNull [] getPacketBytes(Packet packet, ConnectionProtocol state) { // TODO: Fix this ++ ProtocolInfo protocol = this.protocols.get(state); ++ if (protocol == null) { ++ throw new IllegalArgumentException("Unknown protocol state " + state); ++ } ++ ++ ByteBuf buf = Unpooled.buffer(); ++ protocol.codec().encode(buf, packet); ++ ++ buf.readerIndex(0); ++ byte[] ret = new byte[buf.readableBytes()]; ++ buf.readBytes(ret); ++ buf.release(); ++ return ret; ++ } ++ ++ public void saveMarkers(List markers) throws IOException { ++ try (Writer writer = new OutputStreamWriter(new FileOutputStream(markerFile), StandardCharsets.UTF_8)) { ++ writer.write(MARKER_GSON.toJson(markers)); ++ } ++ } ++ ++ public void saveMetaData(@NotNull RecordMetaData data) throws IOException { ++ data.fileFormat = "MCPR"; ++ data.fileFormatVersion = RecordMetaData.CURRENT_FILE_FORMAT_VERSION; ++ data.protocol = SharedConstants.getCurrentVersion().getProtocolVersion(); ++ ++ try (Writer writer = new OutputStreamWriter(new FileOutputStream(metaFile), StandardCharsets.UTF_8)) { ++ writer.write(META_GSON.toJson(data)); ++ } ++ } ++ ++ public void savePacket(long timestamp, Packet packet, ConnectionProtocol protocol) throws Exception { ++ byte[] data = getPacketBytes(packet, protocol); ++ packetStream.writeInt((int) timestamp); ++ packetStream.writeInt(data.length); ++ packetStream.write(data); ++ } ++ ++ public synchronized void closeAndSave(File file) throws IOException { ++ packetStream.close(); ++ ++ String[] files = tmpDir.list(); ++ if (files == null) { ++ return; ++ } ++ ++ try (ZipOutputStream os = new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(file)))) { ++ for (String fileName : files) { ++ os.putNextEntry(new ZipEntry(fileName)); ++ File f = new File(tmpDir, fileName); ++ copy(new FileInputStream(f), os); ++ } ++ ++ os.putNextEntry(new ZipEntry(RECORDING_FILE_CRC32)); ++ Writer writer = new OutputStreamWriter(os); ++ writer.write(Long.toString(crc32.getValue())); ++ writer.flush(); ++ } ++ ++ for (String fileName : files) { ++ File f = new File(tmpDir, fileName); ++ Files.delete(f.toPath()); ++ } ++ Files.delete(tmpDir.toPath()); ++ } ++ ++ public synchronized void closeNotSave() throws IOException { ++ packetStream.close(); ++ ++ String[] files = tmpDir.list(); ++ if (files == null) { ++ return; ++ } ++ ++ for (String fileName : files) { ++ File f = new File(tmpDir, fileName); ++ Files.delete(f.toPath()); ++ } ++ Files.delete(tmpDir.toPath()); ++ } ++ ++ private void copy(@NotNull InputStream in, OutputStream out) throws IOException { ++ byte[] buffer = new byte[8192]; ++ int len; ++ while ((len = in.read(buffer)) > -1) { ++ out.write(buffer, 0, len); ++ } ++ in.close(); ++ } ++ ++ private static boolean deleteDir(File dir) { ++ if (dir == null || !dir.exists()) { ++ return false; ++ } ++ ++ File[] files = dir.listFiles(); ++ if (files != null) { ++ for (File file : files) { ++ if (file.isDirectory()) { ++ deleteDir(file); ++ } else { ++ if (!file.delete()) { ++ return false; ++ } ++ } ++ } ++ } ++ ++ return dir.delete(); ++ } ++} +\ No newline at end of file +diff --git a/src/main/java/org/leavesmc/leaves/replay/ReplayMarker.java b/src/main/java/org/leavesmc/leaves/replay/ReplayMarker.java +new file mode 100644 +index 0000000000000000000000000000000000000000..219b9ea88f8cebaf8aab73b123d3efd586cc3147 +--- /dev/null ++++ b/src/main/java/org/leavesmc/leaves/replay/ReplayMarker.java +@@ -0,0 +1,43 @@ ++package org.leavesmc.leaves.replay; ++ ++import com.google.gson.JsonElement; ++import com.google.gson.JsonObject; ++import com.google.gson.JsonPrimitive; ++import com.google.gson.JsonSerializationContext; ++import com.google.gson.JsonSerializer; ++ ++import java.lang.reflect.Type; ++ ++public class ReplayMarker { ++ ++ public int time; ++ public String name; ++ public double x = 0; ++ public double y = 0; ++ public double z = 0; ++ public float phi = 0; ++ public float theta = 0; ++ public float varphi = 0; ++ ++ public static class Serializer implements JsonSerializer { ++ @Override ++ public JsonElement serialize(ReplayMarker src, Type typeOfSrc, JsonSerializationContext context) { ++ JsonObject ret = new JsonObject(); ++ JsonObject value = new JsonObject(); ++ JsonObject position = new JsonObject(); ++ ret.add("realTimestamp", new JsonPrimitive(src.time)); ++ ret.add("value", value); ++ ++ value.add("name", new JsonPrimitive(src.name)); ++ value.add("position", position); ++ ++ position.add("x", new JsonPrimitive(src.x)); ++ position.add("y", new JsonPrimitive(src.y)); ++ position.add("z", new JsonPrimitive(src.z)); ++ position.add("yaw", new JsonPrimitive(src.phi)); ++ position.add("pitch", new JsonPrimitive(src.theta)); ++ position.add("roll", new JsonPrimitive(src.varphi)); ++ return ret; ++ } ++ } ++} +\ No newline at end of file +diff --git a/src/main/java/org/leavesmc/leaves/replay/ServerPhotographer.java b/src/main/java/org/leavesmc/leaves/replay/ServerPhotographer.java +new file mode 100644 +index 0000000000000000000000000000000000000000..17cd14449bde1ee7d2b5938a04ee3eebf516d581 +--- /dev/null ++++ b/src/main/java/org/leavesmc/leaves/replay/ServerPhotographer.java +@@ -0,0 +1,221 @@ ++package org.leavesmc.leaves.replay; ++ ++import com.mojang.authlib.GameProfile; ++import net.minecraft.server.MinecraftServer; ++import net.minecraft.server.level.ClientInformation; ++import net.minecraft.server.level.ServerLevel; ++import net.minecraft.server.level.ServerPlayer; ++import net.minecraft.stats.ServerStatsCounter; ++import net.minecraft.world.damagesource.DamageSource; ++import net.minecraft.world.phys.Vec3; ++import org.bukkit.Bukkit; ++import org.bukkit.Location; ++import org.bukkit.craftbukkit.CraftWorld; ++import org.jetbrains.annotations.NotNull; ++import org.leavesmc.leaves.LeavesLogger; ++import org.leavesmc.leaves.bot.BotStatsCounter; ++import org.leavesmc.leaves.entity.CraftPhotographer; ++import org.leavesmc.leaves.entity.Photographer; ++ ++import java.io.File; ++import java.io.IOException; ++import java.util.List; ++import java.util.UUID; ++import java.util.concurrent.CompletableFuture; ++import java.util.concurrent.CopyOnWriteArrayList; ++ ++public class ServerPhotographer extends ServerPlayer { ++ ++ private static final List photographers = new CopyOnWriteArrayList<>(); ++ ++ public PhotographerCreateState createState; ++ private ServerPlayer followPlayer; ++ private Recorder recorder; ++ private File saveFile; ++ private Vec3 lastPos; ++ ++ private final ServerStatsCounter stats; ++ ++ private ServerPhotographer(MinecraftServer server, ServerLevel world, GameProfile profile) { ++ super(server, world, profile, ClientInformation.createDefault()); ++ this.followPlayer = null; ++ this.stats = new BotStatsCounter(server); ++ this.lastPos = this.position(); ++ } ++ ++ public static ServerPhotographer createPhotographer(@NotNull PhotographerCreateState state) throws IOException { ++ if (!isCreateLegal(state.id)) { ++ throw new IllegalArgumentException(state.id + " is a invalid photographer id"); ++ } ++ ++ MinecraftServer server = MinecraftServer.getServer(); ++ ++ ServerLevel world = ((CraftWorld) state.loc.getWorld()).getHandle(); ++ GameProfile profile = new GameProfile(UUID.randomUUID(), state.id); ++ ++ ServerPhotographer photographer = new ServerPhotographer(server, world, profile); ++ photographer.recorder = new Recorder(photographer, state.option, new File("replay", state.id)); ++ photographer.saveFile = new File("replay", state.id + ".mcpr"); ++ photographer.createState = state; ++ ++ photographer.recorder.start(); ++ MinecraftServer.getServer().getPlayerList().placeNewPhotographer(photographer.recorder, photographer, world, state.loc); ++ photographer.serverLevel().chunkSource.move(photographer); ++ photographer.setInvisible(true); ++ photographers.add(photographer); ++ ++ LeavesLogger.LOGGER.info("Photographer " + state.id + " created"); ++ ++ // TODO record distance ++ ++ return photographer; ++ } ++ ++ @Override ++ public void tick() { ++ super.tick(); ++ super.doTick(); ++ ++ if (this.server.getTickCount() % 10 == 0) { ++ connection.resetPosition(); ++ this.serverLevel().chunkSource.move(this); ++ } ++ ++ if (this.followPlayer != null) { ++ if (this.getCamera() == this || this.getCamera().level() != this.level()) { ++ this.getBukkitPlayer().teleport(this.getCamera().getBukkitEntity().getLocation()); ++ this.setCamera(followPlayer); ++ } ++ if (lastPos.distanceToSqr(this.position()) > 1024D) { ++ this.getBukkitPlayer().teleport(this.getCamera().getBukkitEntity().getLocation()); ++ } ++ } ++ ++ lastPos = this.position(); ++ } ++ ++ @Override ++ public void die(@NotNull DamageSource damageSource) { ++ super.die(damageSource); ++ remove(true); ++ } ++ ++ @Override ++ public boolean isInvulnerableTo(@NotNull DamageSource damageSource) { ++ return true; ++ } ++ ++ @Override ++ public boolean hurt(@NotNull DamageSource source, float amount) { ++ return false; ++ } ++ ++ @Override ++ public void setHealth(float health) { ++ } ++ ++ @NotNull ++ @Override ++ public ServerStatsCounter getStats() { ++ return stats; ++ } ++ ++ public void remove(boolean async) { ++ this.remove(async, true); ++ } ++ ++ public void remove(boolean async, boolean save) { ++ super.remove(RemovalReason.KILLED); ++ photographers.remove(this); ++ this.recorder.stop(); ++ this.server.getPlayerList().removePhotographer(this); ++ ++ LeavesLogger.LOGGER.info("Photographer " + createState.id + " removed"); ++ ++ if (!recorder.isSaved()) { ++ CompletableFuture future = recorder.saveRecording(saveFile, save); ++ if (!async) { ++ future.join(); ++ } ++ } ++ } ++ ++ public void setFollowPlayer(ServerPlayer followPlayer) { ++ this.setCamera(followPlayer); ++ this.followPlayer = followPlayer; ++ } ++ ++ public void setSaveFile(File saveFile) { ++ this.saveFile = saveFile; ++ } ++ ++ public void pauseRecording() { ++ this.recorder.pauseRecording(); ++ } ++ ++ public void resumeRecording() { ++ this.recorder.resumeRecording(); ++ } ++ ++ public static ServerPhotographer getPhotographer(String id) { ++ for (ServerPhotographer photographer : photographers) { ++ if (photographer.createState.id.equals(id)) { ++ return photographer; ++ } ++ } ++ return null; ++ } ++ ++ public static ServerPhotographer getPhotographer(UUID uuid) { ++ for (ServerPhotographer photographer : photographers) { ++ if (photographer.getUUID().equals(uuid)) { ++ return photographer; ++ } ++ } ++ return null; ++ } ++ ++ public static List getPhotographers() { ++ return photographers; ++ } ++ ++ public Photographer getBukkitPlayer() { ++ return getBukkitEntity(); ++ } ++ ++ @Override ++ @NotNull ++ public CraftPhotographer getBukkitEntity() { ++ return (CraftPhotographer) super.getBukkitEntity(); ++ } ++ ++ public static boolean isCreateLegal(@NotNull String name) { ++ if (!name.matches("^[a-zA-Z0-9_]{4,16}$")) { ++ return false; ++ } ++ ++ return Bukkit.getPlayerExact(name) == null && ServerPhotographer.getPhotographer(name) == null; ++ } ++ ++ public static class PhotographerCreateState { ++ ++ public RecorderOption option; ++ public Location loc; ++ public final String id; ++ ++ public PhotographerCreateState(Location loc, String id, RecorderOption option) { ++ this.loc = loc; ++ this.id = id; ++ this.option = option; ++ } ++ ++ public ServerPhotographer createSync() { ++ try { ++ return createPhotographer(this); ++ } catch (IOException e) { ++ e.printStackTrace(); ++ } ++ return null; ++ } ++ } ++} +\ No newline at end of file +diff --git a/src/main/java/org/leavesmc/leaves/replay/ServerPhotographerGameMode.java b/src/main/java/org/leavesmc/leaves/replay/ServerPhotographerGameMode.java +new file mode 100644 +index 0000000000000000000000000000000000000000..41adf5787bdab11806c76fd379275403c00466a9 +--- /dev/null ++++ b/src/main/java/org/leavesmc/leaves/replay/ServerPhotographerGameMode.java +@@ -0,0 +1,35 @@ ++package org.leavesmc.leaves.replay; ++ ++import net.kyori.adventure.text.Component; ++import net.minecraft.server.level.ServerPlayerGameMode; ++import net.minecraft.world.level.GameType; ++import org.bukkit.event.player.PlayerGameModeChangeEvent; ++import org.jetbrains.annotations.NotNull; ++import org.jetbrains.annotations.Nullable; ++ ++public class ServerPhotographerGameMode extends ServerPlayerGameMode { ++ ++ public ServerPhotographerGameMode(ServerPhotographer photographer) { ++ super(photographer); ++ super.setGameModeForPlayer(GameType.SPECTATOR, null); ++ } ++ ++ @Override ++ public boolean changeGameModeForPlayer(@NotNull GameType gameMode) { ++ return false; ++ } ++ ++ @Nullable ++ @Override ++ public PlayerGameModeChangeEvent changeGameModeForPlayer(@NotNull GameType gameMode, PlayerGameModeChangeEvent.@NotNull Cause cause, @Nullable Component cancelMessage) { ++ return null; ++ } ++ ++ @Override ++ protected void setGameModeForPlayer(@NotNull GameType gameMode, @Nullable GameType previousGameMode) { ++ } ++ ++ @Override ++ public void tick() { ++ } ++} +\ No newline at end of file +diff --git a/src/main/java/org/leavesmc/leaves/util/UUIDSerializer.java b/src/main/java/org/leavesmc/leaves/util/UUIDSerializer.java +new file mode 100644 +index 0000000000000000000000000000000000000000..93f72a99595a0b1f182f3950de36f1282a171e84 +--- /dev/null ++++ b/src/main/java/org/leavesmc/leaves/util/UUIDSerializer.java +@@ -0,0 +1,17 @@ ++package org.leavesmc.leaves.util; ++ ++import com.google.gson.JsonElement; ++import com.google.gson.JsonPrimitive; ++import com.google.gson.JsonSerializationContext; ++import com.google.gson.JsonSerializer; ++import org.jetbrains.annotations.NotNull; ++ ++import java.lang.reflect.Type; ++import java.util.UUID; ++ ++public class UUIDSerializer implements JsonSerializer { ++ @Override ++ public JsonElement serialize(@NotNull UUID src, Type typeOfSrc, JsonSerializationContext context) { ++ return new JsonPrimitive(src.toString()); ++ } ++} +\ No newline at end of file +diff --git a/src/main/java/net/minecraft/server/level/ServerPlayer.java b/src/main/java/net/minecraft/server/level/ServerPlayer.java +index 8f188b137247289770b0a663124f6345a902cd8a..11949d56094523968fd47c3f6f25a627681acf97 100644 +--- a/src/main/java/net/minecraft/server/level/ServerPlayer.java ++++ b/src/main/java/net/minecraft/server/level/ServerPlayer.java +@@ -161,6 +161,8 @@ import net.minecraft.world.phys.Vec3; + import net.minecraft.world.scores.PlayerTeam; + import net.minecraft.world.scores.ScoreAccess; + import net.minecraft.world.scores.ScoreHolder; ++import org.leavesmc.leaves.replay.ServerPhotographer; ++import org.leavesmc.leaves.replay.ServerPhotographerGameMode; + import org.slf4j.Logger; + import net.minecraft.world.Container; + import net.minecraft.world.InteractionHand; +@@ -425,7 +427,7 @@ public class ServerPlayer extends net.minecraft.world.entity.player.Player imple + public void dataChanged(AbstractContainerMenu handler, int property, int value) {} + }; + this.textFilter = server.createTextFilterForPlayer(this); +- this.gameMode = server.createGameModeForPlayer(this); ++ this.gameMode = this instanceof ServerPhotographer ? new ServerPhotographerGameMode((ServerPhotographer) this) : server.createGameModeForPlayer(this); + this.server = server; + this.stats = server.getPlayerList().getPlayerStats(this); + this.advancements = server.getPlayerList().getPlayerAdvancements(this); +diff --git a/src/main/java/org/leavesmc/leaves/bot/BotStatsCounter.java b/src/main/java/org/leavesmc/leaves/bot/BotStatsCounter.java +new file mode 100644 +index 0000000000000000000000000000000000000000..1dfbda8b2439e3f21fea953292aa0e3e853b22e0 +--- /dev/null ++++ b/src/main/java/org/leavesmc/leaves/bot/BotStatsCounter.java +@@ -0,0 +1,38 @@ ++package org.leavesmc.leaves.bot; ++ ++import com.mojang.datafixers.DataFixer; ++import net.minecraft.server.MinecraftServer; ++import net.minecraft.stats.ServerStatsCounter; ++import net.minecraft.stats.Stat; ++import net.minecraft.world.entity.player.Player; ++ ++import java.io.File; ++ ++public class BotStatsCounter extends ServerStatsCounter { ++ ++ private static final File UNKOWN_FILE = new File("BOT_STATS_REMOVE_THIS"); ++ ++ public BotStatsCounter(MinecraftServer server) { ++ super(server, UNKOWN_FILE); ++ } ++ ++ @Override ++ public void save() { ++ ++ } ++ ++ @Override ++ public void setValue(Player player, Stat stat, int value) { ++ ++ } ++ ++ @Override ++ public void parseLocal(DataFixer dataFixer, String json) { ++ ++ } ++ ++ @Override ++ public int getValue(Stat stat) { ++ return 0; ++ } ++} +\ No newline at end of file \ No newline at end of file