<是否保存?>` 手动停止对给定玩家的录制,你可以选择性地设置录制是否被保存,默认情况下它将会被保存。
+
+- `/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) {