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
5 changes: 3 additions & 2 deletions velocity/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,16 @@ repositories {

dependencies {
implementation(project(":common"))
implementation("gg.grounds:library-i18n:0.4.3")
// plugin-proxy owns the ProxyServiceRegistry at runtime — compileOnly, never shaded, or the
// registry this plugin writes into would be a different class from the one chat/social read.
compileOnly("gg.grounds:plugin-proxy-api:0.5.0")
compileOnly("gg.grounds:plugin-proxy-api:2.6.0")
implementation("tools.jackson.dataformat:jackson-dataformat-yaml:3.2.3")
implementation("tools.jackson.module:jackson-module-kotlin:3.2.3")

// compileOnly above is not visible to tests; PlayerSessionQueryImplTest needs the interface's
// types.
testImplementation("gg.grounds:plugin-proxy-api:0.5.0")
testImplementation("gg.grounds:plugin-proxy-api:2.6.0")
// Same reason: the conventions plugin puts velocity-api on compileOnly, and
// EditionStampListenerTest builds a GameProfile.Property.
testImplementation("com.velocitypowered:velocity-api:3.5.0-SNAPSHOT")
Expand Down
20 changes: 13 additions & 7 deletions velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ import com.velocitypowered.api.plugin.Dependency
import com.velocitypowered.api.plugin.Plugin
import com.velocitypowered.api.plugin.annotation.DataDirectory
import com.velocitypowered.api.proxy.ProxyServer
import gg.grounds.config.MessagesConfig
import gg.grounds.config.MessagesConfigLoader
import gg.grounds.edition.FloodgateLookup
import gg.grounds.edition.listener.EditionStampListener
import gg.grounds.i18n.Translations
import gg.grounds.link.ForgeLinkClient
import gg.grounds.link.LinkCommand
import gg.grounds.listener.PlayerConnectionListener
Expand All @@ -22,6 +22,7 @@ import gg.grounds.locale.listener.LocaleConnectionListener
import gg.grounds.presence.PlayerHeartbeatScheduler
import gg.grounds.presence.PlayerPresenceService
import gg.grounds.presence.PlayerSessionQueryImpl
import gg.grounds.presentation.VelocitySystemMessages
import gg.grounds.proxy.api.PlayerLocaleQuery
import gg.grounds.proxy.api.PlayerSessionQuery
import gg.grounds.proxy.api.ProxyServiceRegistry
Expand All @@ -39,7 +40,7 @@ import org.slf4j.Logger
authors = ["Grounds Development Team and contributors"],
url = "https://github.com/groundsgg/plugin-player",
// plugin-proxy owns the ProxyServiceRegistry this plugin publishes its session lookup into.
dependencies = [Dependency(id = "plugin-proxy")],
dependencies = [Dependency(id = "plugin-proxy"), Dependency(id = "floodgate", optional = true)],
)
class GroundsPluginPlayer
@Inject
Expand All @@ -60,6 +61,12 @@ constructor(
@Subscribe
fun onInitialize(event: ProxyInitializeEvent) {
val messages = MessagesConfigLoader(logger, dataDirectory).loadOrCreate()
val translations =
Translations.forBundleWithSystemRenderer(
"gg.grounds.player.messages",
javaClass.classLoader,
systemRenderer = VelocitySystemMessages.renderer,
)
val serviceUrl = resolveServiceUrl()
playerPresenceService.configure(serviceUrl)

Expand Down Expand Up @@ -94,10 +101,10 @@ constructor(
PlayerLocaleQueryImpl(playerLocaleCache),
)
proxy.commandManager.register(
LangCommand.create(playerLocaleCache, playerPresenceService, logger)
LangCommand.create(playerLocaleCache, playerPresenceService, logger, translations)
)

registerLinkCommands(messages)
registerLinkCommands(translations)
val floodgate = FloodgateLookup.create(logger)
registerEditionStamp(floodgate)
registerPreferredRegion(floodgate)
Expand All @@ -121,9 +128,8 @@ constructor(
}
proxy.eventManager.register(this, EditionStampListener(floodgate, logger))
logger.info(
"Marking Bedrock players for backends ({}={})",
"Marking authenticated player edition for backends ({})",
EditionStampListener.PROPERTY,
EditionStampListener.BEDROCK,
)
}

Expand Down Expand Up @@ -160,7 +166,7 @@ constructor(
* pushed workloads. Outside that context (a bare local proxy, say) the env vars are absent —
* skip the commands rather than register ones that can only ever fail.
*/
private fun registerLinkCommands(messages: MessagesConfig) {
private fun registerLinkCommands(messages: Translations) {
val forgeUrl = System.getenv("GROUNDS_FORGE_URL")?.trim()?.trimEnd('/')
if (forgeUrl.isNullOrEmpty()) {
logger.info("GROUNDS_FORGE_URL is not set; /link is unavailable")
Expand Down
26 changes: 26 additions & 0 deletions velocity/src/main/kotlin/gg/grounds/PlayerMessage.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package gg.grounds

import gg.grounds.i18n.SystemCategory
import gg.grounds.i18n.SystemLevel
import gg.grounds.i18n.SystemMessageKey

enum class PlayerMessage(
override val id: String,
override val category: SystemCategory = SystemCategory.GENERAL,
override val level: SystemLevel = SystemLevel.INFO,
) : SystemMessageKey {
LOGIN_SERVICE_UNAVAILABLE("login.service-unavailable", level = SystemLevel.ERROR),
LOGIN_ALREADY_ONLINE("login.already-online", level = SystemLevel.ERROR),
LOGIN_INVALID_REQUEST("login.invalid-request", level = SystemLevel.ERROR),
LOGIN_ERROR("login.error", level = SystemLevel.ERROR),
LANG_USAGE("lang.usage", level = SystemLevel.WARNING),
LANG_PLAYERS_ONLY("lang.players-only", level = SystemLevel.ERROR),
LANG_UNKNOWN("lang.unknown", level = SystemLevel.ERROR),
LANG_SET("lang.set", level = SystemLevel.SUCCESS),
LINK_PLAYERS_ONLY("link.players-only", level = SystemLevel.ERROR),
LINK_WORKING("link.working"),
LINK_PROMPT("link.prompt"),
LINK_CLICK("link.click"),
LINK_FAILED("link.failed", level = SystemLevel.ERROR),
UNLINK_DONE("unlink.done", level = SystemLevel.SUCCESS),
}
17 changes: 5 additions & 12 deletions velocity/src/main/kotlin/gg/grounds/config/MessagesConfig.kt
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
package gg.grounds.config

/** Optional literal operator overrides; null uses the shared locale-aware source bundle. */
data class MessagesConfig(
val serviceUnavailable: String = "Login service unavailable",
val alreadyOnline: String = "You are already online.",
val invalidRequest: String = "Invalid login request.",
val genericError: String = "Unable to create player session.",
// /link and /unlink. The consent itself happens in a browser, so the
// prompt's job is to explain why a player is being sent out of the game.
val linkPlayersOnly: String = "Only players can link a Microsoft account.",
val linkWorking: String = "Preparing your Microsoft sign-in...",
val linkPrompt: String = "Sign in with Microsoft to find friends who play here:",
val linkClickHere: String = "[Click to connect]",
val linkFailed: String = "Could not start the link right now. Please try again.",
val unlinkDone: String = "Your Microsoft account has been disconnected.",
val serviceUnavailable: String? = null,
val alreadyOnline: String? = null,
val invalidRequest: String? = null,
val genericError: String? = null,
)
86 changes: 63 additions & 23 deletions velocity/src/main/kotlin/gg/grounds/edition/FloodgateLookup.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import org.slf4j.Logger
* one method costs less than either, and `library-gui` already avoids the same dependency for the
* same reason.
*
* [create] returns null when Floodgate is not installed, which is the ordinary state of the Java
* proxies rather than an error worth logging loudly.
* [create] treats a missing Floodgate class as positive evidence that this is a Java-only proxy. A
* present but incompatible or failing API remains unknown instead.
*
* **Why not just look at the UUID.** Floodgate builds an unlinked Bedrock player's UUID as `new
* UUID(0, xuid)`, so the shape alone answers for them — which is what backends do today. It does
Expand All @@ -29,55 +29,95 @@ import org.slf4j.Logger
class FloodgateLookup
private constructor(
private val logger: Logger,
private val getInstance: Method,
private val isFloodgatePlayer: Method,
private val availability: Availability,
private val getInstance: Method? = null,
private val isFloodgatePlayer: Method? = null,
) {

@Volatile private var failed = false

/**
* Whether Floodgate knows this player as a Bedrock player.
*
* Answers false on any failure, and the direction is deliberate: false means "treat as Java",
* which downstream means "keep checking them". The opposite default would let a Floodgate
* hiccup quietly exempt players from anti-cheat.
*/
fun isBedrock(playerId: UUID): Boolean {
if (failed) return false
/** True for Bedrock, false for known Java, and null when Floodgate cannot classify safely. */
fun classify(playerId: UUID): Boolean? {
if (availability == Availability.JAVA_ONLY) return false
if (availability == Availability.UNKNOWN || failed) return null
return try {
// Resolved per call rather than cached: Floodgate's singleton is not guaranteed to
// exist when this proxy wires its listeners, only by the time a player logs in.
val api = getInstance.invoke(null) ?: return false
isFloodgatePlayer.invoke(api, playerId) as Boolean
val api = getInstance!!.invoke(null) ?: return lookupFailed(playerId, null)
isFloodgatePlayer!!.invoke(api, playerId) as? Boolean
?: return lookupFailed(playerId, null)
} catch (e: ReflectiveOperationException) {
// Once, not per login: a broken API would otherwise write a line for every player who
// ever joins.
failed = true
logger.warn("Floodgate lookup failed; treating every player as Java from here on", e)
false
lookupFailed(playerId, e)
} catch (e: RuntimeException) {
lookupFailed(playerId, e)
}
}

/**
* Whether Floodgate knows this player as a Bedrock player.
*
* Answers false on any failure, and the direction is deliberate: false means "treat as Java"
* for anti-cheat, which keeps checking the player. Edition stamping uses [classify] so the same
* failure cannot become an affirmative Java marker.
*/
fun isBedrock(playerId: UUID): Boolean = classify(playerId) == true

private fun lookupFailed(playerId: UUID, cause: Throwable?): Boolean? {
failed = true
if (cause == null) {
logger.warn(
"Floodgate lookup returned no classification for {}; edition stamping is disabled",
playerId,
)
} else {
logger.warn(
"Floodgate lookup failed; edition stamping is disabled and anti-cheat will treat players as Java",
cause,
)
}
return null
}

companion object {
private const val API = "org.geysermc.floodgate.api.FloodgateApi"

/** Null when Floodgate is not installed on this proxy. */
fun create(logger: Logger): FloodgateLookup? =
/**
* Creates a classifier on every proxy. Missing Floodgate means this is a Java-only proxy; a
* present but incompatible API is unknown and must not produce a Java marker.
*/
fun create(logger: Logger): FloodgateLookup = create(logger) { Class.forName(API) }

internal fun create(logger: Logger, loadApi: () -> Class<*>): FloodgateLookup =
try {
val api = Class.forName(API)
val api = loadApi()
FloodgateLookup(
logger,
Availability.FLOODGATE,
api.getMethod("getInstance"),
api.getMethod("isFloodgatePlayer", UUID::class.java),
)
} catch (e: ClassNotFoundException) {
null
FloodgateLookup(logger, Availability.JAVA_ONLY)
} catch (e: NoSuchMethodException) {
// Floodgate is here but is not the API this expects. Say so: a silent null would
// hide a version skew until someone wondered why Bedrock players were being
// flagged by anti-cheat.
logger.warn("Floodgate is installed but {} is not the expected shape", API, e)
null
FloodgateLookup(logger, Availability.UNKNOWN)
} catch (e: LinkageError) {
logger.warn("Floodgate is installed but {} could not be loaded", API, e)
FloodgateLookup(logger, Availability.UNKNOWN)
} catch (e: SecurityException) {
logger.warn("Floodgate is installed but {} cannot be inspected", API, e)
FloodgateLookup(logger, Availability.UNKNOWN)
}

private enum class Availability {
JAVA_ONLY,
FLOODGATE,
UNKNOWN,
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ import org.slf4j.Logger
* **Ordering.** Floodgate's own skin applier also rewrites the property list, but it copies the
* list and removes only `textures`, so it cannot drop this one whichever way round the two run.
*
* Registered only when Floodgate is installed, which in practice means only on the Bedrock proxy.
* Registered on every proxy. Missing Floodgate identifies a Java-only proxy; a broken Floodgate API
* remains unknown and cannot create an affirmative Java marker.
*/
class EditionStampListener(private val floodgate: FloodgateLookup, private val logger: Logger) {

Expand All @@ -40,24 +41,41 @@ class EditionStampListener(private val floodgate: FloodgateLookup, private val l
}

private fun stamp(player: Player) {
if (!floodgate.isBedrock(player.uniqueId)) return

val updated = withEditionProperty(player.gameProfileProperties) ?: return
val edition =
when (floodgate.classify(player.uniqueId)) {
true -> BEDROCK
false -> JAVA
null -> null
}
val updated = withEditionProperty(player.gameProfileProperties, edition)
if (updated == player.gameProfileProperties) return
player.gameProfileProperties = updated
logger.debug("Marked {} as a Bedrock player ({}={})", player.username, PROPERTY, BEDROCK)
if (edition == null) {
logger.debug("Removed unverified edition marker from {}", player.username)
} else {
logger.debug(
"Marked {} with authenticated edition ({}={})",
player.username,
PROPERTY,
edition,
)
}
}

companion object {
/**
* Namespaced because it rides in a list Mojang also writes to — `textures` is theirs.
*
* Backends match on the name and treat any other value, or its absence, as Java.
* Backends require one of the authenticated values written below.
*/
const val PROPERTY = "grounds:edition"

/** The only value written today. A Java player carries no property rather than a value. */
/** Authenticated Floodgate classification. */
const val BEDROCK = "bedrock"

/** Explicit Java classification written by proxies where Floodgate is absent or says no. */
const val JAVA = "java"

/**
* The property list to write, or null when [existing] already carries the marker.
*
Expand All @@ -71,5 +89,21 @@ class EditionStampListener(private val floodgate: FloodgateLookup, private val l
fun withEditionProperty(existing: List<GameProfile.Property>): List<GameProfile.Property>? =
if (existing.any { it.name == PROPERTY }) null
else existing + GameProfile.Property(PROPERTY, BEDROCK, "")

/**
* Replaces all untrusted edition markers with one authenticated [value]. A null value is an
* unknown classification and therefore removes markers without adding one.
*/
fun withEditionProperty(
existing: List<GameProfile.Property>,
value: String?,
): List<GameProfile.Property> {
require(value == null || value == BEDROCK || value == JAVA) {
"Unsupported edition marker: $value"
}
val withoutEdition = existing.filterNot { it.name == PROPERTY }
return if (value == null) withoutEdition
else withoutEdition + GameProfile.Property(PROPERTY, value, "")
}
}
}
Loading
Loading