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