From 1ab261091f15d84a5d17fe5bf2804b49842286f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?tanh=E4=B8=B6=E6=A1=81?= <100672377+tanhHeng@users.noreply.github.com> Date: Tue, 5 Mar 2024 21:51:14 +0800 Subject: [PATCH 01/16] Update README.md --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e1d0d75..2369b92 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # 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. @@ -418,4 +420,4 @@ class ExampleMod: ModInitializer { } } } -``` \ No newline at end of file +``` From 8eb410d550fa45f4a59a8f836bd5860039c3c96b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?tanh=E4=B8=B6=E6=A1=81?= <100672377+tanhHeng@users.noreply.github.com> Date: Tue, 5 Mar 2024 21:52:33 +0800 Subject: [PATCH 02/16] Create README_cn.md --- README_cn.md | 419 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 419 insertions(+) create mode 100644 README_cn.md diff --git a/README_cn.md b/README_cn.md new file mode 100644 index 0000000..4ae98f8 --- /dev/null +++ b/README_cn.md @@ -0,0 +1,419 @@ +# Server Replay + +译 / tanh_Heng + +一个只在服务端生效的replay模组,它允许你在服务器上一次性同时录制多个在线玩家或某一片区块,然后产生一个可以被客户端replay模组使用的录制文件用来渲染。 + +## 为什么选择服务端? + +与客户端的Replay Mod相比,在服务端录制有着许多好处: + +* 能够录制固定的区块 + + + 你可以指定确切的区块大小(不受服务端视距的影响) + + + 这些记录的区块可以在不影响replay的情况下卸载 + + - 区块不会在卸载与加载的过程中闪烁 + + - 这些区块也不会被recorder录制器加载(不像PCRC一样会手动加载区块) + + - recorder录制器会跳过区块被卸载的时间 + + - *这里指的是录制器不会手动加载那些本应被卸载的区块(只有当区块被加载时才会进行记录),并且区块的加载与卸载不会对记录产生影响。————译者注* + +* 能够录制玩家 + + + 玩家不需要安装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` 允许模组按照给定的规则(详见 [Predicates](#predicates-config) 部分)自动记录玩家。 + +- `/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, + "include_compressed_in_status": true, + "recover_unsaved_replays": 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, + "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` 。

| +| `"pause_unloaded_chunks"` |

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

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

| +| `"pause_notify_players"` |

If `pause_unloaded_chunks` is enabled and this is enabled then when the recording for the chunk area is paused or resumed all online players will be notified.

| +| `"notify_admins_of_status"` |

When enabled this will notify admins of when a replay starts, when a replay ends, and when a replay has finished saving, as well as any errors that occur.

| +| `"restart_after_max_file_size"` |

If a max file size is set and this limit is reached then the replay recording will automatically restart creating a new replay file.

| +| `"include_compressed_in_status"` |

Includes the compressed file size of the replays when you do `/replay status`, for long replays this may cause the status message to take a while to be displayed, so you can disable it.

| +| `"recover_unsaved_replays"` |

This tries to recover any unsaved replays, for example if your server crashes or stops before a replay is stopped or has finished saving, this does not guarantee that the replay will not be corrupt, but it will try to salvage what is available.

| +| `"fixed_daylight_cycle"` |

This fixes the daylight cycle in the replay if you do not want the constant day-night cycle in long timelapses. This should be set to the time of day in ticks, e.g. `6000` (midday). To disable the fixed daylight cycle set the value to `-1`.

| +| `"fix_carpet_bot_view_distance"` |

If you are recording carpet bots you want to enable this as it sets the view distance to the server view distance. Otherwise it will only record a distance of 2 chunks around the bot.

| +| `"ignore_sound_packets"` |

If you are recording a large area for a timelapse it's unlikely you'll want to record any sounds, these can eat up significant storage space.

| +| `"ignore_light_packets"` |

Light is calculated on the client as well as on the server so light packets are mostly redundant.

| +| `"ignore_chat_packets"` |

Stops chat packets (from both the server and other players) from being recorded if they are not necessary for your replay.

| +| `"ignore_scoreboard_packets"` |

Stops scoreboard packets from being recorded (for example, if you have a scoreboard displaying digs then this will not appear, and player's scores will also not be recorded).

| +| `"optimize_explosion_packets"` |

This reduces the file size greatly by not sending the client explosion packets instead just sending the explosion particles and sounds.

| +| `"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.

| +| `"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 Config + +You can define chunk areas to be recorded automatically when the server starts or when +you enable ServerReplay. + +Each chunk definition must include: `"name"`, `"dimension"`, `"from_x"`, `"to_x"`, `"from_z"`, and `"to_z"`. For example: +```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 + } + // ... + ] +} +``` + +### Predicates Config + +You can define a predicate, which determines which players on your server +will be recorded automatically. +You can do this by specifying whether players have a specific uuid, +name, are on a specific team, or whether they are an operator. + +After defining a predicate you must run `/replay reload` in game then players must +re-log if they want to be recorded (and meet the predicate criteria). + +Most basic option is just to record all players in which case you can use: +```json5 +{ + // ... + "player_predicate": { + "type": "all" + } +} +``` + +If you wanted to only record players with specific names or uuids you can do the following: +```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" + ] + } +} +``` + +If you only wanted to record operators: +```json5 +{ + // ... + "player_predicate": { + "type": "has_op", + "level": 4 + } +} +``` + +If you only want to record players on specific teams, this is useful for allowing players to be +added and removed in-game, as you can just add players to a team and then have them re-log: +```json5 +{ + // ... + "player_predicate": { + "type": "in_team", + "teams": [ + "Red", + "Blue", + "Spectators" + ] + } +} +``` + +You are also able to negate predicates, using 'not' and combine them using 'or' and 'and'. +For example, if you wanted to record all non-operators that also don't have the name 'senseiwells' or is on the red team: +```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" + ] + } + ] + } + } + ] + } +} +``` + +If you are using carpet mod and have the ability to spawn fake players you may want to exclude them from being recorded. +You can do this with the `is_fake` predicate: +```json5 +{ + // ... + "player_predicate": { + "type": "not", + "predicate": { + "type": "is_fake" + } + } +} +``` + +## Developers + +If you want more control over, when players are recorded, you can implement this into your own mod. + +To implement the API into your project, you can add the +following to your `build.gradle.kts` + +```kts +repositories { + maven { + url = uri("https://jitpack.io") + } +} + +dependencies { + // For the most recent version use the latest commit hash + val version = "e444c355ad" + modImplementation("com.github.Senseiwells:ServerReplay:$version") +} +``` + +Here's a basic example of what you can do: +```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.tryStart(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.tryStart(log = false) + } + } +} +``` From 65af14ccf19a725ac4db602bda65323a44a7a6ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?tanh=E4=B8=B6=E6=A1=81?= <100672377+tanhHeng@users.noreply.github.com> Date: Tue, 5 Mar 2024 21:53:12 +0800 Subject: [PATCH 03/16] Update README_cn.md --- README_cn.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README_cn.md b/README_cn.md index 4ae98f8..bbba641 100644 --- a/README_cn.md +++ b/README_cn.md @@ -1,5 +1,7 @@ # Server Replay +[English](./README.md) | **中文** + 译 / tanh_Heng 一个只在服务端生效的replay模组,它允许你在服务器上一次性同时录制多个在线玩家或某一片区块,然后产生一个可以被客户端replay模组使用的录制文件用来渲染。 From 1906c2799d7c5431c746cf23c18a08577ea3ea95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?tanh=E4=B8=B6=E6=A1=81?= <100672377+tanhHeng@users.noreply.github.com> Date: Thu, 7 Mar 2024 22:04:22 +0800 Subject: [PATCH 04/16] Update README_cn.md --- README_cn.md | 82 +++++++++++++++++++++++++--------------------------- 1 file changed, 39 insertions(+), 43 deletions(-) diff --git a/README_cn.md b/README_cn.md index bbba641..d4eb07c 100644 --- a/README_cn.md +++ b/README_cn.md @@ -2,7 +2,7 @@ [English](./README.md) | **中文** -译 / tanh_Heng +*译 / tanh_Heng* 一个只在服务端生效的replay模组,它允许你在服务器上一次性同时录制多个在线玩家或某一片区块,然后产生一个可以被客户端replay模组使用的录制文件用来渲染。 @@ -18,9 +18,9 @@ - 区块不会在卸载与加载的过程中闪烁 - - 这些区块也不会被recorder录制器加载(不像PCRC一样会手动加载区块) + - 这些区块也不会被录制器加载(不像PCRC一样会手动加载区块) - - recorder录制器会跳过区块被卸载的时间 + - 录制器可以跳过区块被卸载的时间 - *这里指的是录制器不会手动加载那些本应被卸载的区块(只有当区块被加载时才会进行记录),并且区块的加载与卸载不会对记录产生影响。————译者注* @@ -80,7 +80,7 @@ 此文件然后可以被放在客户端的`./replay_recordings`文件夹下,并用客户端replay模组打开。 -**重要提示:** 如果你要记录carpet的假人,你大概率需要在设置中打开`"fix_carpet_bot_view_distance"`,否则只有假人周围的2个区块会被记录。 +**重要提示:** 如果你要记录carpet的假人,你大概率需要在设置中打开`"fix_carpet_bot_view_distance"`,否则只有假人周围的2个区块的距离内会被记录。 #### 区块 @@ -188,28 +188,27 @@ | `"chunk_recording_path"` |

区块录制的保存路径。

| | `"max_file_size"` |

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

如果录制达到了这个限制,录制器将会停止。若要不对其进行限制,将此选项设置为 `0` 。

| | `"pause_unloaded_chunks"` |

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

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

| -| `"pause_notify_players"` |

If `pause_unloaded_chunks` is enabled and this is enabled then when the recording for the chunk area is paused or resumed all online players will be notified.

| -| `"notify_admins_of_status"` |

When enabled this will notify admins of when a replay starts, when a replay ends, and when a replay has finished saving, as well as any errors that occur.

| -| `"restart_after_max_file_size"` |

If a max file size is set and this limit is reached then the replay recording will automatically restart creating a new replay file.

| -| `"include_compressed_in_status"` |

Includes the compressed file size of the replays when you do `/replay status`, for long replays this may cause the status message to take a while to be displayed, so you can disable it.

| -| `"recover_unsaved_replays"` |

This tries to recover any unsaved replays, for example if your server crashes or stops before a replay is stopped or has finished saving, this does not guarantee that the replay will not be corrupt, but it will try to salvage what is available.

| -| `"fixed_daylight_cycle"` |

This fixes the daylight cycle in the replay if you do not want the constant day-night cycle in long timelapses. This should be set to the time of day in ticks, e.g. `6000` (midday). To disable the fixed daylight cycle set the value to `-1`.

| -| `"fix_carpet_bot_view_distance"` |

If you are recording carpet bots you want to enable this as it sets the view distance to the server view distance. Otherwise it will only record a distance of 2 chunks around the bot.

| -| `"ignore_sound_packets"` |

If you are recording a large area for a timelapse it's unlikely you'll want to record any sounds, these can eat up significant storage space.

| -| `"ignore_light_packets"` |

Light is calculated on the client as well as on the server so light packets are mostly redundant.

| -| `"ignore_chat_packets"` |

Stops chat packets (from both the server and other players) from being recorded if they are not necessary for your replay.

| -| `"ignore_scoreboard_packets"` |

Stops scoreboard packets from being recorded (for example, if you have a scoreboard displaying digs then this will not appear, and player's scores will also not be recorded).

| -| `"optimize_explosion_packets"` |

This reduces the file size greatly by not sending the client explosion packets instead just sending the explosion particles and sounds.

| -| `"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.

| -| `"player_predicate"` |

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

| +| `"pause_notify_players"` |

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

| +| `"notify_admins_of_status"` |

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

| +| `"restart_after_max_file_size"` |

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

| +| `"include_compressed_in_status"` |

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

| +| `"recover_unsaved_replays"` |

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

| +| `"fixed_daylight_cycle"` |

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

| +| `"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。这可能会导致一些不一致,但这大概可以被忽略不计。

| +| `"player_predicate"` |

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

| | `"chunks"` |

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

| -### Chunks Config +### 区块设置 -You can define chunk areas to be recorded automatically when the server starts or when -you enable ServerReplay. +你可以定义当服务端启动或你启用了ServerReplay时,要被自动录制的区块区域。 -Each chunk definition must include: `"name"`, `"dimension"`, `"from_x"`, `"to_x"`, `"from_z"`, and `"to_z"`. For example: +每一个区块的定义必须包含:`"name"`, `"dimension"`, `"from_x"`, `"to_x"`, `"from_z"`, and `"to_z"`。例如: ```json5 { // ... @@ -235,17 +234,16 @@ Each chunk definition must include: `"name"`, `"dimension"`, `"from_x"`, `"to_x" } ``` -### Predicates Config +### 匹配规则设置 -You can define a predicate, which determines which players on your server -will be recorded automatically. -You can do this by specifying whether players have a specific uuid, -name, are on a specific team, or whether they are an operator. +*其实这个东西应该叫“断言”。————译者注* -After defining a predicate you must run `/replay reload` in game then players must -re-log if they want to be recorded (and meet the predicate criteria). +你可以定义一个匹配规则,它将决定服务端上要被自动录制的玩家。 +你可以通过指定某个玩家是否有特定的uuid,名字,在某个特定的队伍里,或是否是一个管理员,来设置此规则。 -Most basic option is just to record all players in which case you can use: +在定义规则后,你必须在游戏中运行 `/replay reload`,同时玩家要想被自动录制,他们必须重新登录服务器(且满足匹配规则)。 + +最基本的选项是记录所有玩家,在这种情况下,您可以使用: ```json5 { // ... @@ -255,7 +253,7 @@ Most basic option is just to record all players in which case you can use: } ``` -If you wanted to only record players with specific names or uuids you can do the following: +如果你想要只记录带有特定名字或uuid的玩家,你可以使用: ```json5 { // ... @@ -285,7 +283,7 @@ If you wanted to only record players with specific names or uuids you can do the } ``` -If you only wanted to record operators: +如果你只想要记录管理员: ```json5 { // ... @@ -296,8 +294,7 @@ If you only wanted to record operators: } ``` -If you only want to record players on specific teams, this is useful for allowing players to be -added and removed in-game, as you can just add players to a team and then have them re-log: +如果你只想要记录在特定队伍中的玩家,这一选项可以支持玩家在游戏中被加入或移除队伍,因此你可以只玩家加入队伍,然后让他们重新登录(*来自动记录该玩家 ————译者注*)。 ```json5 { // ... @@ -312,8 +309,8 @@ added and removed in-game, as you can just add players to a team and then have t } ``` -You are also able to negate predicates, using 'not' and combine them using 'or' and 'and'. -For example, if you wanted to record all non-operators that also don't have the name 'senseiwells' or is on the red team: +你还可以使用否定规则,用 `not` 然后用 `or` 和 `and` 连接。 +例如,如果你想要记录非管理员且玩家名不为 `senseiwells` 的玩家或在红队中的玩家: ```json5 { // ... @@ -352,8 +349,8 @@ For example, if you wanted to record all non-operators that also don't have the } ``` -If you are using carpet mod and have the ability to spawn fake players you may want to exclude them from being recorded. -You can do this with the `is_fake` predicate: +如果你正在使用carpet模组且能够召唤假人,你可能会想让假人不被自动记录。 +你可以使用 `is_fake` 条件来实现: ```json5 { // ... @@ -366,12 +363,11 @@ You can do this with the `is_fake` predicate: } ``` -## Developers +## 开发者 -If you want more control over, when players are recorded, you can implement this into your own mod. +如果你想要玩家被记录时的更多的控制权,你可以在你的模组中接入此方法。 -To implement the API into your project, you can add the -following to your `build.gradle.kts` +要在你的项目中接入API,你可以下面内容加入你的 `build.gradle.kts` 中: ```kts repositories { @@ -387,7 +383,7 @@ dependencies { } ``` -Here's a basic example of what you can do: +这里有一个最基本的例子: ```kt class ExampleMod: ModInitializer { override fun onInitialize() { From 39df15e2139aaabb327548b06535ff5824c98724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?tanh=E4=B8=B6=E6=A1=81?= <100672377+tanhHeng@users.noreply.github.com> Date: Thu, 7 Mar 2024 22:11:42 +0800 Subject: [PATCH 05/16] Update README_cn.md --- README_cn.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README_cn.md b/README_cn.md index d4eb07c..28adf28 100644 --- a/README_cn.md +++ b/README_cn.md @@ -118,7 +118,7 @@ 注意:对于所有的指令,玩家必须要有等级4的op权限,或如果你有一个权限模组(例如[LuckPerms](https://luckperms.net/)),玩家可以在拥有权限节点 `replay.commands.replay` 时使用这些指令。 -- `/replay enable` 允许模组按照给定的规则(详见 [Predicates](#predicates-config) 部分)自动记录玩家。 +- `/replay enable` 允许模组按照给定的规则(详见 [匹配规则](#predicates-config) 部分)自动记录玩家。 - `/replay disable` 禁止模组自动录制玩家,这将会同时停止当前的所有的玩家录制和区块录制。 @@ -310,7 +310,7 @@ ``` 你还可以使用否定规则,用 `not` 然后用 `or` 和 `and` 连接。 -例如,如果你想要记录非管理员且玩家名不为 `senseiwells` 的玩家或在红队中的玩家: +例如,如果你想要记录非管理员且玩家名不为 `senseiwells` 的玩家,或在红队中的玩家: ```json5 { // ... From e3ce456b7e127a8e86c7578539b65cb1540c1cba Mon Sep 17 00:00:00 2001 From: senseiwells Date: Thu, 7 Mar 2024 14:32:13 +0000 Subject: [PATCH 06/16] Merge main - updated readme --- README.md | 2 +- README_cn.md | 59 ++++++++++++++++++++++++++++------------------------ 2 files changed, 33 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 7d78807..cc8dd97 100644 --- a/README.md +++ b/README.md @@ -388,7 +388,7 @@ repositories { dependencies { // For the most recent version, use the latest commit hash - modImplementation("com.github.Senseiwells:ServerReplay:53b0795bb4") + modImplementation("com.github.Senseiwells:ServerReplay:281e9e0ec0") } ``` diff --git a/README_cn.md b/README_cn.md index 28adf28..b50bc0e 100644 --- a/README_cn.md +++ b/README_cn.md @@ -159,8 +159,10 @@ "player_recording_path": "./recordings/players", "max_file_size": "0GB", "restart_after_max_file_size": false, - "include_compressed_in_status": true, + "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, @@ -172,6 +174,7 @@ "ignore_scoreboard_packets": false, "optimize_explosion_packets": true, "optimize_entity_packets": false, + "record_voice_chat": false, "player_predicate": { "type": "none" }, @@ -179,30 +182,33 @@ } ``` -| 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` 。

| -| `"pause_unloaded_chunks"` |

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

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

| -| `"pause_notify_players"` |

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

| -| `"notify_admins_of_status"` |

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

| -| `"restart_after_max_file_size"` |

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

| -| `"include_compressed_in_status"` |

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

| -| `"recover_unsaved_replays"` |

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

| -| `"fixed_daylight_cycle"` |

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

| -| `"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。这可能会导致一些不一致,但这大概可以被忽略不计。

| -| `"player_predicate"` |

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

| -| `"chunks"` |

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

| +| 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`。

如果录制达到了这个限制,录制器将会停止。This is only approximate, expect the real file size to be slightly larger. 若要不对其进行限制,将此选项设置为 `0` 。

Be warned that this may impact server performance if your max file size is large, in order to check whether a file is too big (`>5GB`) it must be compressed which can be very expensive. You may check the time until the next file size check by running `/replay status`.

| +| `"restart_after_max_file_size"` |

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

| +| `"max_duration"` |

Sets the maximum duration for a replay, once the replay has recorded for the specified amount of time it will stop, this is any number followed by units (you may also have multiple units), e.g. `4h 35m 2.1s`. Set this to `0` to not have a max duration limit. Note: if a recorder is paused it's duration does not increase.

| +| `"restart_after_max_duration"` |

If the `max_duration` is set and this limit is reached then the replay recording will automatically restart creating a new replay file.

| +| `"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"` |

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"` |

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

| +| `"chunks"` |

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

| ### 区块设置 @@ -378,8 +384,7 @@ repositories { dependencies { // For the most recent version use the latest commit hash - val version = "e444c355ad" - modImplementation("com.github.Senseiwells:ServerReplay:$version") + modImplementation("com.github.Senseiwells:ServerReplay:281e9e0ec0") } ``` From ec72f737370392c1ccde85088418eb085ed76edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?tanh=E4=B8=B6=E6=A1=81?= <100672377+tanhHeng@users.noreply.github.com> Date: Thu, 7 Mar 2024 23:24:01 +0800 Subject: [PATCH 07/16] Update README_cn.md --- README_cn.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README_cn.md b/README_cn.md index b50bc0e..dee0eb3 100644 --- a/README_cn.md +++ b/README_cn.md @@ -118,7 +118,7 @@ 注意:对于所有的指令,玩家必须要有等级4的op权限,或如果你有一个权限模组(例如[LuckPerms](https://luckperms.net/)),玩家可以在拥有权限节点 `replay.commands.replay` 时使用这些指令。 -- `/replay enable` 允许模组按照给定的规则(详见 [匹配规则](#predicates-config) 部分)自动记录玩家。 +- `/replay enable` 允许模组按照给定的规则(详见 [匹配规则](#匹配规则设置) 部分)自动记录玩家。 - `/replay disable` 禁止模组自动录制玩家,这将会同时停止当前的所有的玩家录制和区块录制。 @@ -189,10 +189,10 @@ | `"server_name"` |

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

| | `"player_recording_path"` |

玩家录制的保存路径。

| | `"chunk_recording_path"` |

区块录制的保存路径。

| -| `"max_file_size"` |

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

如果录制达到了这个限制,录制器将会停止。This is only approximate, expect the real file size to be slightly larger. 若要不对其进行限制,将此选项设置为 `0` 。

Be warned that this may impact server performance if your max file size is large, in order to check whether a file is too big (`>5GB`) it must be compressed which can be very expensive. You may check the time until the next file size check by running `/replay status`.

| +| `"max_file_size"` |

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

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

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

| | `"restart_after_max_file_size"` |

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

| -| `"max_duration"` |

Sets the maximum duration for a replay, once the replay has recorded for the specified amount of time it will stop, this is any number followed by units (you may also have multiple units), e.g. `4h 35m 2.1s`. Set this to `0` to not have a max duration limit. Note: if a recorder is paused it's duration does not increase.

| -| `"restart_after_max_duration"` |

If the `max_duration` is set and this limit is reached then the replay recording will automatically restart creating a new replay file.

| +| `"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`。

| @@ -206,9 +206,9 @@ | `"ignore_scoreboard_packets"` |

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

| | `"optimize_explosion_packets"` |

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

| | `"optimize_entity_packets"` |

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

| -| `"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"` |

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

| -| `"chunks"` |

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

| +| `"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"` |

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

| ### 区块设置 From 5d21736e34520e14f1e4997a88ecea4d3538b8f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?tanh=E4=B8=B6=E6=A1=81?= <100672377+tanhHeng@users.noreply.github.com> Date: Thu, 7 Mar 2024 23:30:18 +0800 Subject: [PATCH 08/16] A simple spelling mistake, "when the server 'starts' " :D --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cc8dd97..4d641e8 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,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 From 76433ee73fd19a0d23a5e6f4accd1eafb0897c80 Mon Sep 17 00:00:00 2001 From: senseiwells <66843746+senseiwells@users.noreply.github.com> Date: Sat, 9 Mar 2024 22:02:20 +0000 Subject: [PATCH 09/16] Update README.md to include Modrinth download --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 4d641e8..6ca7d3e 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ 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 From a994bcbb5619728d43da6cb590097e52013fd1b7 Mon Sep 17 00:00:00 2001 From: senseiwells <66843746+senseiwells@users.noreply.github.com> Date: Sat, 9 Mar 2024 22:03:45 +0000 Subject: [PATCH 10/16] Update README_cn.md to include Modrinth download --- README_cn.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README_cn.md b/README_cn.md index dee0eb3..8a9979e 100644 --- a/README_cn.md +++ b/README_cn.md @@ -1,4 +1,4 @@ -# Server Replay +# Server Replay [English](./README.md) | **中文** @@ -6,6 +6,8 @@ 一个只在服务端生效的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相比,在服务端录制有着许多好处: From e9a49a6fe8cc5b029bf8f1358291f00aacaea377 Mon Sep 17 00:00:00 2001 From: senseiwells Date: Wed, 13 Mar 2024 17:49:04 +0000 Subject: [PATCH 11/16] Fix #19 --- README.md | 2 +- build.gradle.kts | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6ca7d3e..30ee651 100644 --- a/README.md +++ b/README.md @@ -390,7 +390,7 @@ repositories { dependencies { // For the most recent version, use the latest commit hash - modImplementation("com.github.Senseiwells:ServerReplay:281e9e0ec0") + modImplementation("com.github.Senseiwells:ServerReplay:a994bcbb56") } ``` 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()}" From 2d1580e97cc2ca57f9348be3a01545eb459abee3 Mon Sep 17 00:00:00 2001 From: senseiwells Date: Wed, 13 Mar 2024 17:49:22 +0000 Subject: [PATCH 12/16] Version bump --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 158d07c..c5e0664 100644 --- a/gradle.properties +++ b/gradle.properties @@ -17,6 +17,6 @@ carpet_version=1.4.128 voicechat_version=1.20.4-2.5.1 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 From da3b0e55ce54aebfe955c27889f8e8225909c39a Mon Sep 17 00:00:00 2001 From: senseiwells Date: Wed, 13 Mar 2024 17:50:34 +0000 Subject: [PATCH 13/16] Fix mod icon --- .../assets/{serverreplay => server-replay}/icon.png | Bin 1 file changed, 0 insertions(+), 0 deletions(-) rename src/main/resources/assets/{serverreplay => server-replay}/icon.png (100%) 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 From 0774dce0ffa971c00cb07ec2af43fb1bf61248b7 Mon Sep 17 00:00:00 2001 From: senseiwells Date: Sat, 16 Mar 2024 23:30:00 +0000 Subject: [PATCH 14/16] Add documentation and update API --- README.md | 4 +- README_cn.md | 2 +- .../me/senseiwells/replay/ServerReplay.kt | 3 +- .../replay/api/RejoinedPacketSender.kt | 10 - .../replay/api/ReplayPluginManager.kt | 54 ++++ .../senseiwells/replay/api/ReplaySenders.kt | 12 - .../replay/api/ServerReplayPlugin.kt | 45 +++ .../me/senseiwells/replay/chunk/ChunkArea.kt | 43 +++ .../replay/chunk/ChunkRecordable.kt | 58 ++++ .../senseiwells/replay/chunk/ChunkRecorder.kt | 142 +++++++-- .../replay/chunk/ChunkRecorders.kt | 112 +++++++- .../replay/commands/PackCommand.kt | 3 +- .../replay/commands/ReplayCommand.kt | 4 +- .../compat/voicechat/ReplayVoicechatPlugin.kt | 22 +- .../senseiwells/replay/config/ReplayConfig.kt | 5 +- .../config/predicates/ReplayPlayerContext.kt | 2 - .../replay/player/PlayerRecorder.kt | 150 +++++++--- .../replay/player/PlayerRecorders.kt | 53 +++- .../replay/recorder/ChunkSender.kt | 115 +++++++- .../replay/recorder/ReplayRecorder.kt | 270 ++++++++++++++---- .../replay/rejoin/RejoinedReplayPlayer.kt | 8 +- .../replay/util/ReplayOptimizerUtils.kt | 9 + .../replay/util/SizedZipReplayFile.kt | 5 +- src/main/resources/fabric.mod.json | 6 + 24 files changed, 954 insertions(+), 183 deletions(-) delete mode 100644 src/main/kotlin/me/senseiwells/replay/api/RejoinedPacketSender.kt create mode 100644 src/main/kotlin/me/senseiwells/replay/api/ReplayPluginManager.kt delete mode 100644 src/main/kotlin/me/senseiwells/replay/api/ReplaySenders.kt create mode 100644 src/main/kotlin/me/senseiwells/replay/api/ServerReplayPlugin.kt diff --git a/README.md b/README.md index 30ee651..3080f58 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Server Replay +# Server Replay **English** | [中文](./README_cn.md) @@ -390,7 +390,7 @@ repositories { dependencies { // For the most recent version, use the latest commit hash - modImplementation("com.github.Senseiwells:ServerReplay:a994bcbb56") + modImplementation("com.github.Senseiwells:ServerReplay:da3b0e55ce") } ``` diff --git a/README_cn.md b/README_cn.md index 8a9979e..d6d7e2a 100644 --- a/README_cn.md +++ b/README_cn.md @@ -1,4 +1,4 @@ -# Server Replay +# Server Replay [English](./README.md) | **中文** 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..0a6ddcb --- /dev/null +++ b/src/main/kotlin/me/senseiwells/replay/api/ServerReplayPlugin.kt @@ -0,0 +1,45 @@ +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. + */ +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 51ff5ec..2611009 100644 --- a/src/main/kotlin/me/senseiwells/replay/chunk/ChunkRecorder.kt +++ b/src/main/kotlin/me/senseiwells/replay/chunk/ChunkRecorder.kt @@ -5,7 +5,9 @@ import kotlinx.serialization.json.JsonPrimitive 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 @@ -14,7 +16,6 @@ import net.minecraft.network.protocol.Packet import net.minecraft.network.protocol.game.ClientboundAddEntityPacket 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.ClientInformation import net.minecraft.server.level.ServerLevel import net.minecraft.server.level.ServerPlayer @@ -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, ClientInformation.createDefault()) 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 69314cf..4009773 100644 --- a/src/main/kotlin/me/senseiwells/replay/commands/PackCommand.kt +++ b/src/main/kotlin/me/senseiwells/replay/commands/PackCommand.kt @@ -13,8 +13,7 @@ import net.minecraft.commands.SharedSuggestionProvider import net.minecraft.commands.arguments.UuidArgument import net.minecraft.network.protocol.common.ClientboundResourcePackPopPacket import net.minecraft.network.protocol.common.ClientboundResourcePackPushPacket -import java.util.Optional -import java.util.UUID +import java.util.* import java.util.concurrent.CompletableFuture object PackCommand { 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 76f7544..140112b 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.common.ClientCommonPacketListener 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 6ae0b76..8a53bcd 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 @@ -10,13 +9,28 @@ import net.minecraft.network.protocol.game.ClientGamePacketListener import net.minecraft.network.protocol.game.ClientboundBundlePacket import net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket import net.minecraft.server.MinecraftServer -import net.minecraft.server.level.* +import net.minecraft.server.level.ChunkTrackingView +import net.minecraft.server.level.ServerEntity +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, @@ -25,79 +39,149 @@ 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) { ChunkTrackingView.of(this.getCenterChunk(), this.server.playerList.viewDistance).forEach(consumer) } + /** + * 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 00674fd..ce2dc4a 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 @@ -49,8 +51,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 ) { @@ -82,13 +96,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 { @@ -104,6 +134,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") @@ -162,22 +206,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) { @@ -190,18 +254,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) { @@ -232,6 +329,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 = ", " @@ -259,43 +364,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.CONFIGURATION - } - - @Internal - fun afterConfigure() { - 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) { @@ -303,6 +389,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) @@ -316,20 +408,102 @@ 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 `CONFIGURATION`. + */ + @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.CONFIGURATION + } + + /** + * This method should be called after the player has finished + * their configuration phase, and this will mark the player + * as playing the game - actually in the Minecraft world. + */ + @Internal + fun afterConfigure() { + this.protocol = ConnectionProtocol.PLAY + } + private fun prePacket(packet: MinecraftPacket<*>): Boolean { when (packet) { is ClientboundAddEntityPacket -> { @@ -368,7 +542,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() } } @@ -434,7 +608,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 7f1c489..97a36bc 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.ducks.`ServerReplay$PackTracker` import me.senseiwells.replay.player.PlayerRecorder @@ -121,10 +121,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 faa577a..1dd2614 100644 --- a/src/main/kotlin/me/senseiwells/replay/util/ReplayOptimizerUtils.kt +++ b/src/main/kotlin/me/senseiwells/replay/util/ReplayOptimizerUtils.kt @@ -14,6 +14,7 @@ import net.minecraft.world.entity.projectile.Projectile import net.minecraft.world.level.Explosion object ReplayOptimizerUtils { + // Set of packets that are ignored by replay mod private val IGNORED = setOf>>( ClientboundBlockChangedAckPacket::class.java, ClientboundOpenBookPacket::class.java, @@ -35,22 +36,26 @@ 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, ClientboundResetScorePacket::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/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", From a5ac0ccb7eac4f89acfda809df3da2e546a4102d Mon Sep 17 00:00:00 2001 From: senseiwells Date: Sat, 16 Mar 2024 23:37:46 +0000 Subject: [PATCH 15/16] Update readme --- README.md | 35 +++++++++++++++++-- README_cn.md | 6 ++-- .../replay/api/ServerReplayPlugin.kt | 17 +++++++++ 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3080f58..08c1c8c 100644 --- a/README.md +++ b/README.md @@ -390,7 +390,7 @@ repositories { dependencies { // For the most recent version, use the latest commit hash - modImplementation("com.github.Senseiwells:ServerReplay:da3b0e55ce") + modImplementation("com.github.senseiwells:ServerReplay:da3b0e55ce") } ``` @@ -403,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)!! @@ -421,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 index d6d7e2a..77deb3f 100644 --- a/README_cn.md +++ b/README_cn.md @@ -386,7 +386,7 @@ repositories { dependencies { // For the most recent version use the latest commit hash - modImplementation("com.github.Senseiwells:ServerReplay:281e9e0ec0") + modImplementation("com.github.senseiwells:ServerReplay:281e9e0ec0") } ``` @@ -399,7 +399,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,7 +417,7 @@ class ExampleMod: ModInitializer { ChunkPos(5, 5), "Named" ) - recorder.tryStart(log = false) + recorder.start(log = false) } } } diff --git a/src/main/kotlin/me/senseiwells/replay/api/ServerReplayPlugin.kt b/src/main/kotlin/me/senseiwells/replay/api/ServerReplayPlugin.kt index 0a6ddcb..1981213 100644 --- a/src/main/kotlin/me/senseiwells/replay/api/ServerReplayPlugin.kt +++ b/src/main/kotlin/me/senseiwells/replay/api/ServerReplayPlugin.kt @@ -12,6 +12,23 @@ import me.senseiwells.replay.player.PlayerRecorder * 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 { /** From 2af1736879071fc4d1cb2478bb25c2ee6e0a488b Mon Sep 17 00:00:00 2001 From: senseiwells Date: Sat, 16 Mar 2024 23:40:44 +0000 Subject: [PATCH 16/16] Fix trailing comma --- src/main/kotlin/me/senseiwells/replay/commands/PackCommand.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/me/senseiwells/replay/commands/PackCommand.kt b/src/main/kotlin/me/senseiwells/replay/commands/PackCommand.kt index 031d988..439dba4 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) {