diff --git a/README.md b/README.md index 4d13b93..08c1c8c 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,13 @@ -# Server Replay +# Server Replay + +**English** | [中文](./README_cn.md) A completely server-side implementation of the replay mod, this mod allows you to record multiple players that are online, or chunk areas, on a server at a time. This will produce replay files which can then be used with the replay mod for rendering. +[![Modrinth download](https://img.shields.io/modrinth/dt/server-replay?label=Download%20on%20Modrinth&style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEuNSIgY2xpcC1ydWxlPSJldmVub2RkIiB2aWV3Qm94PSIwIDAgMTAwIDEwMCI+PHBhdGggZmlsbD0ibm9uZSIgZD0iTTAgMGgxMDB2MTAwSDB6Ii8+PGNsaXBQYXRoIGlkPSJhIj48cGF0aCBkPSJNMTAwIDBIMHYxMDBoMTAwVjBaTTQ2LjAwMiA0OS4yOTVsLjA3NiAxLjc1NyA4LjgzIDMyLjk2MyA3Ljg0My0yLjEwMi04LjU5Ni0zMi4wOTQgNS44MDQtMzIuOTMyLTcuOTk3LTEuNDEtNS45NiAzMy44MThaIi8+PC9jbGlwUGF0aD48ZyBjbGlwLXBhdGg9InVybCgjYSkiPjxwYXRoIGZpbGw9IiMwMGQ4NDUiIGQ9Ik01MCAxN2MxOC4yMDcgMCAzMi45ODggMTQuNzg3IDMyLjk4OCAzM1M2OC4yMDcgODMgNTAgODMgMTcuMDEyIDY4LjIxMyAxNy4wMTIgNTAgMzEuNzkzIDE3IDUwIDE3Wm0wIDljMTMuMjQgMCAyMy45ODggMTAuNzU1IDIzLjk4OCAyNFM2My4yNCA3NCA1MCA3NCAyNi4wMTIgNjMuMjQ1IDI2LjAxMiA1MCAzNi43NiAyNiA1MCAyNloiLz48L2c+PGNsaXBQYXRoIGlkPSJiIj48cGF0aCBkPSJNMCAwdjQ2aDUwbDEuMzY4LjI0MUw5OSA2My41NzhsLTIuNzM2IDcuNTE3TDQ5LjI5NSA1NEgwdjQ2aDEwMFYwSDBaIi8+PC9jbGlwUGF0aD48ZyBjbGlwLXBhdGg9InVybCgjYikiPjxwYXRoIGZpbGw9IiMwMGQ4NDUiIGQ9Ik01MCAwYzI3LjU5NiAwIDUwIDIyLjQwNCA1MCA1MHMtMjIuNDA0IDUwLTUwIDUwUzAgNzcuNTk2IDAgNTAgMjIuNDA0IDAgNTAgMFptMCA5YzIyLjYyOSAwIDQxIDE4LjM3MSA0MSA0MVM3Mi42MjkgOTEgNTAgOTEgOSA3Mi42MjkgOSA1MCAyNy4zNzEgOSA1MCA5WiIvPjwvZz48Y2xpcFBhdGggaWQ9ImMiPjxwYXRoIGQ9Ik01MCAwYzI3LjU5NiAwIDUwIDIyLjQwNCA1MCA1MHMtMjIuNDA0IDUwLTUwIDUwUzAgNzcuNTk2IDAgNTAgMjIuNDA0IDAgNTAgMFptMCAzOS41NDljNS43NjggMCAxMC40NTEgNC42ODMgMTAuNDUxIDEwLjQ1MSAwIDUuNzY4LTQuNjgzIDEwLjQ1MS0xMC40NTEgMTAuNDUxLTUuNzY4IDAtMTAuNDUxLTQuNjgzLTEwLjQ1MS0xMC40NTEgMC01Ljc2OCA0LjY4My0xMC40NTEgMTAuNDUxLTEwLjQ1MVoiLz48L2NsaXBQYXRoPjxnIGNsaXAtcGF0aD0idXJsKCNjKSI+PHBhdGggZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDBkODQ1IiBzdHJva2Utd2lkdGg9IjkiIGQ9Ik01MCA1MCA1LjE3MSA3NS44ODIiLz48L2c+PGNsaXBQYXRoIGlkPSJkIj48cGF0aCBkPSJNNTAgMGMyNy41OTYgMCA1MCAyMi40MDQgNTAgNTBzLTIyLjQwNCA1MC01MCA1MFMwIDc3LjU5NiAwIDUwIDIyLjQwNCAwIDUwIDBabTAgMjUuMzZjMTMuNTk5IDAgMjQuNjQgMTEuMDQxIDI0LjY0IDI0LjY0UzYzLjU5OSA3NC42NCA1MCA3NC42NCAyNS4zNiA2My41OTkgMjUuMzYgNTAgMzYuNDAxIDI1LjM2IDUwIDI1LjM2WiIvPjwvY2xpcFBhdGg+PGcgY2xpcC1wYXRoPSJ1cmwoI2QpIj48cGF0aCBmaWxsPSJub25lIiBzdHJva2U9IiMwMGQ4NDUiIHN0cm9rZS13aWR0aD0iOSIgZD0ibTUwIDUwIDUwLTEzLjM5NyIvPjwvZz48cGF0aCBmaWxsPSIjMDBkODQ1IiBkPSJNMzcuMjQzIDUyLjc0NiAzNSA0NWw4LTkgMTEtMyA0IDQtNiA2LTQgMS0zIDQgMS4xMiA0LjI0IDMuMTEyIDMuMDkgNC45NjQtLjU5OCAyLjg2Ni0yLjk2NCA4LjE5Ni0yLjE5NiAxLjQ2NCA1LjQ2NC04LjA5OCA4LjAyNkw0Ni44MyA2NS40OWwtNS41ODctNS44MTUtNC02LjkyOVoiLz48L3N2Zz4=)](https://modrinth.com/mod/server-replay) + ### Why Server-Side? Compared to the client [Replay Mod](https://www.replaymod.com/) recording @@ -206,7 +210,7 @@ After you boot the server a new file will be generated in the path | `"optimize_entity_packets"` |

This reduces the file size by letting the client handle the logic for some entities, e.g. projectiles and tnt. This may cause some inconsistencies however it will likely be negligible.

| | `"record_voice_chat"` |

This enables support for recording voice-chat if you have the [simple-voice-chat](https://github.com/henkelmax/simple-voice-chat) mod installed, when watching back the replay you must have [replay-voice-chat](https://github.com/henkelmax/replay-voice-chat) installed.

| | `"player_predicate"` |

The predicate for recording players automatically, more information in the [Predicates](#predicates-config) section.

| -| `"chunks"` |

The list of chunks to automatically record when the server stars, more information in the [Chunks](#chunks-config) section.

| +| `"chunks"` |

The list of chunks to automatically record when the server starts, more information in the [Chunks](#chunks-config) section.

| ### Chunks Config @@ -386,7 +390,7 @@ repositories { dependencies { // For the most recent version, use the latest commit hash - modImplementation("com.github.Senseiwells:ServerReplay:bf89353cd2") + modImplementation("com.github.senseiwells:ServerReplay:da3b0e55ce") } ``` @@ -399,7 +403,7 @@ class ExampleMod: ModInitializer { if (!PlayerRecorders.has(player)) { if (player.level().dimension() == Level.END) { val recorder = PlayerRecorders.create(player) - recorder.tryStart(log = true) + recorder.start(log = true) } } else { val existing = PlayerRecorders.get(player)!! @@ -417,8 +421,37 @@ class ExampleMod: ModInitializer { ChunkPos(5, 5), "Named" ) - recorder.tryStart(log = false) + recorder.start(log = false) } } } +``` + +If you want to add support to your mod for ServerReplay you can create a plugin: +```kotlin +class MyServerReplayPlugin: ServerReplayPlugin { + override fun onPlayerReplayStart(recorder: PlayerRecorder) { + // Send any additional packets for players here + } + + + override fun onChunkReplayStart(recorder: ChunkRecorder) { + // Send any additional packets for chunks here + } +} +``` +Then you simply register this in your `fabric.mod.json`: +```json5 +{ + // ... + "entrypoints": { + "main": [ + // ... + ], + "server_replay": [ + "com.example.MyServerReplayPlugin" + ] + } + // ... +} ``` \ No newline at end of file diff --git a/README_cn.md b/README_cn.md new file mode 100644 index 0000000..77deb3f --- /dev/null +++ b/README_cn.md @@ -0,0 +1,424 @@ +# Server Replay + +[English](./README.md) | **中文** + +*译 / tanh_Heng* + +一个只在服务端生效的replay模组,它允许你在服务器上一次性同时录制多个在线玩家或某一片区块,然后产生一个可以被客户端replay模组使用的录制文件用来渲染。 + +[![Modrinth download](https://img.shields.io/modrinth/dt/server-replay?label=Download%20on%20Modrinth&style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEuNSIgY2xpcC1ydWxlPSJldmVub2RkIiB2aWV3Qm94PSIwIDAgMTAwIDEwMCI+PHBhdGggZmlsbD0ibm9uZSIgZD0iTTAgMGgxMDB2MTAwSDB6Ii8+PGNsaXBQYXRoIGlkPSJhIj48cGF0aCBkPSJNMTAwIDBIMHYxMDBoMTAwVjBaTTQ2LjAwMiA0OS4yOTVsLjA3NiAxLjc1NyA4LjgzIDMyLjk2MyA3Ljg0My0yLjEwMi04LjU5Ni0zMi4wOTQgNS44MDQtMzIuOTMyLTcuOTk3LTEuNDEtNS45NiAzMy44MThaIi8+PC9jbGlwUGF0aD48ZyBjbGlwLXBhdGg9InVybCgjYSkiPjxwYXRoIGZpbGw9IiMwMGQ4NDUiIGQ9Ik01MCAxN2MxOC4yMDcgMCAzMi45ODggMTQuNzg3IDMyLjk4OCAzM1M2OC4yMDcgODMgNTAgODMgMTcuMDEyIDY4LjIxMyAxNy4wMTIgNTAgMzEuNzkzIDE3IDUwIDE3Wm0wIDljMTMuMjQgMCAyMy45ODggMTAuNzU1IDIzLjk4OCAyNFM2My4yNCA3NCA1MCA3NCAyNi4wMTIgNjMuMjQ1IDI2LjAxMiA1MCAzNi43NiAyNiA1MCAyNloiLz48L2c+PGNsaXBQYXRoIGlkPSJiIj48cGF0aCBkPSJNMCAwdjQ2aDUwbDEuMzY4LjI0MUw5OSA2My41NzhsLTIuNzM2IDcuNTE3TDQ5LjI5NSA1NEgwdjQ2aDEwMFYwSDBaIi8+PC9jbGlwUGF0aD48ZyBjbGlwLXBhdGg9InVybCgjYikiPjxwYXRoIGZpbGw9IiMwMGQ4NDUiIGQ9Ik01MCAwYzI3LjU5NiAwIDUwIDIyLjQwNCA1MCA1MHMtMjIuNDA0IDUwLTUwIDUwUzAgNzcuNTk2IDAgNTAgMjIuNDA0IDAgNTAgMFptMCA5YzIyLjYyOSAwIDQxIDE4LjM3MSA0MSA0MVM3Mi42MjkgOTEgNTAgOTEgOSA3Mi42MjkgOSA1MCAyNy4zNzEgOSA1MCA5WiIvPjwvZz48Y2xpcFBhdGggaWQ9ImMiPjxwYXRoIGQ9Ik01MCAwYzI3LjU5NiAwIDUwIDIyLjQwNCA1MCA1MHMtMjIuNDA0IDUwLTUwIDUwUzAgNzcuNTk2IDAgNTAgMjIuNDA0IDAgNTAgMFptMCAzOS41NDljNS43NjggMCAxMC40NTEgNC42ODMgMTAuNDUxIDEwLjQ1MSAwIDUuNzY4LTQuNjgzIDEwLjQ1MS0xMC40NTEgMTAuNDUxLTUuNzY4IDAtMTAuNDUxLTQuNjgzLTEwLjQ1MS0xMC40NTEgMC01Ljc2OCA0LjY4My0xMC40NTEgMTAuNDUxLTEwLjQ1MVoiLz48L2NsaXBQYXRoPjxnIGNsaXAtcGF0aD0idXJsKCNjKSI+PHBhdGggZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDBkODQ1IiBzdHJva2Utd2lkdGg9IjkiIGQ9Ik01MCA1MCA1LjE3MSA3NS44ODIiLz48L2c+PGNsaXBQYXRoIGlkPSJkIj48cGF0aCBkPSJNNTAgMGMyNy41OTYgMCA1MCAyMi40MDQgNTAgNTBzLTIyLjQwNCA1MC01MCA1MFMwIDc3LjU5NiAwIDUwIDIyLjQwNCAwIDUwIDBabTAgMjUuMzZjMTMuNTk5IDAgMjQuNjQgMTEuMDQxIDI0LjY0IDI0LjY0UzYzLjU5OSA3NC42NCA1MCA3NC42NCAyNS4zNiA2My41OTkgMjUuMzYgNTAgMzYuNDAxIDI1LjM2IDUwIDI1LjM2WiIvPjwvY2xpcFBhdGg+PGcgY2xpcC1wYXRoPSJ1cmwoI2QpIj48cGF0aCBmaWxsPSJub25lIiBzdHJva2U9IiMwMGQ4NDUiIHN0cm9rZS13aWR0aD0iOSIgZD0ibTUwIDUwIDUwLTEzLjM5NyIvPjwvZz48cGF0aCBmaWxsPSIjMDBkODQ1IiBkPSJNMzcuMjQzIDUyLjc0NiAzNSA0NWw4LTkgMTEtMyA0IDQtNiA2LTQgMS0zIDQgMS4xMiA0LjI0IDMuMTEyIDMuMDkgNC45NjQtLjU5OCAyLjg2Ni0yLjk2NCA4LjE5Ni0yLjE5NiAxLjQ2NCA1LjQ2NC04LjA5OCA4LjAyNkw0Ni44MyA2NS40OWwtNS41ODctNS44MTUtNC02LjkyOVoiLz48L3N2Zz4=)](https://modrinth.com/mod/server-replay) + +## 为什么选择服务端? + +与客户端的Replay Mod相比,在服务端录制有着许多好处: + +* 能够录制固定的区块 + + + 你可以指定确切的区块大小(不受服务端视距的影响) + + + 这些记录的区块可以在不影响replay的情况下卸载 + + - 区块不会在卸载与加载的过程中闪烁 + + - 这些区块也不会被录制器加载(不像PCRC一样会手动加载区块) + + - 录制器可以跳过区块被卸载的时间 + + - *这里指的是录制器不会手动加载那些本应被卸载的区块(只有当区块被加载时才会进行记录),并且区块的加载与卸载不会对记录产生影响。————译者注* + +* 能够录制玩家 + + + 玩家不需要安装replay模组 *(包括此模组和客户端replay。 ————译者注)* + + + 你可以一次性录制多个机位 + + + 录制可以依靠设置项自动进行 + +* 录制行为可以在任何时间被管理员开启(或其他有权限的任何人) + +但是这仍然有一些缺点和已知问题: + +* 一些东西将不会被记录,比如boss栏 + +* 要预览录制的replay,你必须从服务端下载录制文件。 + +* 录制玩家的内容可能不会和客户端replay的录制内容100%一致 + +* 模组兼容问题。模组可能会和其他一些修改了网络的模组冲突,如果你遇到任何兼容性问题请提交一个issue + +## 用法 + +此模组需要fabric launcher、fabric-api和fabric-kotlin + +有以下两种方法来在服务端进行录制:你可以对模组进行设置来从玩家的视角跟随并记录玩家;或者,你可以录制固定的一片区块。 + +### 快速开始 + +这部分的文档将会简短地引导你进行基础的构建,同时包含了一些重要的信息。 + +#### 玩家 + +要在服务端记录玩家,你可以运行`/replay start players `,例如: + +``` +/replay start players senseiwells +/replay start players @a +/replay start players @a[gamemode=survival] +``` + +玩家录制将会和玩家绑定,并且按服务端视距进行录制。 + +如果玩家退出了服务器或者服务端停止了,录制将会自动停止并保存。 + +同时,如果你想要手动停止录制,你可以运行`/replay stop players <是否保存?>`。这个指令还可以停止录制并取消保存,例如: + +``` +/replay stop players senseiwells +/replay stop players @r +/replay stop players senseiwells false +``` + +此录制之后将会被保存在`"player_recording_path"`所指定的文件夹的玩家uuid目录下。在默认情况下,它将被保存在`./recordings/players//.mcpr`。 + +此文件然后可以被放在客户端的`./replay_recordings`文件夹下,并用客户端replay模组打开。 + +**重要提示:** 如果你要记录carpet的假人,你大概率需要在设置中打开`"fix_carpet_bot_view_distance"`,否则只有假人周围的2个区块的距离内会被记录。 + +#### 区块 + +> **重要提示:** 对于模组录制的指定区域的区块,Minecraft客户端并不会渲染最边缘的那些区块。所以要记录一片**可见的**区块,你必须在边缘添加一个区块 *(当选取时)*,例如录制一片从`-5, -5`到`5, 5`的可见区域,你必须从`-6, 6`到`6, 6`进行录制。 + +要记录服务端的一块区域内的区块,你可以运行`/replay start chunks from <区块X轴起点> <区块Z轴起点> to <区块X轴终点> <区块Z轴终点> in <维度?> named <名称?>`,例如: + +``` +/replay start chunks from -5 -5 to 5 5 in minecraft:overworld named MyChunkRecording +/replay start chunks from 54 67 to 109 124 +/replay start chunks from 30 30 to 60 60 in minecraft:the_nether +``` + +同时你可以指定一个区块和它周围的半径来进行录制,`/replay start chunks around radius <半径> in <维度?> named <名称?>`,例如: + +``` +/replay start chunks around 0 0 radius 5 +/replay start chunks around 67 12 radius 16 in minecraft:overworld named Perimeter Recorder +``` + +区块录制将会被固定并且无法移动,它们将录制指定的区块。需要特别注意的是,当录制开始的时候,这些指定的区块将会被加载一下(并且在有必要的情况下生成)。但在这之后,录制器将不会手动加载这些区块。 + +同时,如果你希望手动停止录制,你可以运行`/replay stop chunks from <区块X轴起点> <区块Z轴起点> to <区块X轴终点> <区块Z轴终点> in <维度?> named <名称?>`。这个指令还可以停止录制并取消保存,例如: + +``` +/replay stop chunks from 0 0 to 5 5 in minecraft:overworld false +/replay stop chunks from 54 67 to 109 124 +``` + +此录制之后将会被保存在`"player_recording_path"`所指定的文件夹的区块录制器名称下。在默认情况下,它将被保存在`./recordings/chunks//.mcpr`。 + +此文件然后可以被放在客户端的`./replay_recordings`文件夹下,并用客户端replay模组打开。 + +#### 指令 + +注意:对于所有的指令,玩家必须要有等级4的op权限,或如果你有一个权限模组(例如[LuckPerms](https://luckperms.net/)),玩家可以在拥有权限节点 `replay.commands.replay` 时使用这些指令。 + +- `/replay enable` 允许模组按照给定的规则(详见 [匹配规则](#匹配规则设置) 部分)自动记录玩家。 + +- `/replay disable` 禁止模组自动录制玩家,这将会同时停止当前的所有的玩家录制和区块录制。 + +- `/replay start players <玩家>` 手动开启对给定的玩家的录制。 + +- `/replay start chunks from <区块X轴起点> <区块Z轴起点> to <区块X轴终点> <区块Z轴终点> in <维度?> named <名称?>` + 手动开启对给定的区块范围的录制;如果维度没有被指定,将会使用发起指令的玩家所在的维度;名称决定了录制文件的保存路径。 + +- `/replay start chunks around <区块X轴位置> <区块Z轴位置> radius <半径> in <维度?> named <名称?>` + 该指令和上一个指令类似;但你可以指定录制给定区块周围的半径内的区域。 + +- `/replay stop players <是否保存?>` 手动停止对给定玩家的录制,你可以选择性地设置录制是否被保存,默认情况下它将会被保存。 + +- `/replay stop chunks from <区块X轴起点> <区块Z轴起点> to <区块X轴终点> <区块Z轴终点> in <维度?> named <名称?>` + 手动停止对于给定区块范围的录制。如果维度没有被指定,将会使用发起指令的玩家所在的维度。你可以选择性地设置录制是否被保存,默认情况下它将会被保存。 + +- `/replay stop chunks named <名称> <是否保存?>` + 该指令和上一个指令类似;但你可以依靠名称来选取指定的区块范围。 + +- `/replay stop [chunks|players] all <是否保存?>` + 手动停止对**所有**区块或玩家的录制。你可以选择性的设置录制是否被保存,默认情况下它将会被保存。 + +- `/replay status` + 获取一个状态信息,包含录制是否被允许,以及当前所有对玩家和区块的录制的列表,它们已被录制的时长,和它们的文件大小。 + +- `/replay reload` 重载replay模组的配置文件。 + +### 配置项 + +在你启动服务器后,一个新的文件将会生成在`./config/ServerReplay/config.json`,默认情况下它是这样的: + +```json +{ + "enabled": false, + "world_name": "World", + "server_name": "Server", + "chunk_recording_path": "./recordings/chunks", + "player_recording_path": "./recordings/players", + "max_file_size": "0GB", + "restart_after_max_file_size": false, + "max_duration": "0s", + "restart_after_max_duration": false, + "recover_unsaved_replays": true, + "include_compressed_in_status": true, + "fixed_daylight_cycle": -1, + "pause_unloaded_chunks": false, + "pause_notify_players": true, + "notify_admins_of_status": true, + "fix_carpet_bot_view_distance": false, + "ignore_sound_packets": false, + "ignore_light_packets": true, + "ignore_chat_packets": false, + "ignore_scoreboard_packets": false, + "optimize_explosion_packets": true, + "optimize_entity_packets": false, + "record_voice_chat": false, + "player_predicate": { + "type": "none" + }, + "chunks": [] +} +``` + +| Config | Description | +|----------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `"enabled"` |

默认情况下录制功能是被禁用的。你可以通过修改`config.json`然后运行`/replay reload`来开启它,或者运行`/replay [enable\|disable]`指令。

| +| `"world_name"` |

在录制文件中呈现的世界名称。

| +| `"server_name"` |

在录制文件中呈现的服务端名称。

| +| `"player_recording_path"` |

玩家录制的保存路径。

| +| `"chunk_recording_path"` |

区块录制的保存路径。

| +| `"max_file_size"` |

回放文件允许录制的最大文件大小,这应当是一个数字+单位,例如 `5.2mb`。

如果录制达到了这个限制,录制器将会停止。这只是个近似值,因此真实的文件大小将会略微大一些。若要不对其进行限制,将此选项设置为 `0` 。

需要注意的是,当最大文件大小被设置的太大时,这可能会影响服务端运行。为了检查一个文件是否超过了该大小(`>5GB`),文件必须被压缩,而这一过程可能会消耗较多性能。你可以通过运行 `/replay status` 来查看距下次文件大小检查的时间。

| +| `"restart_after_max_file_size"` |

如果录制达到了被设置的最大文件大小,那么该录制将会自动地重新开始创建一个新的录制文件.

| +| `"max_duration"` |

设置录制的最大时长,录制会在达到了指定的最大时长时被停止,这应当时一个数字+单位(你也可以有多个单位),例如 `4h 45m 2.1s`。将此选项设置为 `0` 来不限制录制时长。注意:如果一个记录器被暂停了,录制时长将不会增加。

| +| `"restart_after_max_duration"` |

如果 `max_duration` 被设置了且录制达到了该最大时长,那么录制将会自动重启并创建一个新的录制文件。

| +| `"recover_unsaved_replays"` |

尝试恢复未保存的录制,例如你的服务端崩溃了、在某个录制停止或保存成功之前停止了。这不能保证录制一定不被损坏,但会尝试挽救仍可用的信息。

| +| `"include_compressed_in_status"` |

在`/replay status`中包含压缩的录制文件大小,对于较长的录制而言,这可能导致显示状态信息所需的时间增加,所以你可以禁用此选项。

| +| `"fixed_daylight_cycle"` |

如果你不想要长时间恒定的昼夜周期,这将修复录制中的日光周期。此选项应当设置为以tick为单位的一天的时间,例如 `6000` (半天)。要禁用这一修复,将选项值设为 `-1`。

| +| `"pause_unloaded_chunks"` |

如果某一范围内的区块正在被录制,而该区域又被卸载了,当此选项设置为`true`时,录制将会被暂停,直到区块被重新加载时继续录制。

如果此选项设置为`false`,区块将会仍然被录制,就像他们被加载了一样。*(指区块将继续以他们卸载时的状态呈现在回放中,而不是直接将区块卸载的时间跳过。————译者注)*

| +| `"pause_notify_players"` |

如果 `pause_unload_chunks` 被启用,且此选项也被启用,那么将会在录制的区块区域被暂停或恢复时提醒所有的在线玩家。

| +| `"notify_admins_of_status"` |

当启用时,这将会通知管理员录制的开始、结束和保存成功的时间,以及发生的任何错误。

| +| `"fix_carpet_bot_view_distance"` |

如果你要录制carpet假人,你需要启用此选项以将假人视距设置为服务端视距。否则只有假人周围两个区块的距离内会被记录。

| +| `"ignore_sound_packets"` |

忽略声音包。如果你正在为一大片区域录制延时摄影,你大概不会想要记录任何声音,因为这将会占用掉极其巨大的存储空间。

| +| `"ignore_light_packets"` |

忽略光照包。光照是同时在客户端和服务端上计算的,所以光照包大多是多余的。

| +| `"ignore_chat_packets"` |

如果聊天内容在你的录制中并不必要的话,停止对聊天包(来自服务端的和来自其他玩家)的记录。

| +| `"ignore_scoreboard_packets"` |

停止对计分板包的录制(例如,如果你有一个显示挖掘的计分板,那么这个计分板以及玩家的分数都不会被录制)。

| +| `"optimize_explosion_packets"` |

这通过不向客户端发送爆炸数据包,而只发送爆炸粒子和声音来大幅减小文件大小。

| +| `"optimize_entity_packets"` |

这通过让客户端计算一些实体逻辑来减小文件大小,例如弹射物和tnt。这可能会导致一些不一致,但这大概可以被忽略不计。

| +| `"record_voice_chat"` |

如果安装了[simple-voice-chat](https://github.com/henkelmax/simple-voice-chat)模组,此选项允许了对语音聊天的支持。当查看录制的时候,你必须安装了[replay-voice-chat](https://github.com/henkelmax/replay-voice-chat)模组。

| +| `"player_predicate"` |

玩家自动录制的规则,详见 [匹配规则](#匹配规则设置) 部分.

| +| `"chunks"` |

当服务端启动时,要进行自动录制的区块列表。详见 [区块](#区块设置) 部分。

| + +### 区块设置 + +你可以定义当服务端启动或你启用了ServerReplay时,要被自动录制的区块区域。 + +每一个区块的定义必须包含:`"name"`, `"dimension"`, `"from_x"`, `"to_x"`, `"from_z"`, and `"to_z"`。例如: +```json5 +{ + // ... + "chunks": [ + { + "name": "My Chunks", + "dimension": "minecraft:overworld", + "from_x": -5, + "from_z": -5, + "to_x": 5, + "to_z": 5 + }, + { + "name": "My Nether Chunks", + "dimension": "minecraft:the_nether", + "from_x": 100, + "from_z": 50, + "to_x": 90, + "to_z": 60 + } + // ... + ] +} +``` + +### 匹配规则设置 + +*其实这个东西应该叫“断言”。————译者注* + +你可以定义一个匹配规则,它将决定服务端上要被自动录制的玩家。 +你可以通过指定某个玩家是否有特定的uuid,名字,在某个特定的队伍里,或是否是一个管理员,来设置此规则。 + +在定义规则后,你必须在游戏中运行 `/replay reload`,同时玩家要想被自动录制,他们必须重新登录服务器(且满足匹配规则)。 + +最基本的选项是记录所有玩家,在这种情况下,您可以使用: +```json5 +{ + // ... + "player_predicate": { + "type": "all" + } +} +``` + +如果你想要只记录带有特定名字或uuid的玩家,你可以使用: +```json5 +{ + // ... + "player_predicate": { + "type": "has_name", + "names": [ + "senseiwells", + "foobar" + ] + } +} +``` + +```json5 +{ + // ... + "player_predicate": { + "type": "has_uuid", + "uuids": [ + "41048400-886d-497d-9d97-9fe7c9b63afa", + "71266dbd-db0a-484a-b859-3f135590d7a9", + "47d072ca-d7a2-467c-9b60-de501907e91d", + "0e324e7f-e78e-4777-b501-7ae08a65b1eb", + "7d9e24c2-9d0f-479f-81c7-27389624ebb2" + ] + } +} +``` + +如果你只想要记录管理员: +```json5 +{ + // ... + "player_predicate": { + "type": "has_op", + "level": 4 + } +} +``` + +如果你只想要记录在特定队伍中的玩家,这一选项可以支持玩家在游戏中被加入或移除队伍,因此你可以只玩家加入队伍,然后让他们重新登录(*来自动记录该玩家 ————译者注*)。 +```json5 +{ + // ... + "player_predicate": { + "type": "in_team", + "teams": [ + "Red", + "Blue", + "Spectators" + ] + } +} +``` + +你还可以使用否定规则,用 `not` 然后用 `or` 和 `and` 连接。 +例如,如果你想要记录非管理员且玩家名不为 `senseiwells` 的玩家,或在红队中的玩家: +```json5 +{ + // ... + "player_predicate": { + "type": "and", + "predicates": [ + { + "type": "not", + "predicate": { + "type": "has_op", + "level": 4 + } + }, + { + "type": "not", + "predicate": { + "type": "or", + "predicates": [ + { + "type": "has_name", + "names": [ + "senseiwells" + ] + }, + { + "type": "in_team", + "teams": [ + "Red" + ] + } + ] + } + } + ] + } +} +``` + +如果你正在使用carpet模组且能够召唤假人,你可能会想让假人不被自动记录。 +你可以使用 `is_fake` 条件来实现: +```json5 +{ + // ... + "player_predicate": { + "type": "not", + "predicate": { + "type": "is_fake" + } + } +} +``` + +## 开发者 + +如果你想要玩家被记录时的更多的控制权,你可以在你的模组中接入此方法。 + +要在你的项目中接入API,你可以下面内容加入你的 `build.gradle.kts` 中: + +```kts +repositories { + maven { + url = uri("https://jitpack.io") + } +} + +dependencies { + // For the most recent version use the latest commit hash + modImplementation("com.github.senseiwells:ServerReplay:281e9e0ec0") +} +``` + +这里有一个最基本的例子: +```kt +class ExampleMod: ModInitializer { + override fun onInitialize() { + ServerPlayConnectionEvents.JOIN.register { connection, _, _ -> + val player = connection.player + if (!PlayerRecorders.has(player)) { + if (player.level().dimension() == Level.END) { + val recorder = PlayerRecorders.create(player) + recorder.start(log = true) + } + } else { + val existing = PlayerRecorders.get(player)!! + existing.getCompressedRecordingSize().thenAccept { size -> + println("Replay is $size bytes") + } + existing.stop(save = false) + } + } + + ServerLifecycleEvents.SERVER_STARTED.register { server -> + val recorder = ChunkRecorders.create( + server.overworld(), + ChunkPos.ZERO, + ChunkPos(5, 5), + "Named" + ) + recorder.start(log = false) + } + } +} +``` diff --git a/build.gradle.kts b/build.gradle.kts index b30865d..4db1a84 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -82,6 +82,10 @@ loom { getByName("server") { runDir = "run/${project.property("minecraft_version")}" } + + getByName("client") { + runDir = "run/client" + } } } @@ -109,7 +113,8 @@ tasks { from("LICENSE") - relocate("com.github.steveice10", "shadow.server_replay.com.github.steveice10") + relocate("com.github.steveice10.netty", "io.netty") + exclude("com/github/steveice10/netty/**") configurations = listOf(shade) archiveClassifier = "shaded" @@ -172,7 +177,7 @@ tasks { kotlinOptions.jvmTarget = "17" } - create("updateReadme") { + register("updateReadme") { val readmes = listOf("./README.md") val regex = Regex("""com.github.Senseiwells:ServerReplay:[a-z0-9]+""") val replacement = "com.github.Senseiwells:ServerReplay:${getGitHash()}" diff --git a/gradle.properties b/gradle.properties index ac9e214..69568e4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -17,6 +17,6 @@ carpet_version=1.4.112 voicechat_version=1.20.1-2.5.7 voicechat_api_version=2.4.0 -mod_version=1.0.7 +mod_version=1.0.8 org.gradle.jvmargs=-Xmx4000m \ No newline at end of file diff --git a/src/main/kotlin/me/senseiwells/replay/ServerReplay.kt b/src/main/kotlin/me/senseiwells/replay/ServerReplay.kt index 77a28cf..b0dd223 100644 --- a/src/main/kotlin/me/senseiwells/replay/ServerReplay.kt +++ b/src/main/kotlin/me/senseiwells/replay/ServerReplay.kt @@ -1,5 +1,6 @@ package me.senseiwells.replay +import me.senseiwells.replay.api.ReplayPluginManager import me.senseiwells.replay.config.ReplayConfig import net.fabricmc.api.ModInitializer import net.fabricmc.loader.api.FabricLoader @@ -20,6 +21,6 @@ object ServerReplay: ModInitializer { var config: ReplayConfig = ReplayConfig.read() override fun onInitialize() { - + ReplayPluginManager.loadPlugins() } } \ No newline at end of file diff --git a/src/main/kotlin/me/senseiwells/replay/api/RejoinedPacketSender.kt b/src/main/kotlin/me/senseiwells/replay/api/RejoinedPacketSender.kt deleted file mode 100644 index f9858f3..0000000 --- a/src/main/kotlin/me/senseiwells/replay/api/RejoinedPacketSender.kt +++ /dev/null @@ -1,10 +0,0 @@ -package me.senseiwells.replay.api - -import me.senseiwells.replay.chunk.ChunkRecorder -import me.senseiwells.replay.player.PlayerRecorder - -interface RejoinedPacketSender { - fun recordAdditionalPlayerPackets(recorder: PlayerRecorder) - - fun recordAdditionalChunkPackets(recorder: ChunkRecorder) -} \ No newline at end of file diff --git a/src/main/kotlin/me/senseiwells/replay/api/ReplayPluginManager.kt b/src/main/kotlin/me/senseiwells/replay/api/ReplayPluginManager.kt new file mode 100644 index 0000000..3458860 --- /dev/null +++ b/src/main/kotlin/me/senseiwells/replay/api/ReplayPluginManager.kt @@ -0,0 +1,54 @@ +package me.senseiwells.replay.api + +import me.senseiwells.replay.ServerReplay +import me.senseiwells.replay.api.ReplayPluginManager.registerPlugin +import net.fabricmc.loader.api.FabricLoader + +/** + * Plugin manager that manages any plugins for ServerReplay. + * + * Your plugins should be specified in your fabric.mod.json, + * see [registerPlugin] for more information. + */ +object ReplayPluginManager { + internal val plugins = ArrayList() + + /** + * This registers a [ServerReplayPlugin]. + * + * You should add an entrypoint in your `fabric.mod.json` under + * `server_replay` instead. For example: + * ```json + * { + * // ... + * "entrypoints": { + * "main": [ + * // ... + * ], + * "server_replay": [ + * "com.example.MyServerReplayPlugin" + * ] + * } + * // ... + * } + * ``` + * If this is not an option, then you may register your plugin using this method. + * + * @param plugin The plugin to register. + */ + @Suppress("DeprecatedCallableAddReplaceWith") + @Deprecated("You should add an entrypoint for server_replay in your fabric.mod.json for your plugin") + fun registerPlugin(plugin: ServerReplayPlugin) { + this.plugins.add(plugin) + } + + internal fun loadPlugins() { + val containers = FabricLoader.getInstance().getEntrypointContainers("server_replay", ServerReplayPlugin::class.java) + for (container in containers) { + val entrypoint = container.entrypoint + val modInfo = "${container.provider.metadata.id} (${container.provider.metadata.version.friendlyString})" + ServerReplay.logger.info("Loading plugin (${entrypoint::class.java.simpleName}) from mod $modInfo") + this.plugins.add(entrypoint) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/senseiwells/replay/api/ReplaySenders.kt b/src/main/kotlin/me/senseiwells/replay/api/ReplaySenders.kt deleted file mode 100644 index 1711c07..0000000 --- a/src/main/kotlin/me/senseiwells/replay/api/ReplaySenders.kt +++ /dev/null @@ -1,12 +0,0 @@ -package me.senseiwells.replay.api - -import org.jetbrains.annotations.ApiStatus.Experimental - -object ReplaySenders { - internal val senders = ArrayList() - - @Experimental - fun addSender(sender: RejoinedPacketSender) { - this.senders.add(sender) - } -} \ No newline at end of file diff --git a/src/main/kotlin/me/senseiwells/replay/api/ServerReplayPlugin.kt b/src/main/kotlin/me/senseiwells/replay/api/ServerReplayPlugin.kt new file mode 100644 index 0000000..1981213 --- /dev/null +++ b/src/main/kotlin/me/senseiwells/replay/api/ServerReplayPlugin.kt @@ -0,0 +1,62 @@ +package me.senseiwells.replay.api + +import me.senseiwells.replay.chunk.ChunkRecorder +import me.senseiwells.replay.player.PlayerRecorder + +/** + * This interface can be implemented to send additional packets + * to replay recorders when a replay is started. + * + * This is intended for use if your mod sends custom packets to the + * client that are usually sent after the player has logged in - these + * would normally be recorded however if a replay is started with the + * `/replay` command the recorder does not know this, and so they need + * to be manually resent. + * + * You should add an entrypoint in your `fabric.mod.json` under + * `server_replay` instead. For example: + * ```json + * { + * // ... + * "entrypoints": { + * "main": [ + * // ... + * ], + * "server_replay": [ + * "com.example.MyServerReplayPlugin" + * ] + * } + * // ... + * } + * ``` + */ +interface ServerReplayPlugin { + /** + * This method is called **only** for when replays are started + * with the `/replay` command, this allows you to send any + * additional packets that would've been sent when a player logs on. + * + * This is called after all the packets for joining a server have + * been sent, this includes all the chunk and entity packets. + * + * @param recorder The [PlayerRecorder] that has just started. + */ + fun onPlayerReplayStart(recorder: PlayerRecorder) { + + } + + /** + * This method is called for every chunk recording that is started. + * This allows you to send any additional packets that would've + * been sent if a player were to log on and start recording. + * This may, for example, include any resource packs packets. + * + * This is called after all the packets for joining a server have + * been sent, this includes all the chunk and entity packets. + * + * @param recorder The [ChunkRecorder] that has just started. + */ + fun onChunkReplayStart(recorder: ChunkRecorder) { + + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/senseiwells/replay/chunk/ChunkArea.kt b/src/main/kotlin/me/senseiwells/replay/chunk/ChunkArea.kt index 8e5fd7d..616ce20 100644 --- a/src/main/kotlin/me/senseiwells/replay/chunk/ChunkArea.kt +++ b/src/main/kotlin/me/senseiwells/replay/chunk/ChunkArea.kt @@ -9,15 +9,36 @@ import net.minecraft.world.level.levelgen.structure.BoundingBox import kotlin.math.max import kotlin.math.min +/** + * This class represents an area of chunks in a given [level]. + * + * @param level The level that the chunk area is in. + * @param from The first chunk corner. + * @param to The second chunk corner. + */ class ChunkArea( val level: ServerLevel, from: ChunkPos, to: ChunkPos ): Iterable { + /** + * The north-west most chunk corner. + */ val from: ChunkPos + + /** + * The south-east most chunk corner. + */ val to: ChunkPos + /** + * The center most chunk position. + */ val center: ChunkPos + + /** + * The largest distance from the center to the edge chunk. + */ val viewDistance: Int init { @@ -30,10 +51,26 @@ class ChunkArea( this.viewDistance = max(dx, dz) } + /** + * Checks whether this area is in a given [level] and whether it + * contains the given [pos]. + * + * @param level The level to check. + * @param pos The position to check. + * @return Whether the location is inside this chunk area. + */ fun contains(level: ResourceKey, pos: ChunkPos): Boolean { return this.level.dimension() == level && this.contains(pos) } + /** + * Checks whether this area is in a given [level] and whether + * it intersects the given [box]. + * + * @param level The level to check. + * @param box The position to check. + * @return Whether the location intersects with this chunk area. + */ fun intersects(level: ResourceKey, box: BoundingBox): Boolean { if (this.level.dimension() != level) { return false @@ -49,6 +86,12 @@ class ChunkArea( return this.from.x <= pos.x && this.from.z <= pos.z && this.to.x >= pos.x && this.to.z >= pos.z } + /** + * Returns an iterator that iterates over every chunk position + * in this chunk area. + * + * @return The chunk position iterator. + */ override fun iterator(): Iterator { val dx = this.to.x - this.from.x + 1 val dz = this.to.z - this.from.z + 1 diff --git a/src/main/kotlin/me/senseiwells/replay/chunk/ChunkRecordable.kt b/src/main/kotlin/me/senseiwells/replay/chunk/ChunkRecordable.kt index 03e6856..e491e07 100644 --- a/src/main/kotlin/me/senseiwells/replay/chunk/ChunkRecordable.kt +++ b/src/main/kotlin/me/senseiwells/replay/chunk/ChunkRecordable.kt @@ -1,11 +1,69 @@ package me.senseiwells.replay.chunk +/** + * This interface represents an object that can be recorded + * by a [ChunkRecorder]. + * + * If a [ChunkRecorder] is added to this object via [addRecorder] + * any packets that are a result of this object should also then be + * recorded by that recorder. + * + * In order to update the recorders that are able to record this + * object you should call [ChunkRecorders.updateRecordable] to + * add and remove any [ChunkRecorder]s as necessary. + * + * For example: + * ```kotlin + * class MyChunkRecordable: ChunkRecordable { + * // ... + * + * fun tick() { + * // The level that your recordable object is in. + * val level: ServerLevel = // ... + * // If your object is within a chunk: + * val chunkPos: ChunkPos = // ... + * ChunkRecorders.updateRecordable(this, level.dimension(), chunkPos) + * + * // Alternatively if your object spans multiple chunks: + * val boundingBox: BoundingBox = // ... + * ChunkRecorders.updateRecordable(this, level.dimension(), boundingBox) + * } + * + * // ... + * } + * ``` + * + * @see ChunkRecorder + * @see ChunkRecorders.updateRecordable + */ interface ChunkRecordable { + /** + * This gets all the [ChunkRecorder]s that are currently + * recording this object. + * + * @return All the chunk recorders recording this. + */ fun getRecorders(): Collection + /** + * Adds a [ChunkRecorder] to record all packets produced + * by this object. + * + * @param recorder The recorder to add. + */ fun addRecorder(recorder: ChunkRecorder) + /** + * Removes a [ChunkRecorder] from recording packets + * produced by this object. + * + * @param recorder The recorder to remove. + */ fun removeRecorder(recorder: ChunkRecorder) + /** + * Removes all [ChunkRecorder]s from recording + * packets produced by this object. + */ fun removeAllRecorders() } \ No newline at end of file diff --git a/src/main/kotlin/me/senseiwells/replay/chunk/ChunkRecorder.kt b/src/main/kotlin/me/senseiwells/replay/chunk/ChunkRecorder.kt index 99df7e2..ab0aaa1 100644 --- a/src/main/kotlin/me/senseiwells/replay/chunk/ChunkRecorder.kt +++ b/src/main/kotlin/me/senseiwells/replay/chunk/ChunkRecorder.kt @@ -1,12 +1,14 @@ package me.senseiwells.replay.chunk -import com.mojang.authlib.GameProfile import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive +import com.mojang.authlib.GameProfile import me.senseiwells.replay.ServerReplay import me.senseiwells.replay.mixin.chunk.WitherBossAccessor import me.senseiwells.replay.mixin.rejoin.ChunkMapAccessor +import me.senseiwells.replay.player.PlayerRecorder import me.senseiwells.replay.recorder.ChunkSender +import me.senseiwells.replay.recorder.ChunkSender.WrappedTrackedEntity import me.senseiwells.replay.recorder.ReplayRecorder import me.senseiwells.replay.rejoin.RejoinedReplayPlayer import net.minecraft.core.UUIDUtil @@ -15,7 +17,6 @@ import net.minecraft.network.protocol.Packet import net.minecraft.network.protocol.game.ClientboundAddPlayerPacket import net.minecraft.network.protocol.game.ClientboundSetChunkCacheRadiusPacket import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket -import net.minecraft.server.level.ChunkMap.TrackedEntity import net.minecraft.server.level.ServerLevel import net.minecraft.server.level.ServerPlayer import net.minecraft.world.entity.Entity @@ -23,17 +24,27 @@ import net.minecraft.world.entity.boss.wither.WitherBoss import net.minecraft.world.level.ChunkPos import net.minecraft.world.level.levelgen.Heightmap import org.apache.commons.lang3.builder.ToStringBuilder -import org.jetbrains.annotations.ApiStatus.Experimental import org.jetbrains.annotations.ApiStatus.Internal import java.nio.file.Path import java.util.concurrent.CompletableFuture import java.util.function.Consumer +/** + * An implementation of [ReplayRecorder] for recording chunk areas. + * + * This can be created when running the `/replay` command. + * + * @param chunks The [ChunkArea] to record. + * @param recorderName The name of the [ChunkRecorder]. + * @param recordings The chunks recordings directory. + * @see PlayerRecorder + * @see ChunkRecorder + */ class ChunkRecorder internal constructor( val chunks: ChunkArea, val recorderName: String, recordings: Path -): ReplayRecorder(chunks.level.server, PROFILE,recordings), ChunkSender { +): ReplayRecorder(chunks.level.server, PROFILE, recordings), ChunkSender { private val dummy = ServerPlayer(this.server, this.chunks.level, PROFILE) private val recordables = HashSet() @@ -43,14 +54,28 @@ class ChunkRecorder internal constructor( private var loadedChunks = 0 + /** + * The level that the chunk recording is currently in. + */ override val level: ServerLevel get() = this.chunks.level + /** + * This gets the name of the replay recording. + * + * @return The name of the replay recording. + */ override fun getName(): String { return this.recorderName } - override fun start(): Boolean { + /** + * This starts the replay recording, it sends all the chunk and + * entity packets as if a player were logging into the server. + * + * This method should just simulate + */ + override fun initialize(): Boolean { val center = this.getCenterChunk() // Load the chunk this.level.getChunk(center.x, center.z) @@ -77,12 +102,24 @@ class ChunkRecorder internal constructor( return true } + /** + * This method tries to restart the replay recorder by creating + * a new instance of itself. + * + * @return Whether it successfully restarted. + */ override fun restart(): Boolean { val recorder = ChunkRecorders.create(this.chunks, this.recorderName) - return recorder.tryStart(true) + return recorder.start(true) } - override fun closed(future: CompletableFuture) { + /** + * This gets called when the replay is closing. It removes all [ChunkRecordable]s + * and updates the [ChunkRecorders] manager. + * + * @param future The future that will complete once the replay has closed. + */ + override fun onClosing(future: CompletableFuture) { for (recordable in ArrayList(this.recordables)) { recordable.removeRecorder(this) } @@ -94,16 +131,34 @@ class ChunkRecorder internal constructor( ChunkRecorders.close(this.server, this, future) } + /** + * This gets the current timestamp (in milliseconds) of the replay recording. + * This subtracts the amount of time paused from the total recording time. + * + * @return The timestamp of the recording (in milliseconds). + */ override fun getTimestamp(): Long { return super.getTimestamp() - this.totalPausedTime - this.getCurrentPause() } + /** + * This appends any additional data to the status. + * + * @param builder The [ToStringBuilder] which is used to build the status. + * @see getStatusWithSize + */ override fun appendToStatus(builder: ToStringBuilder) { builder.append("chunks_world", this.chunks.level.dimension().location()) builder.append("chunks_from", this.chunks.from) builder.append("chunks_to", this.chunks.to) } + /** + * This allows you to add any additional metadata which will be + * saved in the replay file. + * + * @param map The JSON metadata map which can be mutated. + */ override fun addMetadata(map: MutableMap) { super.addMetadata(map) map["chunks_world"] = JsonPrimitive(this.chunks.level.dimension().location().toString()) @@ -112,46 +167,91 @@ class ChunkRecorder internal constructor( map["paused_time"] = JsonPrimitive(this.totalPausedTime) } - override fun canContinueRecording(): Boolean { - return true - } - + /** + * This gets the center chunk position of the chunk recording. + * + * @return The center most chunk position. + */ override fun getCenterChunk(): ChunkPos { return this.chunks.center } + /** + * This will iterate over every chunk position that is going + * to be sent, each chunk position will be accepted into the + * [consumer]. + * + * @param consumer The consumer that will accept the given chunks positions. + */ override fun forEachChunk(consumer: Consumer) { this.chunks.forEach(consumer) } - override fun sendPacket(packet: Packet<*>) { - this.record(packet) - } - - override fun isValidEntity(entity: Entity): Boolean { - return true + /** + * This determines whether a given [entity] should be tracked. + * + * @param entity The entity to check. + * @param range The entity's tracking range. + * @return Whether the entity should be tracked. + */ + override fun shouldTrackEntity(entity: Entity, range: Double): Boolean { + return this.chunks.contains(entity.level().dimension(), entity.chunkPosition()) } - override fun shouldTrackEntity(tracking: Entity, range: Double): Boolean { - return this.chunks.contains(tracking.level().dimension(), tracking.chunkPosition()) + /** + * This records a packet. + * + * @param packet The packet to be recorded. + */ + override fun sendPacket(packet: Packet<*>) { + this.record(packet) } - override fun addTrackedEntity(tracking: TrackedEntity) { - (tracking as ChunkRecordable).addRecorder(this) + /** + * This is called when [shouldTrackEntity] returns `true`, + * this should be used to send any additional packets for this entity. + * + * @param tracked The [WrappedTrackedEntity]. + */ + override fun addTrackedEntity(tracked: WrappedTrackedEntity) { + (tracked.tracked as ChunkRecordable).addRecorder(this) } + /** + * This gets the view distance of the chunk area. + * + * @return The view distance of the chunk area. + */ override fun getViewDistance(): Int { return this.chunks.viewDistance } + /** + * Determines whether a given packet is able to be recorded. + * + * @param packet The packet that is going to be recorded. + * @return Whether this recorded should record it. + */ override fun canRecordPacket(packet: Packet<*>): Boolean { + // If the server view-distance changes we do not want to update + // the client - this will cut the view distance in the replay if (packet is ClientboundSetChunkCacheRadiusPacket) { return packet.radius == this.getViewDistance() } return super.canRecordPacket(packet) } - @Deprecated("Be extremely careful when using the dummy chunk player") + /** + * This gets the dummy chunk recording player. + * + * **This is *not* a real player, and many operations on this instance + * may cause crashes, be very careful with how you use this.** + * + * This player has no [ServerPlayer.connection] and thus **cannot** be + * sent packets, any attempts will result in a [NullPointerException]. + * + * @return The dummy chunk recording player. + */ fun getDummy(): ServerPlayer { return this.dummy } diff --git a/src/main/kotlin/me/senseiwells/replay/chunk/ChunkRecorders.kt b/src/main/kotlin/me/senseiwells/replay/chunk/ChunkRecorders.kt index 92dc130..bd6eed7 100644 --- a/src/main/kotlin/me/senseiwells/replay/chunk/ChunkRecorders.kt +++ b/src/main/kotlin/me/senseiwells/replay/chunk/ChunkRecorders.kt @@ -1,7 +1,6 @@ package me.senseiwells.replay.chunk import me.senseiwells.replay.ServerReplay -import me.senseiwells.replay.recorder.ReplayRecorder import me.senseiwells.replay.recorder.RecorderRecoverer import net.minecraft.resources.ResourceKey import net.minecraft.server.MinecraftServer @@ -11,19 +10,38 @@ import net.minecraft.world.level.Level import net.minecraft.world.level.levelgen.structure.BoundingBox import java.util.concurrent.CompletableFuture +/** + * This object manages all [ChunkRecorder]s. + */ object ChunkRecorders { private val chunks = LinkedHashMap() private val chunksByName = LinkedHashMap() private val closing = HashMap() + /** + * This creates a [ChunkRecorder]. + * + * @param level The level which the recorder is in. + * @param from The chunk corner to record from. + * @param to The chunk corner to record to. + * @param name The name of the recorder. + * @return The created recorder. + */ @JvmStatic - fun create(level: ServerLevel, from: ChunkPos, to: ChunkPos, name: String): ReplayRecorder { + fun create(level: ServerLevel, from: ChunkPos, to: ChunkPos, name: String): ChunkRecorder { return this.create(ChunkArea(level, from, to), name) } + /** + * This creates a [ChunkRecorder]. + * + * @param area The chunk area to record. + * @param name The name of the recorder, one will be generated by if not provided. + * @return The created recorder. + */ @JvmStatic @JvmOverloads - fun create(area: ChunkArea, name: String = generateName(area)): ReplayRecorder { + fun create(area: ChunkArea, name: String = generateName(area)): ChunkRecorder { if (this.chunks.containsKey(area)) { throw IllegalArgumentException("Recorder for chunk area already exists") } @@ -42,52 +60,117 @@ object ChunkRecorders { return recorder } + /** + * Whether a chunk recorder with a given [name] exists. + * + * @param name The name to check. + * @return Whether a chunk recorder with that name exists. + */ @JvmStatic fun has(name: String): Boolean { return this.chunksByName.containsKey(name) } + /** + * Whether a chunk recorder for the given [area] exists. + * + * @param area The area to check. + * @return Whether a chunk recorder with that area exists. + */ @JvmStatic fun has(area: ChunkArea): Boolean { return this.chunks.containsKey(area) } + /** + * Checks whether the given [area] and [name] is available + * or whether at least one is already taken. + * + * @param area The area to check. + * @param name The name to check. + * @return Whether the area and name are both available. + */ @JvmStatic fun isAvailable(area: ChunkArea, name: String): Boolean { return !this.has(area) && !this.has(name) } + /** + * Gets a [ChunkRecorder] for a given name. + * + * @param name The name of the recorder. + * @return The recorder instance with the given name, null if it doesn't exist. + */ @JvmStatic fun get(name: String): ChunkRecorder? { return this.chunksByName[name] } + /** + * Gets a [ChunkRecorder] for a given [area]. + * + * @param area The area of the recorder. + * @return The recorder instance with the given area, null if it doesn't exist. + */ @JvmStatic fun get(area: ChunkArea): ChunkRecorder? { return this.chunks[area] } + /** + * Gets all the [ChunkRecorder]s that contain a given [chunk] in the given [level]. + * + * @param level The level to check in. + * @param chunk The position the recorder must contain. + * @return A list of chunk recorders. + */ @JvmStatic fun containing(level: ResourceKey, chunk: ChunkPos): List { return this.chunks.values.filter { it.chunks.contains(level, chunk) } } + /** + * Gets all the [ChunkRecorder]s that intersect a given [box] in the given [level]. + * + * @param level The level to check in. + * @param box The bounding box the recorder must intersect with. + * @return A list of chunk recorders. + */ @JvmStatic @Suppress("unused") fun intersecting(level: ResourceKey, box: BoundingBox): List { return this.chunks.values.filter { it.chunks.intersects(level, box) } } + /** + * Gets a collection of all the currently recording chunk recorders. + * + * @return A collection of all the chunk recorders. + */ @JvmStatic fun recorders(): Collection { return ArrayList(this.chunks.values) } + /** + * Gets a collection of all the currently closing chunk recorders. + * + * @return A collection of all the closing chunk recorders. + */ @JvmStatic fun closing(): Collection { return ArrayList(this.closing.values) } + /** + * This updates a [ChunkRecordable] adding and removing chunk + * recordings that should be recording it. + * + * @param recordable The recordable to update. + * @param level The level in which the recordable should be recorded in. + * @param chunk The chunk in which the recordable is in. + * @see ChunkRecordable + */ @JvmStatic fun updateRecordable( recordable: ChunkRecordable, @@ -97,6 +180,15 @@ object ChunkRecorders { this.updateRecordable(recordable) { it.contains(level, chunk) } } + /** + * This updates a [ChunkRecordable] adding and removing chunk + * recordings that should be recording it. + * + * @param recordable The recordable to update. + * @param level The level in which the recordable should be recorded in. + * @param box The bounding box of the recordable. + * @see ChunkRecordable + */ @JvmStatic fun updateRecordable( recordable: ChunkRecordable, @@ -122,6 +214,16 @@ object ChunkRecorders { } } + /** + * This generates a name for a given [area]. + * + * @param area The chunk area. + * @return The name for the given [area]. + */ + fun generateName(area: ChunkArea): String { + return "Chunks (${area.from.x}, ${area.from.z}) to (${area.to.x}, ${area.to.z})" + } + internal fun remove(area: ChunkArea): ChunkRecorder? { val recorder = this.chunks.remove(area) if (recorder != null) { @@ -130,10 +232,6 @@ object ChunkRecorders { return recorder } - fun generateName(area: ChunkArea): String { - return "Chunks (${area.from.x}, ${area.from.z}) to (${area.to.x}, ${area.to.z})" - } - internal fun close( server: MinecraftServer, recorder: ChunkRecorder, diff --git a/src/main/kotlin/me/senseiwells/replay/commands/PackCommand.kt b/src/main/kotlin/me/senseiwells/replay/commands/PackCommand.kt index 91e7b5d..0e46904 100644 --- a/src/main/kotlin/me/senseiwells/replay/commands/PackCommand.kt +++ b/src/main/kotlin/me/senseiwells/replay/commands/PackCommand.kt @@ -20,7 +20,7 @@ object PackCommand { ) } - private fun setPack(context: CommandContext, ): Int { + private fun setPack(context: CommandContext): Int { val url = StringArgumentType.getString(context, "url") val packet = ClientboundResourcePackPacket(url, "", false, null) for (player in context.source.server.playerList.players) { diff --git a/src/main/kotlin/me/senseiwells/replay/commands/ReplayCommand.kt b/src/main/kotlin/me/senseiwells/replay/commands/ReplayCommand.kt index daf89ad..f24a03a 100644 --- a/src/main/kotlin/me/senseiwells/replay/commands/ReplayCommand.kt +++ b/src/main/kotlin/me/senseiwells/replay/commands/ReplayCommand.kt @@ -165,7 +165,7 @@ object ReplayCommand { val players = EntityArgument.getPlayers(context, "players") var i = 0 for (player in players) { - if (!PlayerRecorders.has(player) && PlayerRecorders.create(player).tryStart()) { + if (!PlayerRecorders.has(player) && PlayerRecorders.create(player).start()) { i++ } } @@ -211,7 +211,7 @@ object ReplayCommand { return 0 } val recorder = ChunkRecorders.create(area, id) - recorder.tryStart() + recorder.start() context.source.sendSuccess({ Component.literal("Successfully started chunk replay: ${recorder.getName()}") }, true) return 1 } diff --git a/src/main/kotlin/me/senseiwells/replay/compat/voicechat/ReplayVoicechatPlugin.kt b/src/main/kotlin/me/senseiwells/replay/compat/voicechat/ReplayVoicechatPlugin.kt index ec773cd..cb4ba42 100644 --- a/src/main/kotlin/me/senseiwells/replay/compat/voicechat/ReplayVoicechatPlugin.kt +++ b/src/main/kotlin/me/senseiwells/replay/compat/voicechat/ReplayVoicechatPlugin.kt @@ -8,16 +8,10 @@ import de.maxhenkel.voicechat.api.audio.AudioConverter import de.maxhenkel.voicechat.api.events.* import de.maxhenkel.voicechat.api.opus.OpusDecoder import de.maxhenkel.voicechat.api.packets.SoundPacket -import de.maxhenkel.voicechat.net.AddCategoryPacket -import de.maxhenkel.voicechat.net.AddGroupPacket -import de.maxhenkel.voicechat.net.PlayerStatePacket -import de.maxhenkel.voicechat.net.PlayerStatesPacket -import de.maxhenkel.voicechat.net.RemoveCategoryPacket -import de.maxhenkel.voicechat.net.SecretPacket +import de.maxhenkel.voicechat.net.* import de.maxhenkel.voicechat.plugins.impl.VolumeCategoryImpl import me.senseiwells.replay.ServerReplay -import me.senseiwells.replay.api.RejoinedPacketSender -import me.senseiwells.replay.api.ReplaySenders +import me.senseiwells.replay.api.ServerReplayPlugin import me.senseiwells.replay.chunk.ChunkRecorder import me.senseiwells.replay.chunk.ChunkRecorders import me.senseiwells.replay.player.PlayerRecorder @@ -31,11 +25,10 @@ import net.minecraft.network.protocol.Packet import net.minecraft.network.protocol.game.ClientGamePacketListener import net.minecraft.resources.ResourceLocation import net.minecraft.server.level.ServerPlayer -import java.util.UUID -import java.util.WeakHashMap +import java.util.* @Suppress("unused") -object ReplayVoicechatPlugin: VoicechatPlugin, RejoinedPacketSender { +object ReplayVoicechatPlugin: VoicechatPlugin, ServerReplayPlugin { /** * Mod id of the replay voicechat mod, see [here](https://github.com/henkelmax/replay-voice-chat/blob/master/src/main/java/de/maxhenkel/replayvoicechat/ReplayVoicechat.java). */ @@ -76,8 +69,6 @@ object ReplayVoicechatPlugin: VoicechatPlugin, RejoinedPacketSender { registration.registerEvent(RegisterVolumeCategoryEvent::class.java, this::onRegisterCategory) registration.registerEvent(UnregisterVolumeCategoryEvent::class.java, this::onUnregisterCategory) registration.registerEvent(PlayerStateChangedEvent::class.java, this::onPlayerStateChanged) - - ReplaySenders.addSender(this) } private fun onLocationalSoundPacket(event: LocationalSoundPacketEvent) { @@ -234,7 +225,7 @@ object ReplayVoicechatPlugin: VoicechatPlugin, RejoinedPacketSender { return this.player.player as? ServerPlayer } - override fun recordAdditionalPlayerPackets(recorder: PlayerRecorder) { + override fun onPlayerReplayStart(recorder: PlayerRecorder) { this.recordAdditionalPackets(recorder) val server = Voicechat.SERVER.server val player = recorder.getPlayerOrThrow() @@ -246,11 +237,10 @@ object ReplayVoicechatPlugin: VoicechatPlugin, RejoinedPacketSender { } } - override fun recordAdditionalChunkPackets(recorder: ChunkRecorder) { + override fun onChunkReplayStart(recorder: ChunkRecorder) { this.recordAdditionalPackets(recorder) val server = Voicechat.SERVER.server if (server != null) { - @Suppress("DEPRECATION") val player = recorder.getDummy() // The chunks aren't sending any voice data so doesn't need a secret val packet = SecretPacket(player, Util.NIL_UUID, server.port, Voicechat.SERVER_CONFIG) diff --git a/src/main/kotlin/me/senseiwells/replay/config/ReplayConfig.kt b/src/main/kotlin/me/senseiwells/replay/config/ReplayConfig.kt index bfaad2f..a7a3212 100644 --- a/src/main/kotlin/me/senseiwells/replay/config/ReplayConfig.kt +++ b/src/main/kotlin/me/senseiwells/replay/config/ReplayConfig.kt @@ -13,7 +13,6 @@ import me.senseiwells.replay.config.predicates.NonePredicate import me.senseiwells.replay.config.predicates.ReplayPlayerContext import me.senseiwells.replay.config.predicates.ReplayPlayerPredicate import me.senseiwells.replay.config.serialization.DurationSerializer -import me.senseiwells.replay.config.serialization.FileSizeSerializer import me.senseiwells.replay.config.serialization.PathSerializer import me.senseiwells.replay.player.PlayerRecorders import me.senseiwells.replay.util.FileSize @@ -107,7 +106,7 @@ class ReplayConfig { fun startPlayers(server: MinecraftServer, log: Boolean = true) { for (player in server.playerList.players) { if (!PlayerRecorders.has(player) && this.shouldRecordPlayer(ReplayPlayerContext.of(player))) { - PlayerRecorders.create(player).tryStart(log) + PlayerRecorders.create(player).start(log) } } } @@ -122,7 +121,7 @@ class ReplayConfig { } if (ChunkRecorders.isAvailable(area, chunks.name)) { val recorder = ChunkRecorders.create(area, chunks.name) - recorder.tryStart(log) + recorder.start(log) } } } diff --git a/src/main/kotlin/me/senseiwells/replay/config/predicates/ReplayPlayerContext.kt b/src/main/kotlin/me/senseiwells/replay/config/predicates/ReplayPlayerContext.kt index 7600a64..69f80b1 100644 --- a/src/main/kotlin/me/senseiwells/replay/config/predicates/ReplayPlayerContext.kt +++ b/src/main/kotlin/me/senseiwells/replay/config/predicates/ReplayPlayerContext.kt @@ -1,8 +1,6 @@ package me.senseiwells.replay.config.predicates import com.mojang.authlib.GameProfile -import net.fabricmc.fabric.api.entity.FakePlayer -import net.fabricmc.fabric.api.util.TriState import net.minecraft.server.MinecraftServer import net.minecraft.server.level.ServerPlayer import net.minecraft.world.scores.Team diff --git a/src/main/kotlin/me/senseiwells/replay/player/PlayerRecorder.kt b/src/main/kotlin/me/senseiwells/replay/player/PlayerRecorder.kt index bb559c1..755f6b5 100644 --- a/src/main/kotlin/me/senseiwells/replay/player/PlayerRecorder.kt +++ b/src/main/kotlin/me/senseiwells/replay/player/PlayerRecorder.kt @@ -1,7 +1,6 @@ package me.senseiwells.replay.player import com.mojang.authlib.GameProfile -import me.senseiwells.replay.mixin.rejoin.TrackedEntityAccessor import me.senseiwells.replay.recorder.ChunkSender import me.senseiwells.replay.recorder.ReplayRecorder import me.senseiwells.replay.rejoin.RejoinedReplayPlayer @@ -16,10 +15,22 @@ import net.minecraft.server.level.ServerLevel import net.minecraft.server.level.ServerPlayer import net.minecraft.world.entity.Entity import net.minecraft.world.level.ChunkPos +import org.jetbrains.annotations.ApiStatus.Internal import java.nio.file.Path import java.util.concurrent.CompletableFuture import java.util.function.Consumer +/** + * An implementation of [ReplayRecorder] for recording players. + * + * This can either be created when running the `/replay` command + * or when a player logs in, and they meet the criteria to start recording. + * + * @param server The [MinecraftServer] instance. + * @param profile The profile of the player being recorded. + * @param recordings The player recordings directory. + * @see ReplayRecorder + */ class PlayerRecorder internal constructor( server: MinecraftServer, profile: GameProfile, @@ -28,53 +39,88 @@ class PlayerRecorder internal constructor( private val player: ServerPlayer? get() = this.server.playerList.getPlayer(this.recordingPlayerUUID) + /** + * The level that the player is currently in. + */ override val level: ServerLevel get() = this.getPlayerOrThrow().serverLevel() + /** + * Gets the player that's being recorded. + * If the player doesn't exist, an exception will be thrown. + * + * The exception will only be thrown *if* this method is called + * in the case a [PlayerRecorder] was started as a result of the + * player logging in and the player has not finished logging in yet. + * + * @return The player that is being recorded. + */ fun getPlayerOrThrow(): ServerPlayer { return this.player ?: throw IllegalStateException("Tried to get player before player joined") } + /** + * This gets the name of the replay recording. + * In the case for [PlayerRecorder]s it's just the name of + * the player. + * + * @return The name of the replay recording. + */ override fun getName(): String { return this.profile.name } - override fun start(): Boolean { + /** + * This starts the replay recording, note this is **not** called + * to start a replay if a player is being recorded from the login phase. + * + * This method should just simulate + */ + override fun initialize(): Boolean { val player = this.player ?: return false RejoinedReplayPlayer.rejoin(player, this) this.sendChunksAndEntities() return true } + /** + * This method tries to restart the replay recorder by creating + * a new instance of itself. + * + * @return Whether it successfully restarted. + */ override fun restart(): Boolean { + if (this.player == null) { + return false + } val recorder = PlayerRecorders.create(this.server, this.profile) - return recorder.tryStart(true) + return recorder.start(true) } - override fun closed(future: CompletableFuture) { + /** + * This updates the [PlayerRecorders] manager. + * + * @param future The future that will complete once the replay has closed. + */ + override fun onClosing(future: CompletableFuture) { PlayerRecorders.close(this.server, this, future) } - fun spawnPlayer(player: ServerEntity) { - val list = ArrayList>() - // The player parameter is never used, we can just pass in null - @Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS") - player.sendPairingData(null, list::add) - this.record(ClientboundBundlePacket(list)) - } - - fun removePlayer(player: ServerPlayer) { - this.record(ClientboundRemoveEntitiesPacket(player.id)) - } - - override fun canContinueRecording(): Boolean { - return this.player != null - } - + /** + * The player's chunk position. + * + * @return The player's chunk position. + */ override fun getCenterChunk(): ChunkPos { return this.getPlayerOrThrow().chunkPosition() } + /** + * This method iterates over all the chunk positions in the player's + * view distance accepting a [consumer]. + * + * @param consumer The consumer that will accept the given chunks positions. + */ override fun forEachChunk(consumer: Consumer) { val centerChunkX = this.getCenterChunk().x val centerChunkZ = this.getCenterChunk().z @@ -88,28 +134,63 @@ class PlayerRecorder internal constructor( } } + /** + * This records a packet. + * + * @param packet The packet to be recorded. + */ override fun sendPacket(packet: Packet<*>) { this.record(packet) } - override fun isValidEntity(entity: Entity): Boolean { - return true - } - - override fun shouldTrackEntity(tracking: Entity, range: Double): Boolean { - if (!super.shouldTrackEntity(tracking, range)) { - return false - } + /** + * This determines whether a given [entity] should be sent. + * Whether the entity is within the player's tracking range. + * + * @param entity The entity to check. + * @param range The entity's tracking range. + * @return Whether the entity should be tracked. + */ + override fun shouldTrackEntity(entity: Entity, range: Double): Boolean { val player = this.getPlayerOrThrow() - val delta = player.position().subtract(tracking.position()) + val delta = player.position().subtract(entity.position()) val deltaSqr = delta.x * delta.x + delta.z * delta.z val rangeSqr = range * range - return deltaSqr <= rangeSqr && tracking.broadcastToPlayer(player) + return deltaSqr <= rangeSqr && entity.broadcastToPlayer(player) + } + + /** + * This pairs the data of the tracked entity with the replay recorder. + * + * @param tracked The tracked entity. + */ + override fun addTrackedEntity(tracked: ChunkSender.WrappedTrackedEntity) { + val list = ArrayList>() + tracked.getServerEntity().sendPairingData(this.getPlayerOrThrow(), list::add) + this.record(ClientboundBundlePacket(list)) } - override fun addTrackedEntity(tracking: ChunkMap.TrackedEntity) { + /** + * This records the recording player. + * + * @param player The recording player's [ServerEntity]. + */ + @Internal + fun spawnPlayer(player: ServerEntity) { val list = ArrayList>() - (tracking as TrackedEntityAccessor).serverEntity.sendPairingData(this.getPlayerOrThrow(), list::add) + // The player parameter is never used, we can just pass in null + @Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS") + player.sendPairingData(null, list::add) this.record(ClientboundBundlePacket(list)) } + + /** + * This removes the recording player. + * + * @param player The recording player. + */ + @Internal + fun removePlayer(player: ServerPlayer) { + this.record(ClientboundRemoveEntitiesPacket(player.id)) + } } \ No newline at end of file diff --git a/src/main/kotlin/me/senseiwells/replay/player/PlayerRecorders.kt b/src/main/kotlin/me/senseiwells/replay/player/PlayerRecorders.kt index 1816a13..d1ffa05 100644 --- a/src/main/kotlin/me/senseiwells/replay/player/PlayerRecorders.kt +++ b/src/main/kotlin/me/senseiwells/replay/player/PlayerRecorders.kt @@ -2,29 +2,45 @@ package me.senseiwells.replay.player import com.mojang.authlib.GameProfile import me.senseiwells.replay.ServerReplay -import me.senseiwells.replay.recorder.ReplayRecorder import me.senseiwells.replay.recorder.RecorderRecoverer import me.senseiwells.replay.rejoin.RejoinedReplayPlayer import net.minecraft.server.MinecraftServer import net.minecraft.server.level.ServerPlayer import java.util.* import java.util.concurrent.CompletableFuture -import kotlin.collections.ArrayList +/** + * This object manages all [PlayerRecorder]s. + */ object PlayerRecorders { private val players = LinkedHashMap() private val closing = HashMap() + /** + * This creates a [PlayerRecorder] for a given [player]. + * + * @param player The player you want to record. + * @return The created recorder. + * @see create + */ @JvmStatic - fun create(player: ServerPlayer): ReplayRecorder { + fun create(player: ServerPlayer): PlayerRecorder { if (player is RejoinedReplayPlayer) { throw IllegalArgumentException("Cannot create a replay for a rejoining player") } return this.create(player.server, player.gameProfile) } + /** + * This creates a [PlayerRecorder] for a given [profile]. + * + * @param server The [MinecraftServer] instance. + * @param profile The profile of the player you are going to record. + * @return The created recorder. + * @see create + */ @JvmStatic - fun create(server: MinecraftServer, profile: GameProfile): ReplayRecorder { + fun create(server: MinecraftServer, profile: GameProfile): PlayerRecorder { if (this.players.containsKey(profile.id)) { throw IllegalArgumentException("Player already has a recorder") } @@ -39,26 +55,55 @@ object PlayerRecorders { return recorder } + /** + * Checks whether a player has a recorder. + * + * @param player The player to check. + * @return Whether the player has a recorder. + */ @JvmStatic fun has(player: ServerPlayer): Boolean { return this.players.containsKey(player.uuid) } + /** + * Gets a player recorder if one is present. + * + * @param player The player to get the recorder for. + * @return The [PlayerRecorder], null if it is not present. + */ @JvmStatic fun get(player: ServerPlayer): PlayerRecorder? { return this.getByUUID(player.uuid) } + /** + * Gets a player recorder if one is present using the + * player's UUID. + * + * @param uuid The uuid of the player to get the recorder for. + * @return The [PlayerRecorder], null if it is not present. + */ @JvmStatic fun getByUUID(uuid: UUID): PlayerRecorder? { return this.players[uuid] } + /** + * Gets a collection of all the currently recording player recorders. + * + * @return A collection of all the player recorders. + */ @JvmStatic fun recorders(): Collection { return ArrayList(this.players.values) } + /** + * Gets a collection of all the currently closing player recorders. + * + * @return A collection of all the closing player recorders. + */ @JvmStatic fun closing(): Collection { return ArrayList(this.closing.values) diff --git a/src/main/kotlin/me/senseiwells/replay/recorder/ChunkSender.kt b/src/main/kotlin/me/senseiwells/replay/recorder/ChunkSender.kt index b55dddb..3f81734 100644 --- a/src/main/kotlin/me/senseiwells/replay/recorder/ChunkSender.kt +++ b/src/main/kotlin/me/senseiwells/replay/recorder/ChunkSender.kt @@ -3,12 +3,15 @@ package me.senseiwells.replay.recorder import it.unimi.dsi.fastutil.ints.IntOpenHashSet import it.unimi.dsi.fastutil.ints.IntSet import me.senseiwells.replay.ServerReplay +import me.senseiwells.replay.chunk.ChunkRecorder import me.senseiwells.replay.mixin.rejoin.ChunkMapAccessor import me.senseiwells.replay.mixin.rejoin.TrackedEntityAccessor +import me.senseiwells.replay.player.PlayerRecorder import net.minecraft.network.protocol.Packet import net.minecraft.network.protocol.game.* import net.minecraft.server.level.ChunkMap import net.minecraft.server.level.ChunkMap.TrackedEntity +import net.minecraft.server.level.ServerEntity import net.minecraft.server.level.ServerLevel import net.minecraft.world.entity.Entity import net.minecraft.world.entity.Mob @@ -19,27 +22,72 @@ import org.jetbrains.annotations.ApiStatus.NonExtendable import java.util.function.Consumer import kotlin.math.min +/** + * This interface provides a way to resend any given chunks + * and any entities within those chunks. + * + * @see PlayerRecorder + * @see ChunkRecorder + */ interface ChunkSender { + /** + * The level of which the chunks are in. + */ val level: ServerLevel + /** + * The center chunk position of all the chunks being sent. + * + * @return The center most chunk position. + */ fun getCenterChunk(): ChunkPos + /** + * This will iterate over every chunk position that is going + * to be sent, each chunk position will be accepted into the + * [consumer]. + * + * @param consumer The consumer that will accept the given chunks positions. + */ fun forEachChunk(consumer: Consumer) + /** + * This determines whether a given [entity] should be sent. + * + * @param entity The entity to check. + * @param range The entity's tracking range. + * @return Whether the entity should be tracked. + */ + fun shouldTrackEntity(entity: Entity, range: Double): Boolean + + /** + * This method should consume a packet. + * This is used to send the chunk and entity packets. + * + * @param packet The packet to send. + */ fun sendPacket(packet: Packet<*>) - fun isValidEntity(entity: Entity): Boolean - - fun shouldTrackEntity(tracking: Entity, range: Double): Boolean { - return this.isValidEntity(tracking) - } - - fun addTrackedEntity(tracking: TrackedEntity) - + /** + * This is called when [shouldTrackEntity] returns `true`, + * this should be used to send any additional packets for this entity. + * + * @param tracked The [WrappedTrackedEntity]. + */ + fun addTrackedEntity(tracked: WrappedTrackedEntity) + + /** + * This gets the view distance of the server. + * + * @return The view distance of the server. + */ fun getViewDistance(): Int { return this.level.server.playerList.viewDistance } + /** + * This sends all chunk and entity packets. + */ @NonExtendable fun sendChunksAndEntities() { val seen = IntOpenHashSet() @@ -47,6 +95,11 @@ interface ChunkSender { this.sendChunkEntities(seen) } + /** + * This sends all chunk packets. + * + * @param seen The [IntSet] of entity ids that have already been seen. + */ @Internal fun sendChunks(seen: IntSet) { val center = this.getCenterChunk() @@ -67,6 +120,13 @@ interface ChunkSender { } } + /** + * This sends a specific chunk packet. + * + * @param chunks The [ChunkMap] containing all chunks. + * @param chunk The current chunk that is being sent. + * @param seen The [IntSet] of entity ids that have already been seen. + */ @Internal fun sendChunk( chunks: ChunkMap, @@ -90,11 +150,11 @@ interface ChunkSender { val viewDistance = this.level.server.playerList.viewDistance for (tracked in chunks.entityMap.values) { val entity = (tracked as TrackedEntityAccessor).entity - if (this.isValidEntity(entity) && entity.chunkPosition() == chunk.pos) { + if (entity.chunkPosition() == chunk.pos) { if (!seen.contains(entity.id)) { val range = min(tracked.getRange(), viewDistance * 16).toDouble() if (this.shouldTrackEntity(entity, range)) { - this.addTrackedEntity(tracked) + this.addTrackedEntity(WrappedTrackedEntity(tracked)) seen.add(entity.id) } } @@ -116,6 +176,11 @@ interface ChunkSender { } } + /** + * This sends all the entities. + * + * @param seen The [IntSet] of entity ids that have already been seen. + */ @Internal fun sendChunkEntities(seen: IntSet) { val chunks = this.level.chunkSource.chunkMap @@ -125,8 +190,36 @@ interface ChunkSender { val entity = (tracked as TrackedEntityAccessor).entity val range = min(tracked.getRange(), viewDistance * 16).toDouble() if (this.shouldTrackEntity(entity, range)) { - this.addTrackedEntity(tracked) + this.addTrackedEntity(WrappedTrackedEntity(tracked)) } } } + + /** + * We wrap the tracked entity into a new class because + * [TrackedEntity] by default is a package-private class. + * + * We don't want to force mods that need to implement [ChunkSender] + * to have an access-widener for this class. + */ + class WrappedTrackedEntity(val tracked: TrackedEntity) { + /** + * Gets the [Entity] being tracked. + * + * @return The tracked entity. + */ + @Suppress("unused") + fun getEntity(): Entity { + return (this.tracked as TrackedEntityAccessor).entity + } + + /** + * Gets the [ServerEntity] being tracked. + * + * @return The server entity. + */ + fun getServerEntity(): ServerEntity { + return (this.tracked as TrackedEntityAccessor).serverEntity + } + } } \ No newline at end of file diff --git a/src/main/kotlin/me/senseiwells/replay/recorder/ReplayRecorder.kt b/src/main/kotlin/me/senseiwells/replay/recorder/ReplayRecorder.kt index 8717ce2..0a00a49 100644 --- a/src/main/kotlin/me/senseiwells/replay/recorder/ReplayRecorder.kt +++ b/src/main/kotlin/me/senseiwells/replay/recorder/ReplayRecorder.kt @@ -16,7 +16,9 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.encodeToStream import me.senseiwells.replay.ServerReplay +import me.senseiwells.replay.chunk.ChunkRecorder import me.senseiwells.replay.config.ReplayConfig +import me.senseiwells.replay.player.PlayerRecorder import me.senseiwells.replay.util.* import net.minecraft.DetectedVersion import net.minecraft.SharedConstants @@ -45,8 +47,20 @@ import kotlin.time.Duration.Companion.milliseconds import com.github.steveice10.netty.buffer.Unpooled as ReplayUnpooled import net.minecraft.network.protocol.Packet as MinecraftPacket +/** + * This is the abstract class representing a replay recorder. + * + * This class is responsible for starting, stopping, and saving + * the replay files as well as recording all the packets. + * + * @param server The [MinecraftServer] instance. + * @param profile The profile of the player being recorded. + * @param recordings The replay recordings directory. + * @see PlayerRecorder + * @see ChunkRecorder + */ abstract class ReplayRecorder( - protected val server: MinecraftServer, + val server: MinecraftServer, protected val profile: GameProfile, private val recordings: Path ) { @@ -78,13 +92,29 @@ abstract class ReplayRecorder( private var ignore = false + /** + * The directory at which all the temporary replay + * files will be stored. + * This also determines the final location of the replay file. + */ val location: Path + /** + * Whether the replay recorder has stopped and + * is no longer recording any packets. + */ val stopped: Boolean get() = this.executor.isShutdown + + /** + * The [UUID] of the player the recording is of. + */ val recordingPlayerUUID: UUID get() = this.profile.id + /** + * The level that the replay recording is currently in. + */ abstract val level: ServerLevel init { @@ -100,6 +130,20 @@ abstract class ReplayRecorder( this.saveMeta() } + /** + * This records an outgoing clientbound packet to the + * replay file. + * + * This method will throw an exception if the recorder + * has not started recording yet. + * + * This method **is** thread-safe; however, it should be noted + * that any packet optimizations cannot be done if called off + * the main thread, therefore only calling this method on + * the main thread is preferable. + * + * @param outgoing The outgoing [MinecraftPacket]. + */ fun record(outgoing: MinecraftPacket<*>) { if (!this.started) { throw IllegalStateException("Cannot record packets if recorder not started") @@ -158,22 +202,42 @@ abstract class ReplayRecorder( this.checkDuration() } - fun tryStart(restart: Boolean = false): Boolean { - if (this.started) { - throw IllegalStateException("Cannot start recording after already started!") - } - if (this.start()) { + /** + * This tries to start this replay recorder and returns + * whether it was successful in doing so. + * + * @param restart Whether this is restarting a previous recording, `false` by default. + * @return `true` if the recording started successfully `false` otherwise. + */ + fun start(restart: Boolean = false): Boolean { + if (!this.started && this.initialize()) { this.logStart(restart) return true } + return false } + /** + * Logs that the replay has started/restarted to console and operators. + * + * @param restart Whether the log should state `"restarted"` or `"started"`. + */ @JvmOverloads fun logStart(restart: Boolean = false) { this.broadcastToOpsAndConsole("${if (restart) "Restarted" else "Started"} replay for ${this.getName()}") } + /** + * Stops the replay recorder and returns a future which will be completed + * when the file has completed saving or closing. + * + * A failed future will be returned if the replay is already stopped. + * + * @param save Whether the recorded replay should be saved to disk, `true` by default. + * @return A future which will be completed after the recording has finished saving or + * closing, this completes with the file size of the final compressed replay in bytes. + */ @JvmOverloads fun stop(save: Boolean = true): CompletableFuture { if (this.stopped) { @@ -186,18 +250,51 @@ abstract class ReplayRecorder( // We only save if the player has actually logged in... val future = this.close(save && this.protocol == ConnectionProtocol.PLAY) - this.closed(future) + this.onClosing(future) return future } + /** + * This returns the total amount of time (in milliseconds) that + * has elapsed since the recording has started, this does not + * account for any pauses. + * + * @return The total amount of time (in milliseconds) that has + * elapsed since the start of the recording. + */ fun getTotalRecordingTime(): Long { return System.currentTimeMillis() - this.start } + /** + * This returns the raw (uncompressed) file size of the replay in bytes. + * + * @return The raw file size of the replay in bytes. + */ fun getRawRecordingSize(): Long { return this.replay.getRawFileSize() } + /** + * This returns a future which will provide the compressed file + * size of the replay in bytes. + * + * Be careful when calling this function - to calculate the compressed + * file size, we must zip the entire raw replay which can be very + * expensive. + * + * This will not always be accurate since if you do not force compress, + * then it may return the last compressed size if it predicts that + * the current size is likely very similar to the last size it calculated. + * Further, these futures may take extremely long to complete (can be tens + * of minutes, depending on the raw file size), and by the time the compression + * is complete the replay size may have already changed significantly. + * + * @param force Whether to force compress (which yields a more up-to-date value), `false` by default. + * @return A future which will complete after the compression is complete, providing the + * compressed file size in bytes. + * @see getRawRecordingSize + */ fun getCompressedRecordingSize(force: Boolean = false): CompletableFuture { val current = this.currentSizeFuture if (current != null) { @@ -228,6 +325,14 @@ abstract class ReplayRecorder( return future } + /** + * This creates a future which will provide the status of the + * replay recorder as a formatted string. + * The status may include the compressed file size which is + * this method provides a future, see [getCompressedRecordingSize]. + * + * @return A future that will provide the status of the replay recorder. + */ fun getStatusWithSize(): CompletableFuture { val builder = ToStringBuilder(this, StandardToStringStyle().apply { fieldSeparator = ", " @@ -255,38 +360,24 @@ abstract class ReplayRecorder( return CompletableFuture.completedFuture(builder.toString()) } - @Internal - fun getDebugPacketData(): String { - return this.packets.values - .sortedByDescending { it.size } - .joinToString(separator = "\n", transform = DebugPacketData::format) - } - - @Internal - fun afterLogin() { - this.started = true - this.start = System.currentTimeMillis() - - // We will not have recorded this, so we need to do it manually. - this.record(ClientboundGameProfilePacket(this.profile)) - - this.protocol = ConnectionProtocol.PLAY - } - - protected fun ignore(block: () -> Unit) { - val previous = this.ignore - try { - this.ignore = true - block() - } finally { - this.ignore = previous - } - } - + /** + * This gets the current timestamp (in milliseconds) of the replay recording. + * + * By default, this is the same as [getTotalRecordingTime] however this + * may be overridden to account for pauses in the replay. + * + * @return The timestamp of the recording (in milliseconds). + */ open fun getTimestamp(): Long { return this.getTotalRecordingTime() } + /** + * This appends any additional data to the status. + * + * @param builder The [ToStringBuilder] which is used to build the status. + * @see getStatusWithSize + */ protected open fun appendToStatus(builder: ToStringBuilder) { val duration = this.nextFileCheckTime - System.currentTimeMillis() if (duration > 0) { @@ -294,6 +385,12 @@ abstract class ReplayRecorder( } } + /** + * This allows you to add any additional metadata which will be + * saved in the replay file. + * + * @param map The JSON metadata map which can be mutated. + */ protected open fun addMetadata(map: MutableMap) { map["name"] = JsonPrimitive(this.getName()) map["settings"] = ReplayConfig.toJson(ServerReplay.config) @@ -307,20 +404,92 @@ abstract class ReplayRecorder( map["next_file_check"] = JsonPrimitive(this.nextFileCheckTime) } + /** + * This gets the name of the replay recording. + * + * @return The name of the replay recording. + */ abstract fun getName(): String - protected abstract fun start(): Boolean - + /** + * This starts the replay recording, note this is **not** called + * to start a replay if a player is being recorded from the login phase. + * + * This method should just simulate + */ + protected abstract fun initialize(): Boolean + + /** + * This method tries to restart the replay recorder by creating + * a new instance of itself. + * + * @return Whether it successfully restarted. + */ protected abstract fun restart(): Boolean - protected abstract fun closed(future: CompletableFuture) - - protected abstract fun canContinueRecording(): Boolean - + /** + * This gets called when the replay is closing. + * + * @param future The future that will complete once the replay has closed. + */ + protected abstract fun onClosing(future: CompletableFuture) + + /** + * Determines whether a given packet is able to be recorded. + * + * @param packet The packet that is going to be recorded. + * @return Whether this recorded should record it. + */ protected open fun canRecordPacket(packet: MinecraftPacket<*>): Boolean { return true } + /** + * Calling this ignores any packets that would've been + * recorded by this recorder inside the [block] function. + * + * @param block The function to call while ignoring packets. + */ + protected fun ignore(block: () -> Unit) { + val previous = this.ignore + try { + this.ignore = true + block() + } finally { + this.ignore = previous + } + } + + /** + * This method formats all the debug packet data + * into a string. + * + * @return The formatted debug packet data. + */ + @Internal + fun getDebugPacketData(): String { + return this.packets.values + .sortedByDescending { it.size } + .joinToString(separator = "\n", transform = DebugPacketData::format) + } + + /** + * This method should be called after the player that is being + * recorded has logged in. + * This will mark the replay recorder as being started and will + * change the replay recording phase into `PLAY`. + */ + @Internal + fun afterLogin() { + this.started = true + this.start = System.currentTimeMillis() + + // We will not have recorded this, so we need to do it manually. + this.record(ClientboundGameProfilePacket(this.profile)) + + this.protocol = ConnectionProtocol.PLAY + } + private fun prePacket(packet: MinecraftPacket<*>): Boolean { when (packet) { is ClientboundAddPlayerPacket -> { @@ -357,7 +526,7 @@ abstract class ReplayRecorder( this.broadcastToOpsAndConsole( "Stopped recording replay for ${this.getName()}, past duration limit ${maxDuration}!" ) - if (ServerReplay.config.restartAfterMaxDuration && this.canContinueRecording()) { + if (ServerReplay.config.restartAfterMaxDuration) { this.restart() } } @@ -423,7 +592,7 @@ abstract class ReplayRecorder( this.broadcastToOpsAndConsole( "Stopped recording replay for ${this.getName()}, over max file size ${maxFileSize.raw}!" ) - if (ServerReplay.config.restartAfterMaxFileSize && this.canContinueRecording()) { + if (ServerReplay.config.restartAfterMaxFileSize) { this.restart() } } else { diff --git a/src/main/kotlin/me/senseiwells/replay/rejoin/RejoinedReplayPlayer.kt b/src/main/kotlin/me/senseiwells/replay/rejoin/RejoinedReplayPlayer.kt index 36de8c0..8e80fcc 100644 --- a/src/main/kotlin/me/senseiwells/replay/rejoin/RejoinedReplayPlayer.kt +++ b/src/main/kotlin/me/senseiwells/replay/rejoin/RejoinedReplayPlayer.kt @@ -1,6 +1,6 @@ package me.senseiwells.replay.rejoin -import me.senseiwells.replay.api.ReplaySenders +import me.senseiwells.replay.api.ReplayPluginManager import me.senseiwells.replay.chunk.ChunkRecorder import me.senseiwells.replay.mixin.common.PlayerListAccessor import me.senseiwells.replay.ducks.`ServerReplay$PackTracker` @@ -129,10 +129,10 @@ class RejoinedReplayPlayer private constructor( this.recorder.record(ClientboundUpdateMobEffectPacket(this.id, mobEffectInstance)) } - for (sender in ReplaySenders.senders) { + for (plugin in ReplayPluginManager.plugins) { when (this.recorder) { - is PlayerRecorder -> sender.recordAdditionalPlayerPackets(this.recorder) - is ChunkRecorder -> sender.recordAdditionalChunkPackets(this.recorder) + is PlayerRecorder -> plugin.onPlayerReplayStart(this.recorder) + is ChunkRecorder -> plugin.onChunkReplayStart(this.recorder) } } } diff --git a/src/main/kotlin/me/senseiwells/replay/util/ReplayOptimizerUtils.kt b/src/main/kotlin/me/senseiwells/replay/util/ReplayOptimizerUtils.kt index 9f091e9..f71b187 100644 --- a/src/main/kotlin/me/senseiwells/replay/util/ReplayOptimizerUtils.kt +++ b/src/main/kotlin/me/senseiwells/replay/util/ReplayOptimizerUtils.kt @@ -15,6 +15,7 @@ import net.minecraft.world.entity.item.PrimedTnt import net.minecraft.world.entity.projectile.Projectile object ReplayOptimizerUtils { + // Set of packets that are ignored by replay mod private val IGNORED = setOf>>( ClientboundBlockChangedAckPacket::class.java, ClientboundOpenBookPacket::class.java, @@ -36,21 +37,25 @@ object ReplayOptimizerUtils { ClientboundCustomChatCompletionsPacket::class.java, ClientboundCommandsPacket::class.java ) + // Set of all chat related packs private val CHAT = setOf>>( ClientboundPlayerChatPacket::class.java, ClientboundDeleteChatPacket::class.java, ClientboundSystemChatPacket::class.java, ClientboundDisguisedChatPacket::class.java ) + // Set of all scoreboard-related packets private val SCOREBOARD = setOf>>( ClientboundSetScorePacket::class.java, ClientboundSetObjectivePacket::class.java, ClientboundSetDisplayObjectivePacket::class.java ) + // Set of all sound related packets private val SOUNDS = setOf>>( ClientboundSoundPacket::class.java, ClientboundSoundEntityPacket::class.java ) + // Set of all packets related to entity movement private val ENTITY_MOVEMENT = setOf>>( ClientboundMoveEntityPacket.Pos::class.java, ClientboundTeleportEntityPacket::class.java, @@ -109,6 +114,7 @@ object ReplayOptimizerUtils { val mapper = ENTITY_MAPPERS[type] ?: return false val entity = mapper(packet, recorder.level) ?: return false + // The client can calculate TNT and projectile movement itself. if (entity is PrimedTnt) { return true } @@ -118,6 +124,9 @@ object ReplayOptimizerUtils { return false } + // Explosion packets are huge... + // They contain way more data than they need to. + // We only really need to send the client the explosion sound and particles. private fun optimiseExplosions(recorder: ReplayRecorder, packet: ClientboundExplodePacket) { // Based on Explosion#finalizeExplosion val random = recorder.level.random diff --git a/src/main/kotlin/me/senseiwells/replay/util/SizedZipReplayFile.kt b/src/main/kotlin/me/senseiwells/replay/util/SizedZipReplayFile.kt index af45ca2..633f6d5 100644 --- a/src/main/kotlin/me/senseiwells/replay/util/SizedZipReplayFile.kt +++ b/src/main/kotlin/me/senseiwells/replay/util/SizedZipReplayFile.kt @@ -5,11 +5,8 @@ import com.replaymod.replaystudio.studio.ReplayStudio import com.replaymod.replaystudio.util.Utils import me.senseiwells.replay.ServerReplay import me.senseiwells.replay.mixin.studio.ZipReplayFileAccessor -import org.apache.commons.io.FileUtils as ApacheFileUtils import org.apache.commons.lang3.mutable.MutableLong -import java.io.BufferedInputStream import java.io.File -import java.io.FileInputStream import java.io.OutputStream import java.util.* import java.util.concurrent.CompletableFuture @@ -17,7 +14,7 @@ import java.util.concurrent.Executor import java.util.zip.ZipEntry import java.util.zip.ZipFile import java.util.zip.ZipOutputStream -import kotlin.collections.ArrayList +import org.apache.commons.io.FileUtils as ApacheFileUtils class SizedZipReplayFile( input: File? = null, diff --git a/src/main/resources/assets/serverreplay/icon.png b/src/main/resources/assets/server-replay/icon.png similarity index 100% rename from src/main/resources/assets/serverreplay/icon.png rename to src/main/resources/assets/server-replay/icon.png diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 2829b59..99be350 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -24,6 +24,12 @@ "adapter": "kotlin", "value": "me.senseiwells.replay.compat.voicechat.ReplayVoicechatPlugin" } + ], + "server_replay": [ + { + "adapter": "kotlin", + "value": "me.senseiwells.replay.compat.voicechat.ReplayVoicechatPlugin" + } ] }, "accessWidener": "serverreplay.accesswidener",