Add Replay Api (#86)

* feat: base replay api support

* fix an async bug
This commit is contained in:
Helvetica Volubi
2025-11-16 21:05:01 +08:00
committed by GitHub
parent dbcf764e76
commit feebf388f8
18 changed files with 2091 additions and 1 deletions
@@ -0,0 +1,562 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Helvetica Volubi <suisuroru@blue-millennium.fun>
Date: Sun, 16 Nov 2025 15:48:12 +0800
Subject: [PATCH] Leaves: Replay Mod API
Co-authored by: violetc <58360096+s-yh-china@users.noreply.github.com>
As a part of : Leaves (https://github.com/LeavesMC/Leaves) & ReplayMod (https://github.com/ReplayMod)
Licensed under: GPL-3.0 (https://www.gnu.org/licenses/gpl-3.0.html)
This patch is Powered by ReplayMod (https://github.com/ReplayMod)
diff --git a/net/minecraft/commands/CommandSourceStack.java b/net/minecraft/commands/CommandSourceStack.java
index 8c005a7b9480da7c124c5bd5bfccd5ca83abbcb1..a814a67f321662f05e2c5f81666f75e128bf4ea7 100644
--- a/net/minecraft/commands/CommandSourceStack.java
+++ b/net/minecraft/commands/CommandSourceStack.java
@@ -580,7 +580,7 @@ public class CommandSourceStack implements ExecutionCommandSource<CommandSourceS
@Override
public Collection<String> getOnlinePlayerNames() {
- return this.entity instanceof ServerPlayer sourcePlayer && !sourcePlayer.getBukkitEntity().hasPermission("paper.bypass-visibility.tab-completion") ? this.getServer().getPlayerList().getPlayers().stream().filter(serverPlayer -> sourcePlayer.getBukkitEntity().canSee(serverPlayer.getBukkitEntity())).map(serverPlayer -> serverPlayer.getGameProfile().getName()).toList() : Lists.newArrayList(this.server.getPlayerNames()); // Paper - Make CommandSourceStack respect hidden players
+ return this.entity instanceof ServerPlayer sourcePlayer && !(sourcePlayer instanceof org.leavesmc.leaves.replay.ServerPhotographer) && !sourcePlayer.getBukkitEntity().hasPermission("paper.bypass-visibility.tab-completion") ? this.getServer().getPlayerList().getPlayers().stream().filter(serverPlayer -> sourcePlayer.getBukkitEntity().canSee(serverPlayer.getBukkitEntity())).map(serverPlayer -> serverPlayer.getGameProfile().getName()).toList() : Lists.newArrayList(this.server.getPlayerNames()); // Paper - Make CommandSourceStack respect hidden players // Leaves - only real player
}
@Override
diff --git a/net/minecraft/commands/arguments/selector/EntitySelector.java b/net/minecraft/commands/arguments/selector/EntitySelector.java
index 514f8fbdeb776087608665c35de95294aadf5cf0..b75772897cabc3e7c59301d451685378fa55b6c3 100644
--- a/net/minecraft/commands/arguments/selector/EntitySelector.java
+++ b/net/minecraft/commands/arguments/selector/EntitySelector.java
@@ -128,11 +128,12 @@ public class EntitySelector {
return this.findPlayers(source);
} else if (this.playerName != null) {
ServerPlayer playerByName = source.getServer().getPlayerList().getPlayerByName(this.playerName);
+ playerByName = playerByName instanceof org.leavesmc.leaves.replay.ServerPhotographer ? null : playerByName; // Leaves - skip photographer
return playerByName == null ? List.of() : List.of(playerByName);
} else if (this.entityUUID != null) {
for (ServerLevel serverLevel : source.getServer().getAllLevels()) {
Entity entity = serverLevel.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);
}
@@ -146,7 +147,7 @@ public class EntitySelector {
AABB absoluteAabb = this.getAbsoluteAabb(vec3);
if (this.currentEntity) {
Predicate<Entity> predicate = this.getPredicate(vec3, absoluteAabb, 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<Entity> predicate = this.getPredicate(vec3, absoluteAabb, source.enabledFeatures());
List<Entity> list = new ObjectArrayList<>();
@@ -157,6 +158,7 @@ public class EntitySelector {
this.addEntities(list, serverLevel1, absoluteAabb, predicate);
}
}
+ list.removeIf(entity -> entity instanceof org.leavesmc.leaves.replay.ServerPhotographer); // Leaves - skip photographer
return this.sortAndLimit(vec3, list);
}
@@ -192,9 +194,11 @@ public class EntitySelector {
this.checkPermissions(source);
if (this.playerName != null) {
ServerPlayer playerByName = source.getServer().getPlayerList().getPlayerByName(this.playerName);
+ playerByName = playerByName instanceof org.leavesmc.leaves.replay.ServerPhotographer ? null : playerByName; // Leaves - skip photographer
return playerByName == null ? List.of() : List.of(playerByName);
} else if (this.entityUUID != null) {
ServerPlayer playerByName = source.getServer().getPlayerList().getPlayer(this.entityUUID);
+ playerByName = playerByName instanceof org.leavesmc.leaves.replay.ServerPhotographer ? null : playerByName; // Leaves - skip photographer
return playerByName == null ? List.of() : List.of(playerByName);
} else {
Vec3 vec3 = this.position.apply(source.getPosition());
@@ -206,11 +210,11 @@ public class EntitySelector {
int resultLimit = this.getResultLimit();
List<ServerPlayer> players;
if (this.isWorldLimited()) {
- players = source.getLevel().getPlayers(predicate, resultLimit);
+ players = source.getLevel().getPlayers((player -> !(player instanceof org.leavesmc.leaves.replay.ServerPhotographer) && predicate.test(player)), resultLimit); // Leaves - skip photographer
} else {
players = new ObjectArrayList<>();
- for (ServerPlayer serverPlayer1 : source.getServer().getPlayerList().getPlayers()) {
+ for (ServerPlayer serverPlayer1 : source.getServer().getPlayerList().realPlayers) { // Leaves - only real players
if (predicate.test(serverPlayer1)) {
players.add(serverPlayer1);
if (players.size() >= resultLimit) {
diff --git a/net/minecraft/server/MinecraftServer.java b/net/minecraft/server/MinecraftServer.java
index 81726e5782d25ff35a2bea8820a62786e46148b1..3c94631f468f4ee1b46de9981c17171e5901d745 100644
--- a/net/minecraft/server/MinecraftServer.java
+++ b/net/minecraft/server/MinecraftServer.java
@@ -1800,7 +1800,7 @@ public abstract class MinecraftServer extends ReentrantBlockableEventLoop<TickTa
}
private ServerStatus.Players buildPlayerStatus() {
- List<ServerPlayer> players = new java.util.ArrayList<>(this.playerList.getPlayers()); // Folia - region threading
+ List<ServerPlayer> players = new java.util.ArrayList<>(this.playerList.realPlayers); // Folia - region threading
int maxPlayers = this.getMaxPlayers();
if (this.hidesOnlinePlayers()) {
return new ServerStatus.Players(maxPlayers, players.size(), List.of());
@@ -2001,7 +2001,7 @@ public abstract class MinecraftServer extends ReentrantBlockableEventLoop<TickTa
@Override
public int getPlayerCount() {
- return this.playerList.getPlayerCount();
+ return this.playerList.realPlayers.size(); // Leaves - only real player
}
@Override
@@ -2415,7 +2415,7 @@ public abstract class MinecraftServer extends ReentrantBlockableEventLoop<TickTa
if (Thread.currentThread() != this.serverThread) return; // Paper
// Paper start - we don't need to save everything, just advancements
// this.getPlayerList().saveAll();
- for (final ServerPlayer player : this.getPlayerList().getPlayers()) {
+ for (final ServerPlayer player : this.getPlayerList().realPlayers) { // Leaves - only real players
player.getAdvancements().save();
}
// Paper end - we don't need to save everything, just advancements
@@ -2554,7 +2554,7 @@ public abstract class MinecraftServer extends ReentrantBlockableEventLoop<TickTa
if (!playerList.isUsingWhitelist()) return; // Paper - whitelist not enabled
UserWhiteList whiteList = playerList.getWhiteList();
- for (ServerPlayer serverPlayer : Lists.newArrayList(playerList.getPlayers())) {
+ for (ServerPlayer serverPlayer : Lists.newArrayList(playerList.realPlayers)) { // Leaves - only real player
if (!whiteList.isWhiteListed(serverPlayer.getGameProfile()) && !this.getPlayerList().isOp(serverPlayer.getGameProfile())) { // Paper - Fix kicking ops when whitelist is reloaded (MC-171420)
serverPlayer.connection.disconnect(net.kyori.adventure.text.Component.text(org.spigotmc.SpigotConfig.whitelistMessage), org.bukkit.event.player.PlayerKickEvent.Cause.WHITELIST); // Paper - use configurable message & kick event cause
}
diff --git a/net/minecraft/server/PlayerAdvancements.java b/net/minecraft/server/PlayerAdvancements.java
index 5c0e338dc1b0eb5724d10a73d6fc7975f9d2e5e5..0e7e4246837353a5936a6388f23b6b4b2ec1d5f3 100644
--- a/net/minecraft/server/PlayerAdvancements.java
+++ b/net/minecraft/server/PlayerAdvancements.java
@@ -168,7 +168,7 @@ public class PlayerAdvancements {
public boolean award(AdvancementHolder advancement, String criterionKey) {
// Leaves start - bot can't get advancement
- if (player instanceof org.leavesmc.leaves.bot.ServerBot) {
+ if (player instanceof org.leavesmc.leaves.bot.ServerBot || player instanceof org.leavesmc.leaves.replay.ServerPhotographer) { // Leaves - and photographer
return false;
}
// Leaves end - bot can't get advancement
diff --git a/net/minecraft/server/ServerScoreboard.java b/net/minecraft/server/ServerScoreboard.java
index fc1c377ad05edcefacd407671801ae605f3fc31a..5635f0940b4b70f27c8dfdc1aa666fcb7e26d55d 100644
--- a/net/minecraft/server/ServerScoreboard.java
+++ b/net/minecraft/server/ServerScoreboard.java
@@ -242,7 +242,7 @@ public class ServerScoreboard extends Scoreboard {
public void startTrackingObjective(Objective objective) {
List<Packet<?>> startTrackingPackets = this.getStartTrackingPackets(objective);
- for (ServerPlayer serverPlayer : this.server.getPlayerList().getPlayers()) {
+ for (ServerPlayer serverPlayer : this.server.getPlayerList().realPlayers) { // Leaves - only real players
if (serverPlayer.getBukkitEntity().getScoreboard().getHandle() != this) continue; // CraftBukkit - Only players on this board
for (Packet<?> packet : startTrackingPackets) {
serverPlayer.connection.send(packet);
@@ -268,7 +268,7 @@ public class ServerScoreboard extends Scoreboard {
public void stopTrackingObjective(Objective objective) {
List<Packet<?>> stopTrackingPackets = this.getStopTrackingPackets(objective);
- for (ServerPlayer serverPlayer : this.server.getPlayerList().getPlayers()) {
+ for (ServerPlayer serverPlayer : this.server.getPlayerList().realPlayers) { // Leaves - only real players
if (serverPlayer.getBukkitEntity().getScoreboard().getHandle() != this) continue; // CraftBukkit - Only players on this board
for (Packet<?> packet : stopTrackingPackets) {
serverPlayer.connection.send(packet);
diff --git a/net/minecraft/server/commands/DefaultGameModeCommands.java b/net/minecraft/server/commands/DefaultGameModeCommands.java
index c2ab47bb97c4fbbbc48a8ff0538ab8fd38532a88..2b7d11195f96e68a004e0e060e19d4004bb8a154 100644
--- a/net/minecraft/server/commands/DefaultGameModeCommands.java
+++ b/net/minecraft/server/commands/DefaultGameModeCommands.java
@@ -27,7 +27,7 @@ public class DefaultGameModeCommands {
server.setDefaultGameType(gamemode);
GameType forcedGameType = server.getForcedGameType();
if (forcedGameType != null) {
- for (ServerPlayer serverPlayer : server.getPlayerList().getPlayers()) {
+ for (ServerPlayer serverPlayer : server.getPlayerList().realPlayers) { // Leaves - only real players
serverPlayer.getBukkitEntity().taskScheduler.schedule((ServerPlayer player) -> { // Folia - region threading
// Paper start - Expand PlayerGameModeChangeEvent
org.bukkit.event.player.PlayerGameModeChangeEvent event = player.setGameMode(gamemode, org.bukkit.event.player.PlayerGameModeChangeEvent.Cause.DEFAULT_GAMEMODE, net.kyori.adventure.text.Component.empty()); // Folia - region threading
diff --git a/net/minecraft/server/commands/ListPlayersCommand.java b/net/minecraft/server/commands/ListPlayersCommand.java
index c6ae34f91b3629990294fc5e69237a1e600ef038..2109b0a2d4099e64c34cd1c45b83f72654d3b615 100644
--- a/net/minecraft/server/commands/ListPlayersCommand.java
+++ b/net/minecraft/server/commands/ListPlayersCommand.java
@@ -33,7 +33,7 @@ public class ListPlayersCommand {
private static int format(CommandSourceStack source, Function<ServerPlayer, Component> nameExtractor) {
PlayerList playerList = source.getServer().getPlayerList();
// CraftBukkit start
- List<ServerPlayer> playersTemp = playerList.getPlayers();
+ List<ServerPlayer> playersTemp = playerList.realPlayers;
if (source.getBukkitSender() instanceof org.bukkit.entity.Player) {
org.bukkit.entity.Player sender = (org.bukkit.entity.Player) source.getBukkitSender();
playersTemp = playersTemp.stream().filter((ep) -> sender.canSee(ep.getBukkitEntity())).collect(java.util.stream.Collectors.toList());
diff --git a/net/minecraft/server/commands/OpCommand.java b/net/minecraft/server/commands/OpCommand.java
index e6c7bbb023000b9de90c1256274ff5aba4a6478a..98f8804d58616592332e2a968282be6ad8903ea8 100644
--- a/net/minecraft/server/commands/OpCommand.java
+++ b/net/minecraft/server/commands/OpCommand.java
@@ -25,7 +25,7 @@ public class OpCommand {
(commandContext, suggestionsBuilder) -> {
PlayerList playerList = commandContext.getSource().getServer().getPlayerList();
return SharedSuggestionProvider.suggest(
- playerList.getPlayers()
+ playerList.realPlayers // Leaves - only real player
.stream()
.filter(serverPlayer -> !playerList.isOp(serverPlayer.getGameProfile()))
.map(serverPlayer -> serverPlayer.getGameProfile().getName()),
diff --git a/net/minecraft/server/commands/ParticleCommand.java b/net/minecraft/server/commands/ParticleCommand.java
index 33d96239f4b72a5587dc70f9602847a870d6d6a5..a83ce2cd112fca02cf3545f8c38e5cafae2c7c0e 100644
--- a/net/minecraft/server/commands/ParticleCommand.java
+++ b/net/minecraft/server/commands/ParticleCommand.java
@@ -36,7 +36,7 @@ public class ParticleCommand {
0.0F,
0,
false,
- commandContext.getSource().getServer().getPlayerList().getPlayers()
+ commandContext.getSource().getServer().getPlayerList().realPlayers // Leaves - only real player
)
)
.then(
@@ -50,7 +50,7 @@ public class ParticleCommand {
0.0F,
0,
false,
- context1.getSource().getServer().getPlayerList().getPlayers()
+ context1.getSource().getServer().getPlayerList().realPlayers // Leaves - only real player
)
)
.then(
@@ -68,7 +68,7 @@ public class ParticleCommand {
FloatArgumentType.getFloat(context1, "speed"),
IntegerArgumentType.getInteger(context1, "count"),
false,
- context1.getSource().getServer().getPlayerList().getPlayers()
+ context1.getSource().getServer().getPlayerList().realPlayers // Leaves - only real player
)
)
.then(
@@ -82,7 +82,7 @@ public class ParticleCommand {
FloatArgumentType.getFloat(context1, "speed"),
IntegerArgumentType.getInteger(context1, "count"),
true,
- context1.getSource().getServer().getPlayerList().getPlayers()
+ context1.getSource().getServer().getPlayerList().realPlayers // Leaves - only real player
)
)
.then(
@@ -112,7 +112,7 @@ public class ParticleCommand {
FloatArgumentType.getFloat(context1, "speed"),
IntegerArgumentType.getInteger(context1, "count"),
false,
- context1.getSource().getServer().getPlayerList().getPlayers()
+ context1.getSource().getServer().getPlayerList().realPlayers // Leaves - only real player
)
)
.then(
diff --git a/net/minecraft/server/commands/TeamMsgCommand.java b/net/minecraft/server/commands/TeamMsgCommand.java
index 134d7b1a9d5a5a47ebf4aabff110dde914cd6fe1..894dd1d048904b8a775416ea6cb3112215c567bc 100644
--- a/net/minecraft/server/commands/TeamMsgCommand.java
+++ b/net/minecraft/server/commands/TeamMsgCommand.java
@@ -40,7 +40,7 @@ public class TeamMsgCommand {
} else {
List<ServerPlayer> list = commandSourceStack.getServer()
.getPlayerList()
- .getPlayers()
+ .realPlayers // Leaves - only real players
.stream()
.filter(player -> player == entityOrException || player.getTeam() == team)
.toList();
diff --git a/net/minecraft/server/commands/WhitelistCommand.java b/net/minecraft/server/commands/WhitelistCommand.java
index 763a51d9a0859296eb4ea52b6879d6c7703db673..25b4fcf79dbf3e227451b3321d8fd38bab55ffe9 100644
--- a/net/minecraft/server/commands/WhitelistCommand.java
+++ b/net/minecraft/server/commands/WhitelistCommand.java
@@ -43,7 +43,7 @@ public class WhitelistCommand {
(commandContext, suggestionsBuilder) -> {
PlayerList playerList = commandContext.getSource().getServer().getPlayerList();
return SharedSuggestionProvider.suggest(
- playerList.getPlayers()
+ playerList.realPlayers // Leaves - only real player
.stream()
.filter(serverPlayer -> !playerList.getWhiteList().isWhiteListed(serverPlayer.getGameProfile()))
.map(serverPlayer -> serverPlayer.getGameProfile().getName()),
diff --git a/net/minecraft/server/gui/PlayerListComponent.java b/net/minecraft/server/gui/PlayerListComponent.java
index f5ba0c9a4c3f9eaa38eeb689de915c25c7165433..24bbc32bc17802edbd9cc14310fe8141c0ad85b0 100644
--- a/net/minecraft/server/gui/PlayerListComponent.java
+++ b/net/minecraft/server/gui/PlayerListComponent.java
@@ -17,8 +17,8 @@ public class PlayerListComponent extends JList<String> {
if (this.tickCount++ % 20 == 0) {
Vector<String> list = new Vector<>();
- for (int i = 0; i < this.server.getPlayerList().getPlayers().size(); i++) {
- list.add(this.server.getPlayerList().getPlayers().get(i).getGameProfile().getName());
+ for (int i = 0; i < this.server.getPlayerList().realPlayers.size(); i++) { // Leaves - only real players
+ list.add(this.server.getPlayerList().realPlayers.get(i).getGameProfile().getName()); // Leaves - only real players
}
this.setListData(list);
diff --git a/net/minecraft/server/level/ServerLevel.java b/net/minecraft/server/level/ServerLevel.java
index 5efe7d94a364cec1bd18c4d8d62c19075d52f3e8..a4ee2bcdb31f889c7edbbc422636782c0d5d0af6 100644
--- a/net/minecraft/server/level/ServerLevel.java
+++ b/net/minecraft/server/level/ServerLevel.java
@@ -2836,7 +2836,7 @@ public class ServerLevel extends Level implements ServerEntityGetter, WorldGenLe
if (entity instanceof ServerPlayer serverPlayer) {
ServerLevel.this.players.add(serverPlayer);
// Leaves start - skip
- if (!(serverPlayer instanceof org.leavesmc.leaves.bot.ServerBot)) {
+ if (!(serverPlayer instanceof org.leavesmc.leaves.bot.ServerBot) && !(serverPlayer instanceof org.leavesmc.leaves.replay.ServerPhotographer)) { // and photographer
ServerLevel.this.realPlayers.add(serverPlayer);
}
// Leaves end - skip
@@ -2928,7 +2928,7 @@ public class ServerLevel extends Level implements ServerEntityGetter, WorldGenLe
if (entity instanceof ServerPlayer serverPlayer) {
ServerLevel.this.players.remove(serverPlayer);
// Leaves start - skip
- if (!(serverPlayer instanceof org.leavesmc.leaves.bot.ServerBot)) {
+ if (!(serverPlayer instanceof org.leavesmc.leaves.bot.ServerBot) && !(serverPlayer instanceof org.leavesmc.leaves.replay.ServerPhotographer)) { // and photographer
ServerLevel.this.realPlayers.remove(serverPlayer);
}
// Leaves end - skip
diff --git a/net/minecraft/server/level/ServerPlayer.java b/net/minecraft/server/level/ServerPlayer.java
index 4b2dba8b30b72fb3acc00957b3e19d6fc178c99e..afd607df83fda3de6f53f55ad305c8f69fe6ddb6 100644
--- a/net/minecraft/server/level/ServerPlayer.java
+++ b/net/minecraft/server/level/ServerPlayer.java
@@ -194,7 +194,7 @@ import net.minecraft.world.scores.criteria.ObjectiveCriteria;
import org.slf4j.Logger;
public class ServerPlayer extends Player implements ca.spottedleaf.moonrise.patches.chunk_system.player.ChunkSystemServerPlayer { // Paper - rewrite chunk system
- private static final Logger LOGGER = LogUtils.getLogger();
+ protected static final Logger LOGGER = LogUtils.getLogger();
public static final long LAST_SAVE_ABSENT = Long.MIN_VALUE; public long lastSave = LAST_SAVE_ABSENT; // Paper // Folia - threaded regions - changed to nanoTime
private static final int NEUTRAL_MOB_DEATH_NOTIFICATION_RADII_XZ = 32;
private static final int NEUTRAL_MOB_DEATH_NOTIFICATION_RADII_Y = 10;
diff --git a/net/minecraft/server/players/PlayerList.java b/net/minecraft/server/players/PlayerList.java
index 11c1eb6995ec3aa896815efa59477096c39089d1..54e7ddf0b3949e02d2705ff7626010c947965938 100644
--- a/net/minecraft/server/players/PlayerList.java
+++ b/net/minecraft/server/players/PlayerList.java
@@ -130,6 +130,7 @@ public abstract class PlayerList {
private boolean allowCommandsForAllPlayers;
private static final boolean ALLOW_LOGOUTIVATOR = false;
private int sendAllPlayerInfoIn;
+ public final List<ServerPlayer> realPlayers = new java.util.concurrent.CopyOnWriteArrayList(); // Leaves - replay api
// CraftBukkit start
private org.bukkit.craftbukkit.CraftServer cserver;
@@ -213,6 +214,122 @@ public abstract class PlayerList {
abstract public void loadAndSaveFiles(); // Paper - fix converting txt to json file; moved from DedicatedPlayerList constructor
+ // Leaves start - replay mod api
+ public void placeNewPhotographer(Connection connection, org.leavesmc.leaves.replay.ServerPhotographer player, ServerLevel worldserver) {
+ 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 ClientboundSetHeldSlotPacket(player.getInventory().getSelectedSlot()));
+ RecipeManager craftingmanager = this.server.getRecipeManager();
+ playerconnection.send(new ClientboundUpdateRecipesPacket(craftingmanager.getSynchronizedItemProperties(), craftingmanager.getSynchronizedStonecutterRecipes()));
+
+ 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);
+ org.bukkit.craftbukkit.entity.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
+
+ // Leaves start - bot support
+ if (fun.bm.lophine.config.modules.function.FakeplayerConfig.enable) {
+ org.leavesmc.leaves.bot.ServerBot bot = this.server.getBotList().getBotByName(player.getScoreboardName());
+ if (bot != null) {
+ this.server.getBotList().removeBot(bot, org.leavesmc.leaves.event.bot.BotRemoveEvent.RemoveReason.INTERNAL, player.getBukkitEntity(), false);
+ }
+ this.server.getBotList().bots.forEach(bot1 -> {
+ bot1.sendPlayerInfo(player);
+ bot1.sendFakeDataIfNeed(player, true);
+ }); // Leaves - render bot
+ }
+ // Leaves end - bot support
+
+ final List<ServerPlayer> onlinePlayers = Lists.newArrayListWithExpectedSize(this.players.size() - 1);
+ final List<ServerPlayer> playerList = List.copyOf(this.players); // Lophine - copy to avoid ConcurrentModificationException
+ for (ServerPlayer serverPlayer : playerList) {
+ if (serverPlayer == player || !bukkitPlayer.canSee(serverPlayer.getBukkitEntity())) {
+ continue;
+ }
+
+ // Leaves start - skip photographer
+ if (serverPlayer instanceof org.leavesmc.leaves.replay.ServerPhotographer) {
+ continue;
+ }
+ // Leaves end - skip photographer
+
+ onlinePlayers.add(serverPlayer);
+ }
+ if (!onlinePlayers.isEmpty()) {
+ player.connection.send(ClientboundPlayerInfoUpdatePacket.createPlayerInitializing(onlinePlayers, player));
+ }
+
+ player.sentListPacket = true;
+ player.supressTrackerForLogin = false;
+ 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.level();
+ for (MobEffectInstance mobeffect : player.getActiveEffects()) {
+ playerconnection.send(new ClientboundUpdateMobEffectPacket(player.getId(), mobeffect, false));
+ }
+
+ if (player.isDeadOrDying()) {
+ net.minecraft.core.Holder<net.minecraft.world.level.biome.Biome> plains = worldserver1.registryAccess().lookupOrThrow(net.minecraft.core.registries.Registries.BIOME)
+ .getOrThrow(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(), null, null, false)
+ );
+ }
+ }
+ // Leaves end - replay mod api
+
public void loadSpawnForNewPlayer(Connection connection, ServerPlayer player, CommonListenerCookie cookie, org.apache.commons.lang3.mutable.MutableObject<net.minecraft.util.ProblemReporter.ScopedCollector> scopedCollectorStore, org.apache.commons.lang3.mutable.MutableObject<ValueInput> data, org.apache.commons.lang3.mutable.MutableObject<String> lastKnownName, ca.spottedleaf.concurrentutil.completable.CallbackCompletable<org.bukkit.Location> toComplete) { // Folia - region threading - rewrite login process
player.isRealPlayer = true; // Paper
player.loginTime = System.currentTimeMillis(); // Paper - Replace OfflinePlayer#getLastPlayed
@@ -398,6 +515,7 @@ public abstract class PlayerList {
// player.connection.send(ClientboundPlayerInfoUpdatePacket.createPlayerInitializing(this.players)); // CraftBukkit - replaced with loop below
this.players.add(player);
+ this.realPlayers.add(player); // Leaves - replay api
this.playersByName.put(player.getScoreboardName().toLowerCase(java.util.Locale.ROOT), player); // Spigot
this.playersByUUID.put(player.getUUID(), player);
// this.broadcastAll(ClientboundPlayerInfoUpdatePacket.createPlayerInitializing(List.of(player))); // CraftBukkit - replaced with loop below
@@ -601,6 +719,7 @@ public abstract class PlayerList {
}
protected void save(ServerPlayer player) {
+ if (player instanceof org.leavesmc.leaves.replay.ServerPhotographer) return; // Leaves - skip photographer
if (!player.getBukkitEntity().isPersistent()) return; // CraftBukkit
player.lastSave = System.nanoTime(); // Folia - region threading - changed to nanoTime tracking
this.playerIo.save(player);
@@ -615,6 +734,43 @@ public abstract class PlayerList {
}
}
+ // Leaves start - replay mod api
+ public void removePhotographer(org.leavesmc.leaves.replay.ServerPhotographer entityplayer) {
+ ServerLevel worldserver = entityplayer.level();
+
+ 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 @Nullable net.kyori.adventure.text.Component remove(ServerPlayer player) { // CraftBukkit - return string // Paper - return Component
// Paper start - Fix kick event leave message not being sent
return this.remove(player, net.kyori.adventure.text.Component.translatable("multiplayer.player.left", net.kyori.adventure.text.format.NamedTextColor.YELLOW, io.papermc.paper.configuration.GlobalConfiguration.get().messages.useDisplayNameInQuitMessage ? player.getBukkitEntity().displayName() : io.papermc.paper.adventure.PaperAdventure.asAdventure(player.getDisplayName())));
@@ -688,6 +844,7 @@ public abstract class PlayerList {
player.retireScheduler(); // Paper - Folia schedulers
player.getAdvancements().stopListening();
this.players.remove(player);
+ this.realPlayers.remove(player); // Leaves - replay api
this.playersByName.remove(player.getScoreboardName().toLowerCase(java.util.Locale.ROOT)); // Spigot
this.server.getCustomBossEvents().onPlayerDisconnect(player);
UUID uuid = player.getUUID();
@@ -1044,15 +1201,15 @@ public abstract class PlayerList {
}
public String[] getPlayerNamesArray() {
- List<ServerPlayer> players = new java.util.ArrayList<>(this.players); // Folia - region threading
- String[] strings = new String[this.players.size() + this.server.getBotList().bots.size()]; // Leaves - fakeplayer support
+ List<ServerPlayer> players = new java.util.ArrayList<>(this.realPlayers); // Folia - region threading
+ String[] strings = new String[players.size() + this.server.getBotList().bots.size()]; // Leaves - fakeplayer support, and skip photographer
- for (int i = 0; i < players.size(); i++) { // Folia - region threading
- strings[i] = players.get(i).getGameProfile().getName(); // Folia - region threading
+ for (int i = 0; i < players.size(); i++) { // Folia - region threading // Leaves - only real players
+ strings[i] = players.get(i).getGameProfile().getName(); // Folia - region threading // Leaves - only real players
}
// Leaves start - fakeplayer support
- for (int i = this.players.size(); i < strings.length; ++i) {
- strings[i] = this.server.getBotList().bots.get(i - this.players.size()).getGameProfile().getName();
+ for (int i = players.size(); i < strings.length; ++i) { // Leaves - only real players
+ strings[i] = this.server.getBotList().bots.get(i - players.size()).getGameProfile().getName(); // Leaves - only real players
}
// Leaves end - fakeplayer support
diff --git a/net/minecraft/world/entity/LivingEntity.java b/net/minecraft/world/entity/LivingEntity.java
index 12466e0294123a5922ee7694984d4d277a390ddd..205e2648569b7c18f488aa4d3d9ec944bb1babf4 100644
--- a/net/minecraft/world/entity/LivingEntity.java
+++ b/net/minecraft/world/entity/LivingEntity.java
@@ -257,7 +257,7 @@ public abstract class LivingEntity extends Entity implements Attackable, Waypoin
protected ItemStack useItem = ItemStack.EMPTY;
public int useItemRemaining;
protected int fallFlyTicks;
- private BlockPos lastPos;
+ protected BlockPos lastPos;
private Optional<BlockPos> lastClimbablePos = Optional.empty();
@Nullable
private DamageSource lastDamageSource;
@@ -0,0 +1,106 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Helvetica Volubi <suisuroru@blue-millennium.fun>
Date: Sun, 16 Nov 2025 15:55:47 +0800
Subject: [PATCH] Leaves: Replay Mod API
Co-authored by: violetc <58360096+s-yh-china@users.noreply.github.com>
As a part of : Leaves (https://github.com/LeavesMC/Leaves)
Licensed under: GPL-3.0 (https://www.gnu.org/licenses/gpl-3.0.html)
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 e1f456b31ffda9370b63e51f343847d6f9f62263..8a0f85241cc1530aacccc8b6ea24b9dec86fad66 100644
--- a/src/main/java/io/papermc/paper/plugin/manager/PaperEventManager.java
+++ b/src/main/java/io/papermc/paper/plugin/manager/PaperEventManager.java
@@ -57,7 +57,7 @@ class PaperEventManager {
}
// Leaves start - skip bot
- if (event instanceof org.bukkit.event.player.PlayerEvent playerEvent && playerEvent.getPlayer() instanceof org.leavesmc.leaves.entity.bot.Bot) {
+ if (event instanceof org.bukkit.event.player.PlayerEvent playerEvent && (playerEvent.getPlayer() instanceof org.leavesmc.leaves.entity.bot.Bot || playerEvent.getPlayer() instanceof org.leavesmc.leaves.entity.photographer.Photographer)) { // Leaves - and photographer
return;
}
// Leaves end - skip bot
diff --git a/src/main/java/org/bukkit/craftbukkit/CraftServer.java b/src/main/java/org/bukkit/craftbukkit/CraftServer.java
index 0a96721797b9bbc643f68677bbb1d10c3c89e815..7e2d870dc8b77c58270542f8916b9c9b88588809 100644
--- a/src/main/java/org/bukkit/craftbukkit/CraftServer.java
+++ b/src/main/java/org/bukkit/craftbukkit/CraftServer.java
@@ -317,6 +317,7 @@ public final class CraftServer implements Server {
public io.papermc.paper.SparksFly spark; // Paper - spark // Luminol - Force disable builtin spark
private final ServerConfiguration serverConfig = new PaperServerConfiguration();
private final org.leavesmc.leaves.entity.bot.CraftBotManager botManager; // Leaves
+ private final org.leavesmc.leaves.entity.photographer.CraftPhotographerManager photographerManager = new org.leavesmc.leaves.entity.photographer.CraftPhotographerManager(); // Leaves
// Paper start - Folia region threading API
private final io.papermc.paper.threadedregions.scheduler.FoliaRegionScheduler regionizedScheduler = new io.papermc.paper.threadedregions.scheduler.FoliaRegionScheduler(); // Folia - region threading
@@ -411,7 +412,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<ServerPlayer, CraftPlayer>() {
+ this.playerView = Collections.unmodifiableList(Lists.transform(playerList.realPlayers, new Function<ServerPlayer, CraftPlayer>() { // Leaves - replay api
@Override
public CraftPlayer apply(ServerPlayer player) {
return player.getBukkitEntity();
@@ -3333,4 +3334,11 @@ public final class CraftServer implements Server {
}
}
// Folia end - region TPS API
+
+ // Leaves start - replay mod api
+ @Override
+ public org.leavesmc.leaves.entity.photographer.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 8eb5d014d9ed688ffebaffb4ce0bb40896961a04..634bd74449c6991ad72330d10f42ddbb7f59c164 100644
--- a/src/main/java/org/bukkit/craftbukkit/entity/CraftEntity.java
+++ b/src/main/java/org/bukkit/craftbukkit/entity/CraftEntity.java
@@ -127,6 +127,7 @@ public abstract class CraftEntity implements org.bukkit.entity.Entity {
}
if (entity instanceof org.leavesmc.leaves.bot.ServerBot bot) { return new org.leavesmc.leaves.entity.bot.CraftBot(server, bot); }
+ if (entity instanceof org.leavesmc.leaves.replay.ServerPhotographer photographer) { return new org.leavesmc.leaves.entity.photographer.CraftPhotographer(server, photographer); }
// Special case complex part, since there is no extra entity type for them
if (entity instanceof EnderDragonPart complexPart) {
diff --git a/src/main/java/org/bukkit/craftbukkit/entity/CraftPlayer.java b/src/main/java/org/bukkit/craftbukkit/entity/CraftPlayer.java
index 7883b7973570b5f275a03c6aac4d3b4392e7fa6e..18d066c605f8027d9b1170b64ef1428fb969631b 100644
--- a/src/main/java/org/bukkit/craftbukkit/entity/CraftPlayer.java
+++ b/src/main/java/org/bukkit/craftbukkit/entity/CraftPlayer.java
@@ -2214,7 +2214,7 @@ public class CraftPlayer extends CraftHumanEntity implements Player, PluginMessa
@Override
public boolean canSee(Player player) {
- return this.canSee((org.bukkit.entity.Entity) player);
+ return !(player instanceof org.leavesmc.leaves.entity.photographer.Photographer) && this.canSee((org.bukkit.entity.Entity) player); // Leaves - skip photographer
}
@Override
diff --git a/src/main/java/org/bukkit/craftbukkit/util/CraftMagicNumbers.java b/src/main/java/org/bukkit/craftbukkit/util/CraftMagicNumbers.java
index 679529d94c16960527e5d76adb61c7e536f55d3e..503e11640bd2b6f7c854747723d26fe9cd67f490 100644
--- a/src/main/java/org/bukkit/craftbukkit/util/CraftMagicNumbers.java
+++ b/src/main/java/org/bukkit/craftbukkit/util/CraftMagicNumbers.java
@@ -338,7 +338,7 @@ public final class CraftMagicNumbers implements UnsafeValues {
Bukkit.getLogger().log(Level.SEVERE, "Error saving advancement " + key, ex);
}
- MinecraftServer.getServer().getPlayerList().getPlayers().forEach(player -> {
+ net.minecraft.server.MinecraftServer.getServer().getPlayerList().realPlayers.forEach(player -> { // Leaves - only real players
player.getAdvancements().reload(MinecraftServer.getServer().getAdvancements());
player.getAdvancements().flushDirty(player, false);
});
diff --git a/src/main/java/org/bukkit/craftbukkit/util/LazyPlayerSet.java b/src/main/java/org/bukkit/craftbukkit/util/LazyPlayerSet.java
index 25aae550dcdcef2df268d0dd99bdcc9bbd49fcf8..83af50de50a03e164d572f3c3466b6d0b42ed138 100644
--- a/src/main/java/org/bukkit/craftbukkit/util/LazyPlayerSet.java
+++ b/src/main/java/org/bukkit/craftbukkit/util/LazyPlayerSet.java
@@ -23,7 +23,7 @@ public class LazyPlayerSet extends LazyHashSet<Player> {
}
public static Set<Player> makePlayerSet(final MinecraftServer server) {
- List<ServerPlayer> players = server.getPlayerList().players;
+ List<ServerPlayer> players = server.getPlayerList().realPlayers; // Leaves - only real players
Set<Player> reference = new HashSet<>(players.size());
for (ServerPlayer player : players) {
reference.add(player.getBukkitEntity());
@@ -0,0 +1,90 @@
/*
* This file is part of Leaves (https://github.com/LeavesMC/Leaves)
*
* Leaves is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Leaves is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Leaves. If not, see <https://www.gnu.org/licenses/>.
*/
package org.leavesmc.leaves.entity.photographer;
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() + '}';
}
}
@@ -0,0 +1,99 @@
/*
* This file is part of Leaves (https://github.com/LeavesMC/Leaves)
*
* Leaves is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Leaves is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Leaves. If not, see <https://www.gnu.org/licenses/>.
*/
package org.leavesmc.leaves.entity.photographer;
import com.google.common.collect.Lists;
import org.bukkit.Location;
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<Photographer> 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<Photographer> getPhotographers() {
return photographerViews;
}
}
@@ -0,0 +1,63 @@
/*
* This file is part of Leaves (https://github.com/LeavesMC/Leaves)
*
* Leaves is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Leaves is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Leaves. If not, see <https://www.gnu.org/licenses/>.
*/
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);
}
}
@@ -0,0 +1,40 @@
/*
* This file is part of Leaves (https://github.com/LeavesMC/Leaves)
*
* Leaves is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Leaves is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Leaves. If not, see <https://www.gnu.org/licenses/>.
*/
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 = "Leaves";
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<UUID> players = new HashSet<>();
}
@@ -0,0 +1,277 @@
/*
* This file is part of Leaves (https://github.com/LeavesMC/Leaves)
*
* Leaves is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Leaves is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Leaves. If not, see <https://www.gnu.org/licenses/>.
*/
package org.leavesmc.leaves.replay;
import com.mojang.logging.LogUtils;
import com.mojang.serialization.DynamicOps;
import io.netty.channel.ChannelFutureListener;
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.protocol.BundlePacket;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.PacketFlow;
import net.minecraft.network.protocol.common.*;
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.*;
import net.minecraft.network.protocol.login.ClientboundLoginFinishedPacket;
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.entity.PositionMoveRotation;
import net.minecraft.world.flag.FeatureFlags;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import java.io.File;
import java.io.IOException;
import java.util.Collections;
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.atomic.AtomicBoolean;
public class Recorder extends Connection {
public static final Logger LOGGER = LogUtils.getClassLogger();
public final ExecutorService saveService = Executors.newSingleThreadExecutor();
private final ReplayFile replayFile;
private final ServerPhotographer photographer;
private final RecorderOption recorderOption;
private final RecordMetaData metaData;
private final AtomicBoolean isSaving = new AtomicBoolean(false);
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 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, saveService);
this.channel = new LocalChannel();
}
public void start() {
startTime = System.currentTimeMillis();
metaData.singleplayer = false;
metaData.serverName = recorderOption.serverName;
metaData.date = startTime;
metaData.mcversion = SharedConstants.getCurrentVersion().name();
// TODO start event
this.savePacket(new ClientboundLoginFinishedPacket(photographer.getGameProfile()), ConnectionProtocol.LOGIN);
this.startConfiguration();
savePacket(ClientboundPlayerPositionPacket.of(photographer.getId(), PositionMoveRotation.of(photographer), Collections.emptySet()));
if (recorderOption.forceWeather != null) {
setWeather(recorderOption.forceWeather);
}
}
public void startConfiguration() {
this.state = ConnectionProtocol.CONFIGURATION;
MinecraftServer server = MinecraftServer.getServer();
this.savePacket(new ClientboundCustomPayloadPacket(new BrandPayload(server.getServerModName())), ConnectionProtocol.CONFIGURATION);
this.savePacket(new ClientboundServerLinksPacket(server.serverLinks().untrust()), ConnectionProtocol.CONFIGURATION);
this.savePacket(new ClientboundUpdateEnabledFeaturesPacket(FeatureFlags.REGISTRY.toNames(server.getWorldData().enabledFeatures())), ConnectionProtocol.CONFIGURATION);
List<KnownPack> knownPackslist = server.getResourceManager().listPacks().flatMap((iresourcepack) -> iresourcepack.location().knownPackInfo().stream()).toList();
this.savePacket(new ClientboundSelectKnownPacks(knownPackslist), ConnectionProtocol.CONFIGURATION);
server.getServerResourcePack().ifPresent((info) -> this.savePacket(new ClientboundResourcePackPushPacket(
info.id(), info.url(), info.hash(), info.isRequired(), Optional.ofNullable(info.prompt())
)));
LayeredRegistryAccess<RegistryLayer> layeredregistryaccess = server.registries();
DynamicOps<Tag> dynamicOps = layeredregistryaccess.compositeAccess().createSerializationContext(NbtOps.INSTANCE);
RegistrySynchronization.packRegistries(dynamicOps, layeredregistryaccess.getAccessFrom(RegistryLayer.WORLDGEN), Set.copyOf(knownPackslist),
(key, entries) ->
this.savePacket(new ClientboundRegistryDataPacket(key, entries), ConnectionProtocol.CONFIGURATION)
);
this.savePacket(new ClientboundUpdateTagsPacket(TagNetworkSerialization.serializeTagsToNetwork(layeredregistryaccess)), ConnectionProtocol.CONFIGURATION);
this.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 ChannelFutureListener callbacks, boolean flush) {
if (stopped) {
return;
}
switch (packet) {
case BundlePacket<?> packet1 -> {
packet1.subPackets().forEach(subPacket -> send(subPacket, null));
return;
}
case ClientboundAddEntityPacket packet1 -> {
if (packet1.getType() == EntityType.PLAYER) {
metaData.players.add(packet1.getUUID());
saveMetadata();
}
}
case ClientboundDisconnectPacket ignored -> {
return;
}
case ClientboundTrackedWaypointPacket ignored -> {
return;
}
default -> {
}
}
if (recorderOption.forceDayTime != -1 && packet instanceof ClientboundSetTimePacket packet1) {
packet = new ClientboundSetTimePacket(packet1.dayTime(), 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.execute(() -> {
try {
replayFile.saveMetaData(metaData);
} catch (IOException e) {
LOGGER.error("Error saving metadata", e);
}
});
}
private void savePacket(Packet<?> packet) {
this.savePacket(packet, state);
}
private void savePacket(Packet<?> packet, final ConnectionProtocol protocol) {
final long timestamp = getCurrentTimeAndUpdate();
try {
replayFile.savePacket(timestamp, packet, protocol);
} catch (Exception e) {
LOGGER.error("Error saving packet on thread {}. Are you using some plugin that modify data asynchronously?", Thread.currentThread(), e);
}
}
public boolean isSaved() {
return isSaved;
}
public CompletableFuture<Void> saveRecording(File dest, boolean save) {
if (!isSaving.compareAndSet(false, true)) {
LOGGER.error("saveRecording() called twice");
return CompletableFuture.failedFuture(new IllegalStateException("saveRecording() called twice"));
}
isSaved = true;
metaData.duration = (int) lastPacket;
return CompletableFuture.runAsync(() -> {
try {
replayFile.saveMetaData(metaData);
if (save) {
replayFile.closeAndSave(dest);
} else {
replayFile.closeNotSave();
}
} catch (IOException e) {
throw new CompletionException(e);
}
}, saveService);
}
}
@@ -0,0 +1,74 @@
/*
* This file is part of Leaves (https://github.com/LeavesMC/Leaves)
*
* Leaves is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Leaves is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Leaves. If not, see <https://www.gnu.org/licenses/>.
*/
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 = "Leaves";
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<Packet<?>> packets;
RecordWeather(Packet<?>... packets) {
this.packets = List.of(packets);
}
public List<Packet<?>> getPackets() {
return packets;
}
}
}
@@ -0,0 +1,217 @@
/*
* This file is part of Leaves (https://github.com/LeavesMC/Leaves)
*
* Leaves is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Leaves is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Leaves. If not, see <https://www.gnu.org/licenses/>.
*/
package org.leavesmc.leaves.replay;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufUtil;
import io.netty.buffer.Unpooled;
import net.minecraft.SharedConstants;
import net.minecraft.network.ConnectionProtocol;
import net.minecraft.network.ProtocolInfo;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.configuration.ConfigurationProtocols;
import net.minecraft.network.protocol.game.GameProtocols;
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.protocol.core.ProtocolUtils;
import org.leavesmc.leaves.util.UUIDSerializer;
import java.io.*;
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.concurrent.ExecutorService;
import java.util.zip.CRC32;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static org.leavesmc.leaves.replay.Recorder.LOGGER;
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<ConnectionProtocol, ProtocolInfo<?>> protocols;
private final ExecutorService saveService;
public ReplayFile(@NotNull File name, ExecutorService saveService) throws IOException {
this.saveService = saveService;
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()))
);
}
@SuppressWarnings({"rawtypes", "unchecked"})
private byte @NotNull [] getPacketBytes(Packet packet, ConnectionProtocol state) {
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 = ByteBufUtil.getBytes(buf);
buf.release();
return ret;
}
public void saveMarkers(List<ReplayMarker> 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().protocolVersion();
data.generator = ProtocolUtils.buildProtocolVersion("replay");
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) {
byte[] data = getPacketBytes(packet, protocol);
saveService.execute(() -> {
try {
packetStream.writeInt((int) timestamp);
packetStream.writeInt(data.length);
packetStream.write(data);
} catch (Exception e) {
LOGGER.error("Error saving packet", e);
}
});
}
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();
}
}
@@ -0,0 +1,56 @@
/*
* This file is part of Leaves (https://github.com/LeavesMC/Leaves)
*
* Leaves is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Leaves is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Leaves. If not, see <https://www.gnu.org/licenses/>.
*/
package org.leavesmc.leaves.replay;
import com.google.gson.*;
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<ReplayMarker> {
@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;
}
}
}
@@ -0,0 +1,258 @@
/*
* This file is part of Leaves (https://github.com/LeavesMC/Leaves)
*
* Leaves is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Leaves is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Leaves. If not, see <https://www.gnu.org/licenses/>.
*/
package org.leavesmc.leaves.replay;
import ca.spottedleaf.moonrise.common.util.TickThread;
import com.mojang.authlib.GameProfile;
import io.papermc.paper.threadedregions.RegionizedServer;
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.bot.BotStatsCounter;
import org.leavesmc.leaves.entity.photographer.CraftPhotographer;
import org.leavesmc.leaves.entity.photographer.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<ServerPhotographer> photographers = new CopyOnWriteArrayList<>();
public PhotographerCreateState createState;
private ServerPlayer followPlayer;
private Recorder recorder;
private File saveFile;
private Vec3 lastPosVec3;
private final ServerStatsCounter stats;
private ServerPhotographer(MinecraftServer server, ServerLevel world, GameProfile profile) {
super(server, world, profile, ClientInformation.createDefault());
this.gameMode = new ServerPhotographerGameMode(this);
this.followPlayer = null;
this.stats = new BotStatsCounter(server);
this.lastPosVec3 = 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.absSnapTo(state.loc.x(), state.loc.y(), state.loc.z(), state.loc.getYaw(), state.loc.getPitch());
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();
if (TickThread.isTickThreadFor(world, state.loc.x(), state.loc.z())) {
placePhotographer(server, photographer, world, state);
} else {
RegionizedServer.getInstance().taskQueue.queueTickTaskQueue(
world, net.minecraft.util.Mth.floor(state.loc.getX()) >> 4, net.minecraft.util.Mth.floor(state.loc.getZ()) >> 4,
() -> placePhotographer(server, photographer, world, state),
ca.spottedleaf.concurrentutil.util.Priority.HIGHER);
}
photographers.add(photographer);
// TODO record distance
return photographer;
}
private static void placePhotographer(MinecraftServer server, ServerPhotographer photographer, ServerLevel world, @NotNull PhotographerCreateState state) {
server.getPlayerList().placeNewPhotographer(photographer.recorder, photographer, world);
photographer.level().chunkSource.move(photographer);
photographer.setInvisible(true);
LOGGER.info("Photographer {} created", state.id);
}
@Override
public void tick() {
this.lastPos = this.blockPosition();
super.tick();
if (this.tickCount % 10 == 0) {
connection.resetPosition();
this.level().chunkSource.move(this);
}
if (this.followPlayer != null) {
if (this.getCamera() == this || this.getCamera().level() != this.level()) {
this.getBukkitPlayer().teleportAsync(this.getCamera().getBukkitEntity().getLocation());
this.setCamera(followPlayer);
}
if (lastPosVec3.distanceToSqr(this.position()) > 1024D) {
this.getBukkitPlayer().teleportAsync(this.getCamera().getBukkitEntity().getLocation());
}
}
lastPosVec3 = this.position();
}
@Override
public void die(@NotNull DamageSource damageSource) {
super.die(damageSource);
remove(true);
}
@Override
public boolean isInvulnerableTo(@NotNull ServerLevel world, @NotNull DamageSource damageSource) {
return true;
}
@Override
public boolean hurtServer(@NotNull ServerLevel world, @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.getServer().getPlayerList().removePhotographer(this);
LOGGER.info("Photographer {} removed", createState.id);
if (!recorder.isSaved()) {
CompletableFuture<Void> future = recorder.saveRecording(saveFile, save);
if (!async) {
future.join();
}
}
}
public void setFollowPlayer(ServerPlayer followPlayer) {
this.setCamera(followPlayer);
this.followPlayer = followPlayer;
}
public ServerPlayer getFollowPlayer() {
return 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<ServerPhotographer> 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) {
LOGGER.error("Error happened when create photographer: ", e);
}
return null;
}
}
}
@@ -0,0 +1,52 @@
/*
* This file is part of Leaves (https://github.com/LeavesMC/Leaves)
*
* Leaves is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Leaves is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Leaves. If not, see <https://www.gnu.org/licenses/>.
*/
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() {
}
}
@@ -0,0 +1,34 @@
/*
* This file is part of Leaves (https://github.com/LeavesMC/Leaves)
*
* Leaves is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Leaves is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Leaves. If not, see <https://www.gnu.org/licenses/>.
*/
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<UUID> {
@Override
public JsonElement serialize(@NotNull UUID src, Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive(src.toString());
}
}