9
0
mirror of https://github.com/LeavesMC/Leaves.git synced 2025-12-19 14:59:32 +00:00
Files
LeavesMC/patches/server/0087-Replay-Mod-API.patch
2025-01-15 14:10:10 +08:00

1600 lines
69 KiB
Diff

From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: violetc <58360096+s-yh-china@users.noreply.github.com>
Date: Thu, 3 Aug 2023 20:36:38 +0800
Subject: [PATCH] Replay Mod API
This patch is Powered by ReplayMod(https://github.com/ReplayMod)
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 0724bd95143cb5dc69b5f1eb2e67ecd679e09a99..e1d66ac593c6da954ef02def16601dc401421b9f 100644
--- a/src/main/java/io/papermc/paper/plugin/manager/PaperEventManager.java
+++ b/src/main/java/io/papermc/paper/plugin/manager/PaperEventManager.java
@@ -42,7 +42,7 @@ class PaperEventManager {
}
// Leaves start - skip bot
- if (event instanceof org.bukkit.event.player.PlayerEvent playerEvent && playerEvent.getPlayer() instanceof org.leavesmc.leaves.entity.Bot) {
+ if (event instanceof org.bukkit.event.player.PlayerEvent playerEvent && (playerEvent.getPlayer() instanceof org.leavesmc.leaves.entity.Bot || playerEvent.getPlayer() instanceof org.leavesmc.leaves.entity.Photographer)) { // Leaves - and photographer
return;
}
// Leaves end - skip bot
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<EntitySelector> {
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 c8d39e6e1c570c9219f6066da273dc0130920519..96a074281d16a7f64058619da4b102f387c85b28 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<Entity> 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 ? List.of() : List.of(entityplayer);
} 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 ? List.of() : List.of(entityplayer);
} 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 List.of(entityplayer1);
}
}
@@ -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
} else {
object = new ObjectArrayList();
Iterator iterator = source.getServer().getPlayerList().getPlayers().iterator();
@@ -232,7 +236,7 @@ public class EntitySelector {
while (iterator.hasNext()) {
ServerPlayer entityplayer2 = (ServerPlayer) iterator.next();
- if (predicate.test(entityplayer2)) {
+ if (predicate.test(entityplayer2) && !(entityplayer2 instanceof org.leavesmc.leaves.replay.ServerPhotographer)) { // 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 21a8c087dfe93ff6570d17e2757fe4c2250b3245..94c239ccf8d5ebca84810509abd13db1badfe008 100644
--- a/src/main/java/net/minecraft/server/MinecraftServer.java
+++ b/src/main/java/net/minecraft/server/MinecraftServer.java
@@ -1776,7 +1776,7 @@ public abstract class MinecraftServer extends ReentrantBlockableEventLoop<TickTa
}
private ServerStatus.Players buildPlayerStatus() {
- List<ServerPlayer> list = this.playerList.getPlayers();
+ List<ServerPlayer> list = this.playerList.realPlayers; // Leaves - only real player
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 7f8eaf4590a29b147aa8c05cec919fd7744e74ba..4365a3d4b7fa0903ca6ff0581f72b57cdee762d4 100644
--- a/src/main/java/net/minecraft/server/PlayerAdvancements.java
+++ b/src/main/java/net/minecraft/server/PlayerAdvancements.java
@@ -227,7 +227,7 @@ public class PlayerAdvancements {
}
// Leaves end - spectator don't get advancement
// 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/src/main/java/net/minecraft/server/commands/OpCommand.java b/src/main/java/net/minecraft/server/commands/OpCommand.java
index e7b444a10b244828827b3c66c53465206ea8e0ec..030601fdfde2232a933b2ad7022e9909db3a3783 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 // Leaves - only real player
.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 f1a570b1eb25f2cbe83ce31a59ebfc26887add76..4ff490f7fcf574d0c42a2e1c2773ccf85a80fdb9 100644
--- a/src/main/java/net/minecraft/server/level/ServerLevel.java
+++ b/src/main/java/net/minecraft/server/level/ServerLevel.java
@@ -2720,7 +2720,7 @@ public class ServerLevel extends Level implements ServerEntityGetter, WorldGenLe
if (entity instanceof ServerPlayer entityplayer) {
ServerLevel.this.players.add(entityplayer);
// Leaves start - skip
- if (!(entityplayer instanceof org.leavesmc.leaves.bot.ServerBot)) {
+ if (!(entityplayer instanceof org.leavesmc.leaves.bot.ServerBot) && !(entityplayer instanceof org.leavesmc.leaves.replay.ServerPhotographer)) { // and photographer
ServerLevel.this.realPlayers.add(entityplayer);
}
// Leaves end - skip
@@ -2802,7 +2802,7 @@ public class ServerLevel extends Level implements ServerEntityGetter, WorldGenLe
if (entity instanceof ServerPlayer entityplayer) {
ServerLevel.this.players.remove(entityplayer);
// Leaves start - skip
- if (!(entityplayer instanceof org.leavesmc.leaves.bot.ServerBot)) {
+ if (!(entityplayer instanceof org.leavesmc.leaves.bot.ServerBot) && !(entityplayer instanceof org.leavesmc.leaves.replay.ServerPhotographer)) { // and photographer
ServerLevel.this.realPlayers.remove(entityplayer);
}
// Leaves end - skip
diff --git a/src/main/java/net/minecraft/server/players/PlayerList.java b/src/main/java/net/minecraft/server/players/PlayerList.java
index 0d64e38f37f471de62d093de092ec5c0975f10ee..13dea31c8232cdb08aff09627e84711d2ef0aa6b 100644
--- a/src/main/java/net/minecraft/server/players/PlayerList.java
+++ b/src/main/java/net/minecraft/server/players/PlayerList.java
@@ -125,6 +125,7 @@ import org.bukkit.event.player.PlayerSpawnChangeEvent;
// CraftBukkit end
import org.leavesmc.leaves.bot.ServerBot;
+import org.leavesmc.leaves.replay.ServerPhotographer;
public abstract class PlayerList {
@@ -157,6 +158,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 CraftServer cserver;
@@ -183,6 +185,119 @@ 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 ClientboundSetHeldSlotPacket(player.getInventory().selected));
+ 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);
+ 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 (org.leavesmc.leaves.LeavesConfig.modify.fakeplayer.enable) {
+ 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);
+ 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<MobEffectInstance> iterator = player.getActiveEffects().iterator();
+ while (iterator.hasNext()) {
+ MobEffectInstance mobeffect = iterator.next();
+ 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 api
+
public void placeNewPlayer(Connection connection, ServerPlayer player, CommonListenerCookie clientData) {
player.isRealPlayer = true; // Paper
player.loginTime = System.currentTimeMillis(); // Paper - Replace OfflinePlayer#getLastPlayed
@@ -331,6 +446,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.playersByName.put(player.getScoreboardName().toLowerCase(java.util.Locale.ROOT), player); // Spigot
this.playersByUUID.put(player.getUUID(), player);
// this.broadcastAll(ClientboundPlayerInfoUpdatePacket.createPlayerInitializing(List.of(entityplayer))); // CraftBukkit - replaced with loop below
@@ -401,6 +517,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
@@ -551,6 +673,43 @@ public abstract class PlayerList {
}
+ // Leaevs 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())));
@@ -633,6 +792,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.playersByName.remove(entityplayer.getScoreboardName().toLowerCase(java.util.Locale.ROOT)); // Spigot
this.server.getCustomBossEvents().onPlayerDisconnect(entityplayer);
UUID uuid = entityplayer.getUUID();
@@ -727,7 +887,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 && !this.canBypassPlayerLimit(gameprofile)) {
+ if (this.realPlayers.size() >= this.maxPlayers && !this.canBypassPlayerLimit(gameprofile)) { // Leaves - only real player
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 26ef09c86315c1125167af044323dbd3dbcfc6f0..ea4def367b645a442e7ee4fd8b1b2075b8604ccb 100644
--- a/src/main/java/org/bukkit/craftbukkit/CraftServer.java
+++ b/src/main/java/org/bukkit/craftbukkit/CraftServer.java
@@ -314,6 +314,7 @@ public final class CraftServer implements Server {
private final io.papermc.paper.potion.PaperPotionBrewer potionBrewer; // Paper - Custom Potion Mixes
public final io.papermc.paper.SparksFly spark; // Paper - spark
private final org.leavesmc.leaves.entity.CraftBotManager botManager; // Leaves
+ private final org.leavesmc.leaves.entity.CraftPhotographerManager photographerManager = new org.leavesmc.leaves.entity.CraftPhotographerManager(); // Leaves
// Paper start - Folia region threading API
private final io.papermc.paper.threadedregions.scheduler.FallbackRegionScheduler regionizedScheduler = new io.papermc.paper.threadedregions.scheduler.FallbackRegionScheduler();
@@ -408,7 +409,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();
@@ -3290,4 +3291,11 @@ public final class CraftServer implements Server {
return botManager;
}
// Leaves end - Bot API
+
+ // 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 dea387f418cd173980be2e6e24797b55f9f58409..3e230983586f044c3a5e021fc8e27f6b88978bf6 100644
--- a/src/main/java/org/bukkit/craftbukkit/entity/CraftEntity.java
+++ b/src/main/java/org/bukkit/craftbukkit/entity/CraftEntity.java
@@ -96,6 +96,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.CraftBot(server, bot); }
+ 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) {
diff --git a/src/main/java/org/bukkit/craftbukkit/entity/CraftPlayer.java b/src/main/java/org/bukkit/craftbukkit/entity/CraftPlayer.java
index d4e497961578bb693275cdf95915b60b2cc76eb7..d0132751fb057dc29e13ae3489beedb580225fa7 100644
--- a/src/main/java/org/bukkit/craftbukkit/entity/CraftPlayer.java
+++ b/src/main/java/org/bukkit/craftbukkit/entity/CraftPlayer.java
@@ -2259,7 +2259,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..fed2005cb711d0d15d5c87e5f0f7939c7a6a8ffa
--- /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() + '}';
+ }
+}
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..202e1694123ddfdf716b25d4eaef88d00ed2e3c7
--- /dev/null
+++ b/src/main/java/org/leavesmc/leaves/entity/CraftPhotographerManager.java
@@ -0,0 +1,84 @@
+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.bot.ServerBot;
+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;
+ }
+}
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..e67ff063b7f50b4bfdaaaeb88f225eb768d89623
--- /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);
+ }
+}
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..0e8cd7e878ec1294d6cb830a004eeefd8b82c415
--- /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 = "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<>();
+}
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..d1fb2f08f2d357c6551de7832eb3cf6980d44fb5
--- /dev/null
+++ b/src/main/java/org/leavesmc/leaves/replay/Recorder.java
@@ -0,0 +1,285 @@
+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.ClientboundServerLinksPacket;
+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.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.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.date = startTime;
+ metaData.mcversion = SharedConstants.getCurrentVersion().getName();
+
+ // TODO start event
+ this.savePacket(new ClientboundLoginFinishedPacket(photographer.getGameProfile()), ConnectionProtocol.LOGIN);
+ this.startConfiguration();
+
+ 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 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.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.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<Void> 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");
+ });
+ }
+ }
+}
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..8978fe0c7ed092334618e27892f940ee8c302fc7
--- /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 = "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;
+
+ private RecordWeather(Packet<?>... packets) {
+ this.packets = List.of(packets);
+ }
+
+ public List<Packet<?>> getPackets() {
+ return packets;
+ }
+ }
+}
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..8d96445fa45db5c1976c4f4d6811184810951be0
--- /dev/null
+++ b/src/main/java/org/leavesmc/leaves/replay/ReplayFile.java
@@ -0,0 +1,198 @@
+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.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.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<ConnectionProtocol, ProtocolInfo<?>> 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()))
+ );
+ }
+
+ @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 = new byte[buf.readableBytes()];
+ buf.readBytes(ret);
+ 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().getProtocolVersion();
+ 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) 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();
+ }
+}
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..1568f6928d5d4f38ca1919c6de6ec9bb9deb20b2
--- /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<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;
+ }
+ }
+}
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..d8f8f071c67cd0e29411d3f0f3b5e931abe86f65
--- /dev/null
+++ b/src/main/java/org/leavesmc/leaves/replay/ServerPhotographer.java
@@ -0,0 +1,222 @@
+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<ServerPhotographer> 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.gameMode = new ServerPhotographerGameMode(this);
+ 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 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.server.getPlayerList().removePhotographer(this);
+
+ LeavesLogger.LOGGER.info("Photographer " + createState.id + " removed");
+
+ 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 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) {
+ e.printStackTrace();
+ }
+ return null;
+ }
+ }
+}
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..c612215b0f1e8c3fae641e7a23c7cf7d165eca87
--- /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() {
+ }
+}
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..b0834f4b569b3e28ec7e026b3ff4236219498011
--- /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<UUID> {
+ @Override
+ public JsonElement serialize(@NotNull UUID src, Type typeOfSrc, JsonSerializationContext context) {
+ return new JsonPrimitive(src.toString());
+ }
+}