Skip to content
Merged
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ closes that client. Local runtime diagnostics expose client status, current and
fingerprints, and requested/accepted/downloaded/failed/declined counters. Untrusted diagnostic text
is replaced by bounded, allowlisted reason codes.

The required-pack unavailable disconnect reason remains localized, plain text. It is deliberately
not a prefixed system message because the player is disconnected before normal chat presentation
is available.

## Current scope

There is no Minestom adapter and no gamemode or per-server PackSet overlay support yet.
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ subprojects {
check("\"id\":\"plugin-config\",\"optional\":false" in metadata) {
"plugin metadata must require plugin-config"
}
check("\"id\":\"plugin-proxy\",\"optional\":false" in metadata) {
"plugin metadata must require plugin-proxy"
}
}
}
}
Expand Down
12 changes: 12 additions & 0 deletions velocity/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ configure<BuildConfigExtension> { packageName("gg.grounds.generated") }

dependencies {
compileOnly("gg.grounds:plugin-config-common:1.0.0")
compileOnly("gg.grounds:plugin-proxy-api:2.6.0")
implementation("gg.grounds:library-i18n:0.4.0")
compileOnly("gg.grounds:plugin-config-velocity:1.0.0") {
attributes {
attribute(
Expand All @@ -17,6 +19,7 @@ dependencies {
implementation("gg.grounds:resourcepacks-client:1.0.0")

testImplementation("gg.grounds:plugin-config-common:1.0.0")
testImplementation("gg.grounds:plugin-proxy-api:2.6.0")
testImplementation("gg.grounds:plugin-config-velocity:1.0.0") {
attributes {
attribute(
Expand All @@ -29,3 +32,12 @@ dependencies {
testImplementation("org.yaml:snakeyaml:2.6")
testImplementation(kotlin("test"))
}

val groundsGradleInitScript = providers.environmentVariable("GROUNDS_GRADLE_INIT_SCRIPT")

if (groundsGradleInitScript.isPresent) {
tasks.named<org.gradle.api.tasks.testing.Test>("test") {
dependsOn(tasks.named("shadowJar"))
environment("GROUNDS_GRADLE_INIT_SCRIPT", groundsGradleInitScript.get())
}
}
2 changes: 2 additions & 0 deletions velocity/gradle.lockfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ com.moandjiezana.toml:toml4j:0.7.2=compileClasspath,kapt,kaptClasspath_kaptKotli
com.soywiz.korlibs.korte:korte-jvm:4.0.10=dokkaJavadocGeneratorRuntimeResolver~internal
com.velocitypowered:velocity-api:3.5.0-SNAPSHOT=compileClasspath,kapt,kaptClasspath_kaptKotlin,kaptClasspath_kaptTestKotlin,kaptTest,testCompileClasspath,testRuntimeClasspath
com.velocitypowered:velocity-brigadier:1.0.0-SNAPSHOT=compileClasspath,kapt,kaptClasspath_kaptKotlin,kaptClasspath_kaptTestKotlin,kaptTest,testCompileClasspath,testRuntimeClasspath
gg.grounds:library-i18n:0.4.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
gg.grounds:plugin-config-common:1.0.0=compileClasspath,testCompileClasspath,testRuntimeClasspath
gg.grounds:plugin-config-velocity:1.0.0=compileClasspath,testCompileClasspath,testRuntimeClasspath
gg.grounds:plugin-proxy-api:2.6.0=compileClasspath,testCompileClasspath,testRuntimeClasspath
gg.grounds:resourcepacks-client:1.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
gg.grounds:resourcepacks-contract:1.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.github.java-diff-utils:java-diff-utils:4.12=kotlinInternalAbiValidation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import com.google.inject.Inject
import com.velocitypowered.api.event.EventTask
import com.velocitypowered.api.event.Subscribe
import com.velocitypowered.api.event.connection.DisconnectEvent
import com.velocitypowered.api.event.connection.PluginMessageEvent
import com.velocitypowered.api.event.player.PlayerResourcePackStatusEvent
import com.velocitypowered.api.event.player.ServerPostConnectEvent
import com.velocitypowered.api.event.player.configuration.PlayerConfigurationEvent
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent
Expand All @@ -14,6 +16,7 @@ import com.velocitypowered.api.plugin.annotation.DataDirectory
import com.velocitypowered.api.proxy.ProxyServer
import gg.grounds.config.ConfigStartupMode
import gg.grounds.generated.BuildInfo
import gg.grounds.i18n.Translations
import gg.grounds.resourcepacks.client.PackSetClientState
import gg.grounds.resourcepacks.client.PackSetClientStatus
import gg.grounds.resourcepacks.client.PackSetSelection
Expand All @@ -23,7 +26,6 @@ import java.nio.file.Path
import java.util.concurrent.CompletableFuture
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import net.kyori.adventure.text.Component
import org.slf4j.Logger

@Plugin(
Expand All @@ -33,7 +35,12 @@ import org.slf4j.Logger
description = "Delivers ordered resource-pack requests to players on login",
authors = ["Grounds Development Team and contributors"],
url = "https://github.com/groundsgg/plugin-resourcepacks",
dependencies = [Dependency(id = "plugin-config")],
dependencies =
[
Dependency(id = "plugin-config"),
Dependency(id = "plugin-proxy"),
Dependency(id = "floodgate", optional = true),
],
)
class GroundsResourcePacksPlugin
internal constructor(
Expand All @@ -46,6 +53,14 @@ internal constructor(
private val log: ResourcePackLog,
private val snapshotDeadline: ResourcePackSnapshotDeadline =
ScheduledResourcePackSnapshotDeadline(),
private val appearanceProtocol:
(java.util.UUID) -> com.velocitypowered.api.network.ProtocolVersion? =
{
null
},
private val platformLookup: ChatPlatformLookup = ChatPlatformLookup { null },
private val appearanceRegistry: PlayerChatAppearanceRegistry = NoopPlayerChatAppearanceRegistry,
private val chatStyleBridge: PlayerChatStyleBridge = NoopPlayerChatStyleBridge,
) {
@Inject
constructor(
Expand All @@ -60,6 +75,16 @@ internal constructor(
VelocityPlayerPackSender,
VelocityResourcePackEventRegistry(proxy),
Slf4jResourcePackLog(logger),
appearanceProtocol = { playerId ->
proxy.getPlayer(playerId).orElse(null)?.protocolVersion
},
platformLookup = FloodgateChatPlatformLookup.create(logger),
appearanceRegistry = ProxyPlayerChatAppearanceRegistry,
chatStyleBridge =
VelocityPlayerChatStyleBridge(
registerChannel = { proxy.channelRegistrar.register(it) },
unregisterChannel = { proxy.channelRegistrar.unregister(it) },
),
)

private val lifecycle = Any()
Expand All @@ -70,6 +95,9 @@ internal constructor(
private val state = AtomicReference(closedState())
private val lastLoggedStatus = AtomicReference<PackSetClientStatus?>(null)
private val configurationWaiter = ResourcePackConfigurationWaiter()
private val appearanceTracker = AtomicReference<PlayerChatAppearanceTracker?>(null)
private val messages =
Translations.forBundle("gg.grounds.resourcepacks.messages", javaClass.classLoader)
private val deadlines = Any()
private val initialDeadlines = HashMap<java.util.UUID, InitialDeadline>()
private val coordinator =
Expand All @@ -84,6 +112,8 @@ internal constructor(
requestFactory = VelocityPackRequestFactory(),
deliveryObserver =
ResourcePackDeliveryObserver { player, prepared ->
appearanceTracker.get()?.offered(player.uniqueId, prepared.packHashes)
publishChatStyle(player)
log.info(
"Resource-pack request sent (playerId=${player.uniqueId}, " +
"targetId=${prepared.targetId}, fingerprint=${prepared.fingerprint}, " +
Expand All @@ -106,23 +136,54 @@ internal constructor(
fun onInitialize(@Suppress("UNUSED_PARAMETER") event: ProxyInitializeEvent) {
if (!initialized.compareAndSet(false, true)) return
val deployment = environment()
val tracker =
PlayerChatAppearanceTracker(ChatAppearanceEnvironment.from(deployment).fontPackSha1s)
appearanceTracker.set(tracker)
appearanceRegistry.register(
ResourcePackPlayerChatAppearanceQuery(appearanceProtocol, platformLookup, tracker)
)
chatStyleBridge.register()
val definition = resourcePackSettingsDefinition(bootstrapPackSetChannel(deployment))
val deploymentEnvironment = ResourcePackEnvironment.from(deployment).deploymentEnvironment
val listener =
ResourcePackStatusListener(
metrics,
coordinator::ownsPack,
{ player, packId -> coordinator.ownsPack(player, packId) },
coordinator::targetId,
log,
{ player, packId, status ->
statusObserver@{ player, packId, status ->
val playerId = player.uniqueId
val initial =
val observed =
synchronized(deadlines) {
initialDeadlines[playerId]?.takeIf { it.player === player }
}
initial?.let {
configurationWaiter.onStatus(playerId, packId, status, it.completion)
}
if (stopped.get()) return@synchronized null
val initial =
initialDeadlines[playerId]?.takeIf { it.player === player }
?: return@synchronized null
val publishAppearance =
when (status) {
PlayerResourcePackStatusEvent.Status.SUCCESSFUL -> {
if (packId != null) tracker.loaded(playerId, packId)
true
}
PlayerResourcePackStatusEvent.Status.ACCEPTED,
PlayerResourcePackStatusEvent.Status.DOWNLOADED -> false
else -> {
if (packId != null) tracker.failed(playerId, packId)
true
}
}
ObservedPackStatus(
initial,
if (publishAppearance) captureChatStyle(player) else null,
)
} ?: return@statusObserver
observed.appearance?.let { chatStyleBridge.send(player, it) }
configurationWaiter.onStatus(
playerId,
packId,
status,
observed.initial.completion,
)
},
)
statusListener = listener
Expand Down Expand Up @@ -164,6 +225,9 @@ internal constructor(
previous = configurationWaiter.begin(playerId, completion)
replacedHandle =
synchronized(deadlines) {
if (initialDeadlines[playerId]?.player !== event.player()) {
appearanceTracker.get()?.reset(playerId)
}
initialDeadlines.put(playerId, initial)?.handle
}
registered = true
Expand Down Expand Up @@ -192,11 +256,21 @@ internal constructor(

@Subscribe
fun onDisconnect(event: DisconnectEvent) {
val initial =
val disconnected =
synchronized(deadlines) {
initialDeadlines[event.player.uniqueId]?.takeIf { it.player === event.player }
val initial =
initialDeadlines[event.player.uniqueId]?.takeIf { it.player === event.player }
?: return@synchronized null
initialDeadlines.remove(event.player.uniqueId)
DisconnectedPlayer(
initial,
appearanceTracker.get()?.disconnectSnapshot(event.player.uniqueId),
)
} ?: return
forgetInitial(initial)
disconnected.appearance?.let { snapshot -> chatStyleBridge.send(event.player, snapshot) }
disconnected.initial.handle?.close()
coordinator.forget(event.player.uniqueId, disconnected.initial.session)
configurationWaiter.forget(event.player.uniqueId, disconnected.initial.completion)
}

@Subscribe
Expand All @@ -210,6 +284,14 @@ internal constructor(
initial.completion.isDone
)
coordinator.onServerSwitch(event.player, initial.session)
if (!stopped.get()) publishChatStyle(event.player)
}

@Subscribe
fun onPluginMessage(event: PluginMessageEvent) {
if (event.identifier == chatStyleBridge.identifier) {
event.result = PluginMessageEvent.ForwardResult.handled()
}
}

@Subscribe
Expand All @@ -220,6 +302,9 @@ internal constructor(
var statusToUnregister: ResourcePackStatusListener? = null
var clientToClose: ResourcePackClient? = null
coordinator.clear()
appearanceTracker.getAndSet(null)?.clear()
appearanceRegistry.unregister()
chatStyleBridge.unregister()
snapshotDeadline.close()
synchronized(deadlines) {
initialDeadlines.values.forEach { it.handle?.close() }
Expand All @@ -244,6 +329,23 @@ internal constructor(
state.set(closedState())
}

private fun publishChatStyle(player: com.velocitypowered.api.proxy.Player) {
val snapshot = captureChatStyle(player) ?: return
chatStyleBridge.send(player, snapshot)
}

private fun captureChatStyle(
player: com.velocitypowered.api.proxy.Player
): PlayerChatStyleSnapshot? =
synchronized(deadlines) {
if (stopped.get()) return@synchronized null
if (initialDeadlines[player.uniqueId]?.player !== player) return@synchronized null
appearanceTracker.get()?.snapshot(player.uniqueId) {
appearanceProtocol(player.uniqueId) != null &&
platformLookup.isBedrock(player.uniqueId) == false
}
}

fun runtimeStatus(): ResourcePackRuntimeStatus {
val value = state.get()
val counts = metrics.snapshot()
Expand Down Expand Up @@ -370,7 +472,7 @@ internal constructor(
}
try {
session.player.disconnect(
Component.text("Resource packs are currently unavailable. Please try again.")
messages.render(ResourcePacksMessage.PACKS_UNAVAILABLE, session.player)
)
} finally {
configurationWaiter.forget(playerId, session.completion)
Expand Down Expand Up @@ -399,6 +501,16 @@ internal constructor(
var ready = false
}

private data class ObservedPackStatus(
val initial: InitialDeadline,
val appearance: PlayerChatStyleSnapshot?,
)

private data class DisconnectedPlayer(
val initial: InitialDeadline,
val appearance: PlayerChatStyleSnapshot?,
)

private companion object {
val LOGGED_STATES =
setOf(
Expand Down
Loading