Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ packetevents-plugin = "2.13.0"
# Command API
commandapi = "12.0.0"

# Minestom
minestom = "2026.08.16-26.2"
mojang-brigadier = "1.3.10" # matches the version minestom ships with
guice = "7.0.0"

# LuckPerms
luckperms = "5.5"
luckpermsplugin-bukkit = "v5.5.71-bukkit" # "luckpermsplugin" is written without a separating "-" between "luckperms" and "plugin", because Gradle interprets the version catalog key as a hierarchical alias and would split it into a nested accessor (libs.luckperms.plugin.bukkit), which conflicts with the existing libs.luckperms alias for the API dependency.
Expand Down Expand Up @@ -87,7 +92,9 @@ paper-api = { module = "io.papermc.paper:paper-api", version.ref = "paper-api" }
canvas-api = { module = "io.canvasmc.canvas:canvas-api", version.ref = "canvas-api" }

# Minestom
surf-minestom-lobby-api = { module = "dev.slne.minestom.lobby:surf-minestom-lobby-api", version = "+" }
minestom = { module = "net.minestom:minestom", version.ref = "minestom" }
mojang-brigadier = { module = "com.mojang:brigadier", version.ref = "mojang-brigadier" }
guice = { module = "com.google.inject:guice", version.ref = "guice" }

# Kotlin
kotlinxCoroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
Expand Down
2,077 changes: 2,067 additions & 10 deletions surf-api-minestom/api/surf-api-minestom.api

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion surf-api-minestom/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ dependencies {
}

implementation(projects.surfApiCore.surfApiCoreServer)
compileOnlyApi(libs.surf.minestom.lobby.api)

compileOnlyApi(libs.minestom)
compileOnlyApi(libs.mojang.brigadier)
compileOnlyApi(libs.guice)

Comment thread
TheBjoRedCraft marked this conversation as resolved.
api(libs.guava)
api(libs.dazzleconf)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package dev.slne.surf.api.minestom
import com.github.retrooper.packetevents.PacketEvents
import com.google.inject.Inject
import com.google.inject.Singleton
import dev.slne.minestom.lobby.api.plugin.MinestomPluginEntrypoint
import dev.slne.surf.api.minestom.plugin.MinestomPluginEntrypoint
import dev.slne.surf.api.core.extensions.packetEvents
import dev.slne.surf.api.core.server.packet.NoopPacketEvents
import dev.slne.surf.api.minestom.impl.SurfMinestomInstance
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package dev.slne.surf.api.minestom

import com.google.auto.service.AutoService
import dev.slne.minestom.lobby.api.plugin.MinestomPlugin
import dev.slne.minestom.lobby.api.plugin.annotation.MinestomPluginMeta
import dev.slne.surf.api.minestom.plugin.MinestomPlugin
import dev.slne.surf.api.minestom.plugin.annotation.MinestomPluginMeta
import dev.slne.surf.api.minestom.dialog.callback.DialogCallbackListener
import dev.slne.surf.api.minestom.impl.inventory.framework.MinestomInventoryLoader

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package dev.slne.surf.api.minestom.chat

import dev.slne.surf.api.minestom.event.SuspendingEventNode
import dev.slne.surf.api.minestom.player.SurfMinestomPlayer
import net.kyori.adventure.audience.Audience
import net.kyori.adventure.chat.SignedMessage
import net.kyori.adventure.text.Component
import org.jetbrains.annotations.ApiStatus

/**
* Fired when a [SurfMinestomPlayer] sends a chat message.
*
* Listeners run off the tick thread on
* a virtual thread, so they may suspend *and* they may block
* without affecting the server's tick rate.
*
* Messages from one player are processed strictly in order, so a slow listener delays that player's
* next message.
*
* ```
* AsyncChatEvent.addListener { event ->
* val prefix = database.loadPrefix(event.player.uuid) // suspending or blocking, both fine
* event.renderer = ChatRenderer.viewerUnaware { _, name, message ->
* Component.text().append(prefix).append(name).append(Component.text(": ")).append(message).build()
* }
* }
* ```
*/
class AsyncChatEvent @ApiStatus.Internal constructor(

val player: SurfMinestomPlayer,

/**
* The [Audience]s that this chat message will be displayed to.
*
* Can be modified to add and remove viewers.
*/
val viewers: MutableSet<Audience>,

/**
* The renderer used to turn [message] into the component every viewer sees.
*/
var renderer: ChatRenderer,

/**
* The user-supplied message.
*/
var message: Component,

/**
* The original and unmodified user-supplied message.
*
* The value will **not** reflect changes made through [message].
*/
val originalMessage: Component,

/**
* The signed message backing this event.
*
* Changes made in this event will **not** update the signed message.
*/
val signedMessage: SignedMessage,
) {

var isCancelled = false

companion object {
@ApiStatus.Internal
val node = SuspendingEventNode<AsyncChatEvent>("async-chat")

typealias AsyncChatEventListener = suspend (AsyncChatEvent) -> Unit

/**
* Registers a chat listener. Lower [priority] runs first.
*
* @see SuspendingEventNode.addListener
*/
fun addListener(
priority: Int = 0,
listener: AsyncChatEventListener
) = node.addListener(priority, listener)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package dev.slne.surf.api.minestom.chat

import net.kyori.adventure.audience.Audience
import net.kyori.adventure.text.Component
import net.minestom.server.entity.Player
import org.jetbrains.annotations.ApiStatus

/**
* A chat renderer is responsible for rendering chat messages sent by [Player]s to the server.
*/
fun interface ChatRenderer {

/**
* Renders a chat message. This will be called once for each receiving [Audience].
*
* @param source the message source
* @param sourceDisplayName the display name of the source player
* @param message the chat message
* @param viewer the receiving [Audience]
* @return a rendered chat message
*/
@ApiStatus.OverrideOnly
suspend fun render(
source: Player,
sourceDisplayName: Component,
message: Component,
viewer: Audience
): Component

/**
* Marker for the renderer returned by [defaultRenderer].
*/
@ApiStatus.Internal
sealed interface Default : ChatRenderer, ViewerUnaware

/**
* Similar to [ChatRenderer], but without knowledge of the message viewer.
*
* @see viewerUnaware
*/
fun interface ViewerUnaware {

/**
* Renders a chat message.
*
* @param source the message source
* @param sourceDisplayName the display name of the source player
* @param message the chat message
* @return a rendered chat message
*/
@ApiStatus.OverrideOnly
suspend fun render(source: Player, sourceDisplayName: Component, message: Component): Component
}

companion object {

/**
* Creates a new instance of the default [ChatRenderer].
*/
fun defaultRenderer(): ChatRenderer =
ViewerUnawareChatRenderer.Default { _, sourceDisplayName, message ->
Component.translatable("chat.type.text", sourceDisplayName, message)
}

/**
* Creates a new viewer-unaware [ChatRenderer], which will render the chat message a single
* time, displaying the same rendered message to every viewing [Audience].
*
* @param renderer the viewer unaware renderer
*/
fun viewerUnaware(renderer: ViewerUnaware): ChatRenderer =
ViewerUnawareChatRenderer.Impl(renderer)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package dev.slne.surf.api.minestom.chat

import net.minestom.server.crypto.ChatSession
import java.util.UUID

/**
* The player a [RemoteSignedMessage] came from, as far as the receiving server knows them.
*
* [session] is the chat session the message was signed under. It is `null` for a sender that had
* none, in which case the message is shown as unsigned.
*/
data class RemoteChatSender(
val uuid: UUID,
val username: String,
val session: ChatSession?,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package dev.slne.surf.api.minestom.chat

import net.kyori.adventure.text.Component
import net.minestom.server.crypto.MessageSignature
import java.time.Instant
import java.util.*

/**
* A signed chat message in a form that survives leaving this server.
*
* It carries everything a client needs to verify the signature, so a server that never saw the
* sender can still show the message as signed. A message from a sender without a chat session has no
* [signature], a nil [sessionId] and is shown as unsigned.
*
* @see dev.slne.surf.api.minestom.player.SurfMinestomPlayer.captureSignedMessage
* @see dev.slne.surf.api.minestom.player.SurfMinestomPlayer.sendRemoteSignedMessage
*/
data class RemoteSignedMessage(
val sender: UUID,
val sessionId: UUID,

/** The position of the message in the signature chain of its session. */
val index: Int,

val signature: MessageSignature?,

/** The message as it was signed, which is the plain text the sender typed. */
val content: String,
val timestamp: Instant,
val salt: Long,

/** The signatures of the messages the sender had seen, which the signature also covers. */
val lastSeen: List<MessageSignature>,

/** What the receiver sees instead of [content]. */
val unsignedContent: Component?,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package dev.slne.surf.api.minestom.chat

import net.kyori.adventure.audience.Audience
import net.kyori.adventure.text.Component
import net.minestom.server.entity.Player

/**
* Adapts a [ChatRenderer.ViewerUnaware] to a full [ChatRenderer], rendering the message only once
* and reusing the result for every viewer.
*/
internal sealed class ViewerUnawareChatRenderer(
private val unaware: ChatRenderer.ViewerUnaware
) : ChatRenderer, ChatRenderer.ViewerUnaware {

private var rendered: Component? = null

override suspend fun render(
source: Player,
sourceDisplayName: Component,
message: Component,
viewer: Audience
): Component = render(source, sourceDisplayName, message)

override suspend fun render(
source: Player,
sourceDisplayName: Component,
message: Component
): Component = rendered ?: unaware.render(source, sourceDisplayName, message).also {
rendered = it
}
Comment thread
TheBjoRedCraft marked this conversation as resolved.

class Impl(unaware: ChatRenderer.ViewerUnaware) : ViewerUnawareChatRenderer(unaware)

class Default(unaware: ChatRenderer.ViewerUnaware) :
ViewerUnawareChatRenderer(unaware), ChatRenderer.Default
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package dev.slne.surf.api.minestom.chat.signature

import com.google.common.primitives.Ints
import dev.slne.surf.api.minestom.chat.signature.LastSeenMessages.Update.Companion.IGNORE_CHECKSUM
import net.minestom.server.crypto.MessageSignature
import net.minestom.server.network.packet.client.play.ClientChatMessagePacket
import net.minestom.server.network.packet.client.play.ClientSignedCommandChatPacket
import java.util.BitSet
import net.minestom.server.crypto.LastSeenMessages as NetworkLastSeenMessages

@JvmInline
value class LastSeenMessages(val entries: List<MessageSignature>) {

companion object {
const val LAST_SEEN_MESSAGES_MAX_LENGTH = 20

val EMPTY = LastSeenMessages(emptyList())
}

fun computeChecksum(): Byte {
var checksum = 1

for (entry in entries) {
checksum = 31 * checksum + entry.checksum()
}

val checksumByte = checksum.toByte()

return if (checksumByte == IGNORE_CHECKSUM) 1 else checksumByte
}

fun updateSignature(output: SignatureUpdater.Output) {
output.update(Ints.toByteArray(entries.size))

for (entry in entries) {
output.update(entry.signature())
}
}

fun pack(cache: MessageSignatureCache): NetworkLastSeenMessages.Packed =
NetworkLastSeenMessages.Packed(entries.map { it.pack(cache) })

data class Update(
val offset: Int,
val acknowledged: BitSet,
val checksum: Byte
) {
companion object {
const val IGNORE_CHECKSUM = 0.toByte()

fun fromPacket(packet: ClientChatMessagePacket) = Update(
offset = packet.ackOffset(),
acknowledged = packet.ackList(),
checksum = packet.checksum()
)

fun fromPacket(packet: ClientSignedCommandChatPacket) = Update(
offset = packet.lastSeenMessages().offset(),
acknowledged = packet.lastSeenMessages().acknowledged(),
checksum = packet.checksum()
)
}

fun verifyChecksum(lastSeen: LastSeenMessages): Boolean =
checksum == IGNORE_CHECKSUM || checksum == lastSeen.computeChecksum()
}
}
Loading
Loading