diff --git a/README.md b/README.md index 2dabd7c19..0904f3820 100644 --- a/README.md +++ b/README.md @@ -103,24 +103,32 @@ dependencies { ## Quickstart -Bootstrap helpers are the recommended platform entry points: +Bootstrap helpers are the recommended platform entry points. The name and data +directory are derived from the plugin/mod metadata, so the entry points are +consistent across platforms: - Bukkit/Paper: `BukkitBootstrap.forPlugin(this)` -- BungeeCord: `BungeeBootstrap.forPlugin(this, "MyPlugin")` +- BungeeCord: `BungeeBootstrap.forPlugin(this)` +- Velocity: `VelocityBootstrap.forPlugin(proxy, this)` - Fabric: `FabricBootstrap.forMod("mymod", () -> server)` -- Velocity: `VelocityBootstrap.forPlugin(proxy, this, "MyPlugin", dataDir)` -- NeoForge: manual wiring with `NeoForgePlatformProvider` + - `CommandRegistry.create(...)` +- NeoForge: `NeoForgeBootstrap.forMod("mymod", () -> server)` + +Every builder exposes `.withRecommendedDefaults()` (the default, every language +and messages feature on) and `.minimal()` (opt out of the automatic language and +messages wiring when you manage localization yourself), so you configure intent +with one call instead of a wall of boolean toggles. If your project has a shared `common` module, let the platform layer create the runtime and pass `MagicRuntime` into the shared services instead of calling the bootstrap helpers from common code. `buildRuntime()` returns a managed `MagicRuntime` container with the platform, -config manager, logger, language manager, and optional command registry. -Enable diagnostics with `.enableDiagnostics()` and fetch the service from -`runtime.requireComponent(DiagnosticsService.class)` or the bootstrap runtime -result accessor. +config manager, logger, language manager, and optional command registry. On +every platform, `bootstrap.logger()` returns a typed `Logger` facade with +`info/warn/error/...` methods, player-typed overloads, and a fluent +`Logger#log()` builder. Enable diagnostics with `.enableDiagnostics()` and fetch +the service from `runtime.requireComponent(DiagnosticsService.class)` or the +bootstrap runtime result accessor. See the full setup guide in the docs: https://magicutils.theroer.dev/getting-started/quickstart/ diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 334938532..e9ed02322 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -1,5 +1,7 @@ import org.gradle.api.tasks.compile.JavaCompile import org.gradle.jvm.toolchain.JavaLanguageVersion +import java.net.HttpURLConnection +import java.net.URI plugins { kotlin("jvm") version "2.2.0" @@ -44,6 +46,30 @@ publishing { } } +// -Pskip_existing makes publishing idempotent: skip a PublishToMavenRepository +// task whose POM is already in the (immutable) repo, so a resumed release does +// not 409 on the plugins that a prior run already uploaded. HEAD is cheap and +// runs at execution time (skip decided per task), not at configuration. +if (project.hasProperty("skip_existing") && project.hasProperty("publish_repo")) { + val repoBase = (project.property("publish_repo") as String).trimEnd('/') + tasks.withType().configureEach { + onlyIf { + val pub = publication as org.gradle.api.publish.maven.MavenPublication + val path = "${pub.groupId.replace('.', '/')}/${pub.artifactId}/${pub.version}/" + + "${pub.artifactId}-${pub.version}.pom" + val url = URI("$repoBase/$path").toURL() + val exists = runCatching { + (url.openConnection() as HttpURLConnection).run { + requestMethod = "HEAD"; connectTimeout = 10000; readTimeout = 10000 + val code = responseCode; disconnect(); code == 200 + } + }.getOrDefault(false) + if (exists) logger.lifecycle("skip_existing: ${pub.artifactId}:${pub.version} already published, skipping.") + !exists + } + } +} + dependencies { diff --git a/build-logic/gradle.properties b/build-logic/gradle.properties index 876bbf79e..af1b528af 100644 --- a/build-logic/gradle.properties +++ b/build-logic/gradle.properties @@ -12,4 +12,4 @@ neoForgeModdevVersion=2.0.139 # Coordinates for publishing the build-logic plugins as a reusable tool. # Independent of the MagicUtils library version (root gradle.properties). pluginsGroup=dev.ua.theroer.magicutils.build -pluginsVersion=0.1.5 \ No newline at end of file +pluginsVersion=0.1.8 \ No newline at end of file diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerBukkitPlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerBukkitPlugin.kt index fbdb37aa8..841b8ca09 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerBukkitPlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerBukkitPlugin.kt @@ -26,7 +26,6 @@ class MagicUtilsConsumerBukkitPlugin : Plugin { project.pluginManager.apply("magicutils.target") val target = project.extensions.getByType(MagicUtilsTargetExtension::class.java) - val consumer = project.magicUtilsConsumerExtension() project.extensions.configure(JavaPluginExtension::class.java) { java -> java.toolchain.languageVersion.set(JavaLanguageVersion.of(target.java.get())) @@ -43,53 +42,11 @@ class MagicUtilsConsumerBukkitPlugin : Plugin { project.exposeMagicUtilsTargetFacts(target) - // MagicUtils modules the consumer declared. Bukkit has no jar-in-jar, so - // embedMode maps onto the dependency configuration + the shadow jar: - // SHADED → api/implementation, so shadow relocates the modules into the - // fat jar (a self-contained plugin). This is the Bukkit default - // (AUTO resolves here), but see the SHADED note in EmbedMode: - // two shaded consumers on one server load two isolated copies — - // use EXTERNAL there. - // EXTERNAL → compileOnly, and dev/ua/theroer/magicutils is stripped from - // the shadow jar, so the built jar is thin and expects - // MagicUtils installed as a separate server plugin. - // JAR_IN_JAR → rejected by resolveEmbedMode (fail-fast; no JiJ on Bukkit). - // Resolved at afterEvaluate: the consumer sets embedMode in its DSL block, - // which runs after this plugin applies. Bukkit has no Loom early-observe of - // configurations, so a late `add` is safe (unlike the Fabric consumer). - project.afterEvaluate { - val embed = resolveEmbedMode(consumer.embedMode.get(), ConsumerLoader.BUKKIT) - val shaded = embed == EmbedMode.SHADED - val apiConfig = if (shaded) "api" else "compileOnly" - val implConfig = if (shaded) "implementation" else "compileOnly" - val base = consumer.magicutilsVersion.get() - consumer.apiModules.get().forEach { module -> - project.dependencies.add(apiConfig, magicUtilsModuleCoordinate(module, base, target)) - } - consumer.implementationModules.get().forEach { module -> - project.dependencies.add(implConfig, magicUtilsModuleCoordinate(module, base, target)) - } - - // EXTERNAL: strip MagicUtils and its bundled libraries from the shadow - // jar, so this jar carries none of them and the standalone MagicUtils - // plugin provides the single copy at runtime. These reach the fat jar - // transitively via the common module (whose MagicUtils deps are `api`), - // so moving this module's deps to compileOnly alone does not remove - // them; the shadow exclude does. jackson is the config modules' only - // external dependency and the bundle plugin owns its own relocated - // copy — shipping a second here produced a ClassCastException under - // Paper's isolated classloaders, so it is excluded too. - if (!shaded) { - project.tasks.named("shadowJar", com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar::class.java) - .configure { shadow -> - // The `**/` prefix also catches multi-release copies under - // META-INF/versions//, which a root-anchored pattern - // (e.g. `com/fasterxml/...`) would leave behind. - shadow.exclude("**/dev/ua/theroer/magicutils/**") - shadow.exclude("**/com/fasterxml/jackson/**") - } - } - } + // MagicUtils modules per embedMode + the EXTERNAL shadow-strip. Bukkit and + // Velocity share this exactly (flat-classpath JVM plugins, no jar-in-jar), + // so it lives in one helper. SHADED is the Bukkit default (AUTO); EXTERNAL + // makes a thin jar that expects the standalone MagicUtils bundle beside it. + project.configureJvmConsumerEmbed(target, ConsumerLoader.BUKKIT) // Local dev server (runPaper/runFolia), only when the consumer opted in // with `magicutilsConsumer { devServer { ... } }`. The Folia runner is diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerExtension.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerExtension.kt index dd19a49e3..987a52a28 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerExtension.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerExtension.kt @@ -394,7 +394,7 @@ internal fun magicUtilsModuleCoordinate( module: String, version: String, target: MagicUtilsTargetExtension, -): String = "dev.ua.theroer:$module:${target.publishedVersion(version)}" +): String = "dev.ua.theroer:$module:${target.publishedVersion(module, version)}" /** * Exposes the resolved target's facts as extra properties so consumer build @@ -408,8 +408,9 @@ internal fun magicUtilsModuleCoordinate( * - `magicutilsIsDeobfuscated` — Boolean * - `magicutilsClassifier` — e.g. `mc26` * - `magicutilsPublishedVersion` — the base `magicutils_version` with the - * target's `+` suffix (e.g. `1.22.0+26.2`), for the rare coordinate - * a script must build by hand (a classifier-less bundle jar). + * bundle's `+java` suffix (e.g. `1.27.1+java25`), for the rare coordinate a + * script must build by hand (a classifier-less bundle jar). Bundles keep the + * suffix; plain modules are published bare, so this fact is bundle-shaped. */ internal fun Project.exposeMagicUtilsTargetFacts(target: MagicUtilsTargetExtension) { val base = magicUtilsConsumerExtension().magicutilsVersion.get() @@ -418,7 +419,9 @@ internal fun Project.exposeMagicUtilsTargetFacts(target: MagicUtilsTargetExtensi extensions.extraProperties.set("magicutilsJavaVersion", target.java.get()) extensions.extraProperties.set("magicutilsIsDeobfuscated", target.isDeobfuscated) extensions.extraProperties.set("magicutilsClassifier", target.mcClassifier) - extensions.extraProperties.set("magicutilsPublishedVersion", target.publishedVersion(base)) + // This fact is consumed only for bundle jars, which retain the +java + // coordinate; pass a bundle-shaped name so the discriminator keeps the suffix. + extensions.extraProperties.set("magicutilsPublishedVersion", target.publishedVersion("magicutils-bundle", base)) // Optional per-platform facts (not every targets.properties defines them). target.neoforge.orNull?.let { extensions.extraProperties.set("magicutilsNeoforgeVersion", it) } } @@ -452,3 +455,51 @@ internal fun Project.addConsumerMagicUtilsModules( register(apiConfiguration, consumer.apiModules) register(implementationConfiguration, consumer.implementationModules) } + +/** + * Wires the consumer's MagicUtils modules onto a plain-JVM loader (Bukkit, + * Velocity) honouring [EmbedMode], and — for EXTERNAL — strips MagicUtils and its + * bundled jackson from the shadow jar so the standalone bundle owns the single + * runtime copy. Bukkit and Velocity are identical here (both are flat-classpath + * JVM plugins with no jar-in-jar), so this is the one place that logic lives; + * each consumer plugin just passes its [ConsumerLoader]. BungeeCord is always + * shaded (no EXTERNAL split) and uses [addConsumerMagicUtilsModules] instead. + * + * Runs in `afterEvaluate` because the consumer sets `embedMode` in its DSL block, + * which executes after the plugin applies; a plain JVM loader has no Loom + * early-observe of configurations, so a late `add` is safe. + */ +internal fun Project.configureJvmConsumerEmbed( + target: MagicUtilsTargetExtension, + loader: ConsumerLoader, +) { + val consumer = magicUtilsConsumerExtension() + afterEvaluate { + val shaded = resolveEmbedMode(consumer.embedMode.get(), loader) == EmbedMode.SHADED + val apiConfig = if (shaded) "api" else "compileOnly" + val implConfig = if (shaded) "implementation" else "compileOnly" + val base = consumer.magicutilsVersion.get() + consumer.apiModules.get().forEach { module -> + dependencies.add(apiConfig, magicUtilsModuleCoordinate(module, base, target)) + } + consumer.implementationModules.get().forEach { module -> + dependencies.add(implConfig, magicUtilsModuleCoordinate(module, base, target)) + } + + // EXTERNAL: the modules reach the fat jar transitively via the common + // module (whose MagicUtils deps are `api`), so moving this module's own + // deps to compileOnly doesn't remove them — the shadow exclude does. The + // `**/` prefix also catches multi-release copies under + // META-INF/versions//. jackson is the config modules' only external + // dependency and the bundle ships its own relocated copy; a second one + // here clashes under the isolated plugin/proxy classloaders, so it goes + // too. + if (!shaded) { + tasks.named("shadowJar", com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar::class.java) + .configure { shadow -> + shadow.exclude("**/dev/ua/theroer/magicutils/**") + shadow.exclude("**/com/fasterxml/jackson/**") + } + } + } +} diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerFabricPlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerFabricPlugin.kt index 3ad3b583d..40f77b6c1 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerFabricPlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerFabricPlugin.kt @@ -64,7 +64,7 @@ class MagicUtilsConsumerFabricPlugin : Plugin { // for compile/dev-runtime; on deobfuscated 26.x there is no remap, so the // classifier-less jar is itself the fat, self-contained artifact used for // both publish and dev runtime. - val version = target.publishedVersion(consumer.magicutilsVersion.get()) + val version = target.publishedVersion("magicutils-fabric-bundle", consumer.magicutilsVersion.get()) val module = "dev.ua.theroer:magicutils-fabric-bundle:$version" val shippedDep = module val fatDep = if (target.isDeobfuscated) shippedDep else "$module:dev" diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerNeoForgePlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerNeoForgePlugin.kt index 2aaa6fd51..2c069f6c7 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerNeoForgePlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerNeoForgePlugin.kt @@ -68,7 +68,7 @@ class MagicUtilsConsumerNeoForgePlugin : Plugin { val embed = resolveEmbedMode(consumer.embedMode.get(), ConsumerLoader.NEOFORGE) if (embed == EmbedMode.JAR_IN_JAR) { val bundleCoordinate = project.provider { - val version = target.publishedVersion(consumer.magicutilsVersion.get()) + val version = target.publishedVersion("magicutils-neoforge-bundle", consumer.magicutilsVersion.get()) project.dependencies.create("dev.ua.theroer:magicutils-neoforge-bundle:$version") } // moddev registers `jarJar` at apply time, but hook it via diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerVelocityPlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerVelocityPlugin.kt index 003becab2..704ab9f3a 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerVelocityPlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerVelocityPlugin.kt @@ -55,44 +55,11 @@ class MagicUtilsConsumerVelocityPlugin : Plugin { project.exposeMagicUtilsTargetFacts(target) - // MagicUtils modules the consumer declared, wired per embedMode. Velocity - // has no jar-in-jar, so this maps onto the dependency configuration + the - // shadow jar exactly like the Bukkit consumer. Resolved at afterEvaluate - // because the consumer sets embedMode in its DSL block, which runs after - // this plugin applies; Velocity has no Loom early-observe of - // configurations, so a late `add` is safe (unlike the Fabric consumer). - project.afterEvaluate { - val embed = resolveEmbedMode(consumer.embedMode.get(), ConsumerLoader.VELOCITY) - val shaded = embed == EmbedMode.SHADED - val apiConfig = if (shaded) "api" else "compileOnly" - val implConfig = if (shaded) "implementation" else "compileOnly" - val base = consumer.magicutilsVersion.get() - consumer.apiModules.get().forEach { module -> - project.dependencies.add(apiConfig, magicUtilsModuleCoordinate(module, base, target)) - } - consumer.implementationModules.get().forEach { module -> - project.dependencies.add(implConfig, magicUtilsModuleCoordinate(module, base, target)) - } - - // EXTERNAL: strip MagicUtils and its bundled libraries from the shadow - // jar, so this jar carries none of them and the standalone - // velocity-bundle provides the single copy at runtime. They reach the - // fat jar transitively via the common module (whose MagicUtils deps are - // `api`), so moving this module's deps to compileOnly alone does not - // remove them; the shadow exclude does. jackson is the config modules' - // only external dependency and the bundle owns its own copy — shipping - // a second here clashes under the proxy classloader, so exclude it too. - if (!shaded) { - project.tasks.named("shadowJar", com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar::class.java) - .configure { shadow -> - // The `**/` prefix also catches multi-release copies under - // META-INF/versions//, which a root-anchored pattern - // would leave behind. - shadow.exclude("**/dev/ua/theroer/magicutils/**") - shadow.exclude("**/com/fasterxml/jackson/**") - } - } - } + // MagicUtils modules per embedMode + the EXTERNAL shadow-strip — shared + // with the Bukkit consumer (see configureJvmConsumerEmbed). Velocity's + // default is SHADED (self-contained proxy plugin); EXTERNAL yields a thin + // jar that expects the standalone velocity-bundle beside it on the proxy. + project.configureJvmConsumerEmbed(target, ConsumerLoader.VELOCITY) project.afterEvaluate { val spec = consumer.devServerSpec.orNull ?: return@afterEvaluate diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsFanout.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsFanout.kt new file mode 100644 index 000000000..035a12a5b --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsFanout.kt @@ -0,0 +1,104 @@ +package dev.ua.theroer.magicutils.build.matrix + +import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.api.tasks.Exec +import org.gradle.api.tasks.TaskProvider + +/** + * Shared per-target Gradle fan-out. + * + * A single Gradle build graph is pinned to one Minecraft target (resolved in + * `settings.gradle`), so covering every target is inherently N separate Gradle + * invocations. Every feature that must run "the same task, once per target" + * (`buildAllTargets`, the local Maven release, the Modrinth bundle build) shells + * out to the project's own `gradlew` wrapper with `-Ptarget=mcXXXX`. This is the + * single place that mechanism lives, so no caller re-derives the wrapper name, + * the child Gradle home, or the dry-run printing. + * + * A nested `GradleBuild` task can't be used: Gradle forbids a nested build over a + * root that itself `includeBuild`s another project (build-logic), which is + * exactly this layout. Shelling out to `gradlew` is also what CI does per target, + * so the two paths stay behaviourally identical. + */ + +/** The project's Gradle wrapper script name for the current OS. */ +internal fun magicUtilsGradleWrapperName(): String = + if (System.getProperty("os.name").orEmpty().lowercase().contains("win")) "gradlew.bat" else "./gradlew" + +/** One per-target invocation: the extra `gradlew` args to run for [target]. */ +data class MagicUtilsFanoutInvocation( + val target: String, + /** Gradle arguments after the wrapper (tasks + `-P` flags), excluding `-Ptarget`. */ + val args: List, + /** + * Extra environment for the child process. The child runs with an isolated + * `--gradle-user-home`, so it does NOT read the caller's + * ~/.gradle/gradle.properties. Secrets the caller resolved (publish creds, + * Modrinth token) must be passed here as env so the child's publish can + * authenticate. Passed as env, not `-P` args, so they never appear in `ps`. + */ + val env: Map = emptyMap(), +) + +/** + * Registers one task per [invocations] entry (named ``) and + * returns the providers so the caller can build an aggregate that depends on + * them. + * + * A real run is an [Exec] shelling out to the wrapper; each child runs against a + * dedicated Gradle user home (`build/`) so its daemon registry, + * caches and lock files never collide with the parent build that spawned it (the + * parent still holds the root project's locks while this runs). Without this the + * child intermittently fails its cold compile racing the parent for the daemon. + * + * When [dryRun] is true each entry is a plain task that just prints the command + * it would run, so a release can be previewed without spawning any child build. + */ +internal fun registerMagicUtilsFanout( + project: Project, + taskPrefix: String, + taskGroup: String, + invocations: List, + childHomeSubdir: String, + dryRun: Boolean, + describe: (MagicUtilsFanoutInvocation) -> String, +): List> { + val rootDir = project.rootProject.projectDir + val wrapper = magicUtilsGradleWrapperName() + // Under .gradle/, NOT build/: a child invocation may run `clean` (the Maven + // release does, to republish from scratch), which would delete build/ — and + // with it the child's own Gradle home if it lived there, killing the daemon + // mid-run ("could not setcwd"). .gradle/ survives clean. + val childGradleHome = rootDir.resolve(".gradle").resolve(childHomeSubdir) + + fun commandLine(invocation: MagicUtilsFanoutInvocation): List = buildList { + add(wrapper) + addAll(invocation.args) + // -Ptarget pins the whole include graph in settings.gradle; the child + // Gradle home isolates the nested invocation from the parent's locks. + add("-Ptarget=${invocation.target}") + add("--gradle-user-home=${childGradleHome.absolutePath}") + } + + return invocations.map { invocation -> + val taskName = "$taskPrefix${invocation.target.replaceFirstChar(Char::titlecase)}" + if (dryRun) { + project.tasks.register(taskName) { task -> + task.group = taskGroup + task.description = "[dry-run] ${describe(invocation)}" + task.doLast { task.logger.lifecycle("[dry-run] ${commandLine(invocation).joinToString(" ")}") } + } + } else { + project.tasks.register(taskName, Exec::class.java) { task -> + task.group = taskGroup + task.description = describe(invocation) + task.workingDir = rootDir + task.commandLine(commandLine(invocation)) + // The child has an isolated Gradle home; pass resolved secrets as + // env so its publish can authenticate (it can't read ~/.gradle). + invocation.env.forEach { (key, value) -> task.environment(key, value) } + } + } + } +} diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixModel.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixModel.kt index c24cd520e..c7e1ee2db 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixModel.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixModel.kt @@ -61,24 +61,44 @@ internal fun MagicUtilsMatrixDefinition.availablePlatformsFor(target: String): S platforms.values.filter { it.isEnabledFor(target) }.map { it.name }.toSet() /** - * Publish units for every target. The default target publishes all - * categories (DEFAULT_ONLY runs exactly once, here). Non-default targets - * publish COMMON_MATRIX, plus FABRIC_MATRIX when the fabric platform is - * enabled for that target. Non-default targets carry the `mcXXXX` - * classifier suffix. + * Publish units, one per distinct Java level rather than per Minecraft target. + * + * The published coordinate is `+java` (see [publishedVersion]) because + * MagicUtils' compiled bytecode depends only on the Java level, not the + * Minecraft version. Several targets can share a Java level (e.g. mc1205 / + * mc12110 / mc12111 are all Java 21); publishing each would re-upload a + * byte-identical jar to the same coordinate and 409 in the immutable releases + * repo. So we pick one representative target per Java level — the default target + * for its own level, otherwise the first target in file order — and publish the + * full module set there (`publishDefaultMatrix` covers every category, plus the + * Fabric matrix when that representative enables the fabric platform). + * + * The remaining targets still exist for the build/smoke matrix (each Minecraft + * version boots a real server); they just don't publish. */ -internal fun MagicUtilsMatrixDefinition.publishUnits(allTargets: List): List = - allTargets.map { target -> - if (target == defaultTarget) { - MagicUtilsPublishUnit(target, listOf("publishDefaultMatrix"), suffix = false) - } else { - val tasks = mutableListOf("publishCommonMatrix") - if ("fabric" in availablePlatformsFor(target)) { - tasks += "publishFabricMatrix" - } - MagicUtilsPublishUnit(target, tasks, suffix = true) +internal fun MagicUtilsMatrixDefinition.publishUnits( + allTargets: List, + javaLevelOf: (String) -> Int, +): List { + val representatives = LinkedHashMap() + for (target in allTargets) { + val java = javaLevelOf(target) + // Prefer the default target as its level's representative; otherwise the + // first-seen target for that level wins (file order). + if (java !in representatives || target == defaultTarget) { + representatives[java] = target + } + } + return representatives.values.map { target -> + val tasks = mutableListOf("publishDefaultMatrix") + if ("fabric" in availablePlatformsFor(target)) { + tasks += "publishFabricMatrix" } + // suffix is always false now: the +java suffix is intrinsic to the + // coordinate (publishedVersion), not a per-target CI toggle. + MagicUtilsPublishUnit(target, tasks.distinct(), suffix = false) } +} /** Minimal JSON array serializer for the matrix outputs (no dependency needed). */ internal fun List.toMatrixJson(): String = diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt index a4e35e4e8..0418c1a3f 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt @@ -46,7 +46,22 @@ class MagicUtilsMatrixRootPlugin : Plugin { registerAggregatedJavadocTask(project, resolvedContext) registerDevServerAggregateTasks(project) registerPublishCategoryTasks(project) - registerReleaseTasks(project, publishingSpec) + val defaultTargetJava = resolveMagicUtilsTargetSpec( + targetsFile = project.rootProject.file(resolvedContext.definition.targetsFile), + defaultTarget = resolvedContext.definition.defaultTarget, + explicitTarget = resolvedContext.definition.defaultTarget, + ).java + val modrinthSpec = project.gradle.extensions.extraProperties.properties["magicutilsModrinthSpec"] + as? ModrinthReleaseSpec + val releaseSpec = project.gradle.extensions.extraProperties.properties["magicutilsReleaseSpec"] + as? MagicUtilsReleaseSpec ?: MagicUtilsReleaseSpec() + registerReleaseMavenTasks( + project, + publishingSpec, + resolvedContext.definition, + project.rootProject.file(resolvedContext.definition.targetsFile), + ) + registerReleaseJavadocTask(project, publishingSpec) @Suppress("UNCHECKED_CAST") val smokeSpecs = project.gradle.extensions.extraProperties.properties["magicutilsSmokeSpecs"] @@ -60,10 +75,16 @@ class MagicUtilsMatrixRootPlugin : Plugin { ) registerReleaseMatrixTask(project, resolvedContext, smokeSpecs) - val modrinthSpec = project.gradle.extensions.extraProperties.properties["magicutilsModrinthSpec"] - as? ModrinthReleaseSpec val targetsFile = project.rootProject.file(resolvedContext.definition.targetsFile) registerModrinthTasks(project, modrinthSpec, smokeSpecs, resolvedContext.definition.defaultTarget, targetsFile) + // After publishToModrinth exists: the local release wraps it with a + // per-Java-level bundle build fan-out. + registerReleaseModrinthTask(project, resolvedContext.definition, targetsFile) + + // The orchestrator last: every step task it chains (releaseMavenAll, + // releaseModrinth, releaseJavadoc, ...) is registered by now, so the + // release aggregate can wire its mustRunAfter chain directly. + registerReleaseTasks(project, publishingSpec, defaultTargetJava, modrinthSpec?.projectId, releaseSpec) } } @@ -91,7 +112,13 @@ private fun registerMatrixJsonTasks( task.group = "help" task.description = "Print the publish matrix (target + publish tasks) as JSON for CI." task.doLast { - val units = definition.publishUnits(loadAllTargetNames(targetsFile)) + val units = definition.publishUnits(loadAllTargetNames(targetsFile)) { target -> + resolveMagicUtilsTargetSpec( + targetsFile = targetsFile, + defaultTarget = definition.defaultTarget, + explicitTarget = target, + ).java + } println(units.toMatrixJson()) } } @@ -280,37 +307,28 @@ private fun registerAllTargetsTask( return } - val rootDir = project.rootProject.projectDir - val isWindows = System.getProperty("os.name").orEmpty().lowercase().contains("win") - val wrapper = if (isWindows) "gradlew.bat" else "./gradlew" - // Child builds run against a dedicated Gradle user home so their daemon - // registry, caches and lock files never collide with the parent build that - // spawned them (the parent is still holding the root project's locks while - // this task executes). Without this the child intermittently fails its cold - // compile racing the parent for the same daemon/locks. - val childGradleHome = project.layout.buildDirectory.dir("all-targets-gradle-home").get().asFile - - val perTargetTasks = resolvedTargets.map { targetName -> - project.tasks.register( - "buildTarget${targetName.replaceFirstChar(Char::titlecase)}", - org.gradle.api.tasks.Exec::class.java, - ) { task -> - task.group = "matrix" - task.description = "Run '${effectiveSpec.taskType.gradleTask}' for target $targetName." - task.workingDir = rootDir - // Each target is a fresh Gradle invocation; -Ptarget pins its whole - // include graph in settings.gradle. Scenario limits which platforms - // that run builds (defaults to the matrix default scenario when null). - val args = mutableListOf( - wrapper, - effectiveSpec.taskType.gradleTask, - "-Ptarget=$targetName", - "--gradle-user-home=${childGradleHome.absolutePath}", + // Each target is a fresh Gradle invocation; -Ptarget pins its whole include + // graph in settings.gradle. Scenario limits which platforms that run builds + // (defaults to the matrix default scenario when null). The per-target Exec + // mechanism itself lives in registerMagicUtilsFanout so it is shared with the + // release publish/modrinth fan-outs. + val perTargetTasks = registerMagicUtilsFanout( + project = project, + taskPrefix = "buildTarget", + taskGroup = "matrix", + invocations = resolvedTargets.map { targetName -> + MagicUtilsFanoutInvocation( + target = targetName, + args = buildList { + add(effectiveSpec.taskType.gradleTask) + effectiveSpec.scenario?.let { add("-Pscenario=$it") } + }, ) - effectiveSpec.scenario?.let { args += "-Pscenario=$it" } - task.commandLine(args) - } - } + }, + childHomeSubdir = "all-targets-gradle-home", + dryRun = false, + describe = { "Run '${effectiveSpec.taskType.gradleTask}' for target ${it.target}." }, + ) project.tasks.register("buildAllTargets") { task -> task.group = "matrix" diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixSettingsPlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixSettingsPlugin.kt index c8ce1b431..c9f69d7fe 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixSettingsPlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixSettingsPlugin.kt @@ -65,6 +65,16 @@ open class MagicUtilsMatrixSettingsExtension { internal fun toAllTargetsSpec(): MagicUtilsAllTargetsSpec = allTargetsDsl.toSpec() + private val releaseDsl = MagicUtilsReleaseDsl() + + /** Shapes the `release` orchestrator (which steps run: maven/modrinth/tag/javadoc/...). */ + fun release(action: org.gradle.api.Action) { + action.execute(releaseDsl) + } + + internal fun toReleaseSpec(): dev.ua.theroer.magicutils.build.release.MagicUtilsReleaseSpec = + releaseDsl.toSpec() + fun commonProject(path: String) { commonProjectPaths += normalizeProjectPath(path) } @@ -386,6 +396,7 @@ class MagicUtilsMatrixSettingsPlugin : Plugin { settings.gradle.extensions.extraProperties.set("magicutilsSmokeGate", extension.smokeGate()) settings.gradle.extensions.extraProperties.set("magicutilsModrinthSpec", extension.toModrinthSpec()) settings.gradle.extensions.extraProperties.set("magicutilsAllTargetsSpec", extension.toAllTargetsSpec()) + settings.gradle.extensions.extraProperties.set("magicutilsReleaseSpec", extension.toReleaseSpec()) resolvedContext.includedProjects.forEach { projectPath -> settings.include(projectPath.removePrefix(":")) diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsReleaseDsl.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsReleaseDsl.kt new file mode 100644 index 000000000..039c6d75c --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsReleaseDsl.kt @@ -0,0 +1,51 @@ +package dev.ua.theroer.magicutils.build.matrix + +import dev.ua.theroer.magicutils.build.release.MagicUtilsReleaseSpec + +/** + * Consumer-facing DSL for the `release` orchestrator, e.g.: + * + * magicMatrix { + * release { + * // publishMaven = true // default + * publishModrinth = false // this plugin has no Modrinth page + * push = true // push the tag to origin as part of release + * } + * } + * + * Every axis is optional; omitting the block entirely means a full library + * release (validate, bump, tag locally, publish Maven + Modrinth + Javadoc, + * verify). Each step is also overridable per invocation with + * `-Prelease.=true|false` without editing settings.gradle. + */ +open class MagicUtilsReleaseDsl { + var validateVersion: Boolean = true + var validateBuild: Boolean = true + var bump: Boolean = true + var tag: Boolean = true + var push: Boolean = false + var publishMaven: Boolean = true + var publishModrinth: Boolean = true + var publishJavadoc: Boolean = true + var verify: Boolean = true + + /** + * Branch a release may run from (releasePreflight gate). Defaults to `main`; + * set to `null` to release from any branch. Override per invocation with + * `-Prelease.branch=`, or bypass once with `-Prelease.allowAnyBranch=true`. + */ + var releaseBranch: String? = "main" + + internal fun toSpec(): MagicUtilsReleaseSpec = MagicUtilsReleaseSpec( + validateVersion = validateVersion, + validateBuild = validateBuild, + bump = bump, + tag = tag, + push = push, + publishMaven = publishMaven, + publishModrinth = publishModrinth, + publishJavadoc = publishJavadoc, + verify = verify, + releaseBranch = releaseBranch, + ) +} diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsBukkitBundlePlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsBukkitBundlePlugin.kt deleted file mode 100644 index 1da01f791..000000000 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsBukkitBundlePlugin.kt +++ /dev/null @@ -1,121 +0,0 @@ -package dev.ua.theroer.magicutils.build.module - -import dev.ua.theroer.magicutils.build.support.* -import dev.ua.theroer.magicutils.build.target.* - -import org.gradle.api.Plugin -import org.gradle.api.Project -import org.gradle.api.attributes.Usage -import org.gradle.api.publish.maven.MavenPublication -import org.gradle.kotlin.dsl.* -import org.gradle.language.jvm.tasks.ProcessResources -import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar - -class MagicUtilsBukkitBundlePlugin : Plugin { - override fun apply(project: Project) { - project.pluginManager.apply("magicutils.java-library") - project.pluginManager.apply("magicutils.common") - project.pluginManager.apply("magicutils.target") - project.pluginManager.apply("magicutils.shadow") - - val magicutilsTarget = project.extensions.getByType(MagicUtilsTargetExtension::class.java) - val moduleName = project.magicUtilsModuleName() - val apiVersion = bukkitApiVersion(magicutilsTarget.minecraft.get()) - - with(project) { - val bundleShadow = configurations.create("bundleShadow") - bundleShadow.isCanBeConsumed = false - bundleShadow.isCanBeResolved = true - bundleShadow.attributes.attribute( - Usage.USAGE_ATTRIBUTE, - objects.named(Usage::class.java, Usage.JAVA_RUNTIME) - ) - - val bundleProjects = listOf( - project(":platform-api"), - project(":logger"), - project(":commands"), - project(":diagnostics"), - project(":http-client"), - project(":placeholders"), - project(":core"), - project(":platform-bukkit") - ) - - val bundleShadedProjects = listOf( - project(":config"), - project(":config-yaml"), - project(":config-toml"), - project(":lang") - ) - - bundleShadedProjects.forEach { dep -> - val depProject = project(dep.path) - val depDependency = dependencies.add("bundleShadow", depProject) as org.gradle.api.artifacts.ProjectDependency - depDependency.targetConfiguration = "shadedElements" - } - - bundleProjects.forEach { dep -> - dependencies.add("bundleShadow", project(dep.path)) - dependencies.add("compileOnly", project(dep.path)) - } - - bundleShadedProjects.forEach { dep -> - dependencies.add("compileOnly", project(dep.path)) - } - - dependencies.add("compileOnly", "io.papermc.paper:paper-api:${magicutilsTarget.paper.get()}") - - tasks.named("processResources", ProcessResources::class.java).configure { resources -> - resources.inputs.property("version", version) - resources.inputs.property("apiVersion", apiVersion) - resources.filesMatching(listOf("plugin.yml", "paper-plugin.yml")) { details -> - details.expand( - mapOf( - "version" to version, - "apiVersion" to apiVersion, - ) - ) - } - } - - tasks.named("shadowJar", ShadowJar::class.java).configure { shadowJarTask -> - shadowJarTask.archiveBaseName.set(moduleName) - shadowJarTask.archiveClassifier.set("") - shadowJarTask.configurations.set(setOf(bundleShadow)) - shadowJarTask.mergeServiceFiles() - } - - tasks.configureEach { - if (it.javaClass.simpleName == "GenerateModuleMetadata") { - it.enabled = false - } - } - } - - project.extensions.configure(org.gradle.api.publish.PublishingExtension::class.java) { publishing -> - publishing.publications.create("mavenJava", MavenPublication::class.java) { publication -> - publication.artifactId = moduleName - publication.artifact(project.tasks.named("shadowJar", ShadowJar::class.java).get()) - publication.artifact(project.tasks.named("sourcesJar").get()) - publication.artifact(project.tasks.named("javadocJar").get()) - publication.pom.withXml { xml -> - xml.asElement().getElementsByTagName("dependencies").item(0)?.let { node -> - node.parentNode.removeChild(node) - } - } - } - - project.magicUtilsPublishRepository(publishing) - } - } -} - -private fun bukkitApiVersion(minecraftVersion: String): String { - val parts = minecraftVersion.split('.') - return if (parts.size >= 3) { - "${parts[0]}.${parts[1]}" - } else { - minecraftVersion - } -} diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsBungeeBundlePlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsBungeeBundlePlugin.kt deleted file mode 100644 index 4c70ed9d6..000000000 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsBungeeBundlePlugin.kt +++ /dev/null @@ -1,126 +0,0 @@ -package dev.ua.theroer.magicutils.build.module - -import dev.ua.theroer.magicutils.build.consumer.MagicUtilsConsumerBungeePlugin -import dev.ua.theroer.magicutils.build.support.* -import dev.ua.theroer.magicutils.build.target.* - -import org.gradle.api.Plugin -import org.gradle.api.Project -import org.gradle.api.attributes.Usage -import org.gradle.api.publish.maven.MavenPublication -import org.gradle.kotlin.dsl.* -import org.gradle.language.jvm.tasks.ProcessResources -import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar - -/** - * Builds the standalone `bungee-bundle` plugin: a shaded, drop-in BungeeCord - * plugin that jar-in-jars every MagicUtils module so proxy owners install one - * runtime. This mirrors [MagicUtilsVelocityBundlePlugin] (BungeeCord is a plain - * JVM plugin like Velocity/Bukkit, so it shades via the `bundleShadow` - * configuration rather than jar-in-jarring like the loader-based Fabric/NeoForge - * bundles). - * - * Unlike bukkit/fabric there is no per-target BungeeCord API: the API is - * decoupled from the Minecraft version, so the version comes from the - * `bungeeApiVersion` gradle property (default - * [MagicUtilsConsumerBungeePlugin.DEFAULT_BUNGEE_API]). - */ -class MagicUtilsBungeeBundlePlugin : Plugin { - override fun apply(project: Project) { - project.pluginManager.apply("magicutils.java-library") - project.pluginManager.apply("magicutils.common") - project.pluginManager.apply("magicutils.target") - project.pluginManager.apply("magicutils.shadow") - - val moduleName = project.magicUtilsModuleName() - val bungeeApiVersion = project.providers.gradleProperty("bungeeApiVersion") - .orElse(MagicUtilsConsumerBungeePlugin.DEFAULT_BUNGEE_API).get() - - with(project) { - val bundleShadow = configurations.create("bundleShadow") - bundleShadow.isCanBeConsumed = false - bundleShadow.isCanBeResolved = true - bundleShadow.attributes.attribute( - Usage.USAGE_ATTRIBUTE, - objects.named(Usage::class.java, Usage.JAVA_RUNTIME) - ) - - val bundleProjects = listOf( - project(":platform-api"), - project(":logger"), - project(":commands"), - project(":diagnostics"), - project(":http-client"), - project(":placeholders"), - project(":core"), - project(":platform-bungee") - ) - - val bundleShadedProjects = listOf( - project(":config"), - project(":config-yaml"), - project(":config-toml"), - project(":lang") - ) - - bundleShadedProjects.forEach { dep -> - val depProject = project(dep.path) - val depDependency = dependencies.add("bundleShadow", depProject) as org.gradle.api.artifacts.ProjectDependency - depDependency.targetConfiguration = "shadedElements" - } - - bundleProjects.forEach { dep -> - dependencies.add("bundleShadow", project(dep.path)) - dependencies.add("compileOnly", project(dep.path)) - } - - bundleShadedProjects.forEach { dep -> - dependencies.add("compileOnly", project(dep.path)) - } - - // BungeeCord API on the compile classpath for the plugin entrypoint - // (Plugin/ProxyServer). It is provided by the proxy at runtime, so it - // stays out of the bundle jar (compileOnly, not bundleShadow). The - // plugin descriptor is the hand-authored plugin.yml below. - dependencies.add("compileOnly", "net.md-5:bungeecord-api:$bungeeApiVersion") - - // Fill plugin.yml's ${version} from the build version, the same token - // flow bukkit-bundle/velocity-bundle use. - tasks.named("processResources", ProcessResources::class.java).configure { resources -> - resources.inputs.property("version", version) - resources.filesMatching(listOf("plugin.yml")) { details -> - details.expand(mapOf("version" to version)) - } - } - - tasks.named("shadowJar", ShadowJar::class.java).configure { shadowJarTask -> - shadowJarTask.archiveBaseName.set(moduleName) - shadowJarTask.archiveClassifier.set("") - shadowJarTask.configurations.set(setOf(bundleShadow)) - shadowJarTask.mergeServiceFiles() - } - - tasks.configureEach { - if (it.javaClass.simpleName == "GenerateModuleMetadata") { - it.enabled = false - } - } - } - - project.extensions.configure(org.gradle.api.publish.PublishingExtension::class.java) { publishing -> - publishing.publications.create("mavenJava", MavenPublication::class.java) { publication -> - publication.artifactId = moduleName - publication.artifact(project.tasks.named("shadowJar", ShadowJar::class.java).get()) - publication.artifact(project.tasks.named("sourcesJar").get()) - publication.artifact(project.tasks.named("javadocJar").get()) - publication.pom.withXml { xml -> - xml.asElement().getElementsByTagName("dependencies").item(0)?.let { node -> - node.parentNode.removeChild(node) - } - } - } - - project.magicUtilsPublishRepository(publishing) - } - } -} diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsFabricBundlePlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsFabricBundlePlugin.kt index 866be044f..1e065c56b 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsFabricBundlePlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsFabricBundlePlugin.kt @@ -44,6 +44,15 @@ class MagicUtilsFabricBundlePlugin : Plugin { project(":commands-brigadier"), project(":core"), project(":diagnostics"), + // Platform-neutral messaging runtime (MessageBus + plugin-messaging + // transport). Must be listed explicitly: on <26 it used to arrive + // only transitively via commands-fabric's `api(":messaging")` through + // Loom's namedElements fat jar, but the 26.x bundle is built from + // `bundleShadow` alone (the jiJRemap path drops transitive api project + // deps), so the messaging package silently vanished from the 26.x + // (java25) bundle. Adding it here inlines it on every branch, the same + // way the neoforge/jvm bundles list `:messaging` explicitly. + project(":messaging"), // Jackson (its only external dep) is already bundled via config-yaml/toml. project(":http-client") ) @@ -206,11 +215,7 @@ class MagicUtilsFabricBundlePlugin : Plugin { artifact.classifier = "dev" } } - publication.pom.withXml { xml -> - xml.asElement().getElementsByTagName("dependencies").item(0)?.let { node -> - node.parentNode.removeChild(node) - } - } + publication.stripPomDependencies() } project.magicUtilsPublishRepository(publishing) diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsJvmBundlePlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsJvmBundlePlugin.kt new file mode 100644 index 000000000..ec1e105e5 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsJvmBundlePlugin.kt @@ -0,0 +1,186 @@ +package dev.ua.theroer.magicutils.build.module + +import dev.ua.theroer.magicutils.build.consumer.MagicUtilsConsumerBungeePlugin +import dev.ua.theroer.magicutils.build.consumer.MagicUtilsConsumerVelocityPlugin +import dev.ua.theroer.magicutils.build.support.* +import dev.ua.theroer.magicutils.build.target.* + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.attributes.Usage +import org.gradle.api.publish.maven.MavenPublication +import org.gradle.kotlin.dsl.* +import org.gradle.language.jvm.tasks.ProcessResources +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar + +/** + * Builds a standalone, shaded, drop-in plugin for a plain-JVM server platform + * (Bukkit/Paper, BungeeCord, Velocity). All three shade every MagicUtils module + * into one runtime jar via the `bundleShadow` configuration and differ only in: + * + * - which `platform-*` module carries the entrypoint, + * - the compile-only server API coordinate (from the target for Paper, from a + * gradle property for the version-decoupled proxy APIs), + * - the plugin-descriptor resource file(s) filtered for `${version}` (and, for + * Bukkit, the derived `${apiVersion}`). + * + * Those three axes are the [JvmBundlePlatform] table below; everything else (the + * `bundleShadow` wiring, shaded config modules, shadowJar setup, publication) is + * shared. The loader-based Fabric/NeoForge bundles jar-in-jar instead of shading + * and keep their own plugins. + */ +abstract class MagicUtilsJvmBundlePlugin(private val platform: JvmBundlePlatform) : Plugin { + override fun apply(project: Project) { + project.pluginManager.apply("magicutils.java-library") + project.pluginManager.apply("magicutils.common") + project.pluginManager.apply("magicutils.target") + project.pluginManager.apply("magicutils.shadow") + + val moduleName = project.magicUtilsModuleName() + + with(project) { + val bundleShadow = configurations.create("bundleShadow") + bundleShadow.isCanBeConsumed = false + bundleShadow.isCanBeResolved = true + bundleShadow.attributes.attribute( + Usage.USAGE_ATTRIBUTE, + objects.named(Usage::class.java, Usage.JAVA_RUNTIME) + ) + + val bundleProjects = listOf( + project(":platform-api"), + project(":logger"), + project(":commands"), + project(":diagnostics"), + project(":http-client"), + project(":placeholders"), + project(":core"), + project(platform.platformProjectPath) + ) + + val bundleShadedProjects = listOf( + project(":config"), + project(":config-yaml"), + project(":config-toml"), + project(":lang") + ) + + bundleShadedProjects.forEach { dep -> + val depProject = project(dep.path) + val depDependency = dependencies.add("bundleShadow", depProject) as org.gradle.api.artifacts.ProjectDependency + depDependency.targetConfiguration = "shadedElements" + } + + bundleProjects.forEach { dep -> + dependencies.add("bundleShadow", project(dep.path)) + dependencies.add("compileOnly", project(dep.path)) + } + + bundleShadedProjects.forEach { dep -> + dependencies.add("compileOnly", project(dep.path)) + } + + // Ship the optional Redis transport (Jedis) so operators can enable it + // in messaging.yml; the default plugin-messaging transport needs it not. + magicUtilsBundleRedis("bundleShadow") + + // Server API on the compile classpath for the plugin entrypoint; the + // server provides it at runtime, so it stays out of the bundle jar + // (compileOnly, not bundleShadow). The plugin descriptor is the + // hand-authored resource file(s) filtered below. + dependencies.add("compileOnly", platform.apiCoordinate(project)) + + // Extra ${...} tokens the descriptor needs beyond ${version}: Bukkit's + // plugin.yml/paper-plugin.yml carry an ${apiVersion} derived from the + // runtime Minecraft; the proxies have none. + val extraTokens = platform.extraResourceTokens(project) + + tasks.named("processResources", ProcessResources::class.java).configure { resources -> + resources.inputs.property("version", version) + extraTokens.forEach { (key, value) -> resources.inputs.property(key, value) } + resources.filesMatching(platform.resourceFiles) { details -> + details.expand(mapOf("version" to version) + extraTokens) + } + } + + tasks.named("shadowJar", ShadowJar::class.java).configure { shadowJarTask -> + shadowJarTask.archiveBaseName.set(moduleName) + shadowJarTask.archiveClassifier.set("") + shadowJarTask.configurations.set(setOf(bundleShadow)) + shadowJarTask.mergeServiceFiles() + } + + tasks.configureEach { + if (it.javaClass.simpleName == "GenerateModuleMetadata") { + it.enabled = false + } + } + } + + project.extensions.configure(org.gradle.api.publish.PublishingExtension::class.java) { publishing -> + publishing.publications.create("mavenJava", MavenPublication::class.java) { publication -> + publication.artifactId = moduleName + publication.artifact(project.tasks.named("shadowJar", ShadowJar::class.java).get()) + publication.artifact(project.tasks.named("sourcesJar").get()) + publication.artifact(project.tasks.named("javadocJar").get()) + publication.stripPomDependencies() + } + + project.magicUtilsPublishRepository(publishing) + } + } +} + +/** + * The three axes that distinguish a JVM-bundle platform. Each concrete plugin + * (registered under `magicutils.-bundle`) passes one of these. + */ +enum class JvmBundlePlatform( + val platformProjectPath: String, + val resourceFiles: List, +) { + BUKKIT(":platform-bukkit", listOf("plugin.yml", "paper-plugin.yml")) { + override fun apiCoordinate(project: Project): String { + val target = project.extensions.getByType(MagicUtilsTargetExtension::class.java) + return "io.papermc.paper:paper-api:${target.paper.get()}" + } + + override fun extraResourceTokens(project: Project): Map { + val target = project.extensions.getByType(MagicUtilsTargetExtension::class.java) + return mapOf("apiVersion" to bukkitApiVersion(target.minecraft.get())) + } + }, + BUNGEE(":platform-bungee", listOf("plugin.yml")) { + override fun apiCoordinate(project: Project): String { + val version = project.providers.gradleProperty("bungeeApiVersion") + .orElse(MagicUtilsConsumerBungeePlugin.DEFAULT_BUNGEE_API).get() + return "net.md-5:bungeecord-api:$version" + } + }, + VELOCITY(":platform-velocity", listOf("velocity-plugin.json")) { + override fun apiCoordinate(project: Project): String { + val version = project.providers.gradleProperty("velocityApiVersion") + .orElse(MagicUtilsConsumerVelocityPlugin.DEFAULT_VELOCITY_API).get() + return "com.velocitypowered:velocity-api:$version" + } + }; + + /** The compileOnly server-API coordinate for this platform. */ + abstract fun apiCoordinate(project: Project): String + + /** Descriptor tokens beyond `${version}`; empty for the proxies. */ + open fun extraResourceTokens(project: Project): Map = emptyMap() +} + +class MagicUtilsBukkitBundlePlugin : MagicUtilsJvmBundlePlugin(JvmBundlePlatform.BUKKIT) +class MagicUtilsBungeeBundlePlugin : MagicUtilsJvmBundlePlugin(JvmBundlePlatform.BUNGEE) +class MagicUtilsVelocityBundlePlugin : MagicUtilsJvmBundlePlugin(JvmBundlePlatform.VELOCITY) + +private fun bukkitApiVersion(minecraftVersion: String): String { + val parts = minecraftVersion.split('.') + return if (parts.size >= 3) { + "${parts[0]}.${parts[1]}" + } else { + minecraftVersion + } +} diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsNeoForgeBundlePlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsNeoForgeBundlePlugin.kt index 00a223c2c..0ce2b7eb9 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsNeoForgeBundlePlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsNeoForgeBundlePlugin.kt @@ -47,6 +47,7 @@ class MagicUtilsNeoForgeBundlePlugin : Plugin { ":core", ":diagnostics", ":http-client", + ":messaging", ":platform-neoforge", ":commands-neoforge", ).forEach(::addBundleProject) @@ -88,6 +89,23 @@ class MagicUtilsNeoForgeBundlePlugin : Plugin { dependencies.add("bundleContents", coord) } + // The messaging module's MessageCodec (and the config module) need + // Jackson at runtime. Project deps above are non-transitive, so add + // jackson-databind explicitly, mirroring the Adventure handling. + val jacksonVersion = project.extensions + .getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java) + .named("libs") + .findVersion("jackson") + .get() + .requiredVersion + listOf( + "com.fasterxml.jackson.core:jackson-databind", + "com.fasterxml.jackson.core:jackson-core", + "com.fasterxml.jackson.core:jackson-annotations", + ).forEach { coord -> + dependencies.add("bundleContents", "$coord:$jacksonVersion") + } + tasks.named("jar", Jar::class.java).configure { jarTask -> jarTask.archiveBaseName.set(moduleName) jarTask.duplicatesStrategy = DuplicatesStrategy.EXCLUDE @@ -148,11 +166,7 @@ class MagicUtilsNeoForgeBundlePlugin : Plugin { publishing.publications.create("mavenJava", MavenPublication::class.java) { publication -> publication.artifactId = moduleName publication.from(project.components.getByName("java")) - publication.pom.withXml { xml -> - xml.asElement().getElementsByTagName("dependencies").item(0)?.let { node -> - node.parentNode.removeChild(node) - } - } + publication.stripPomDependencies() } project.magicUtilsPublishRepository(publishing) diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsVelocityBundlePlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsVelocityBundlePlugin.kt deleted file mode 100644 index f46345b61..000000000 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsVelocityBundlePlugin.kt +++ /dev/null @@ -1,129 +0,0 @@ -package dev.ua.theroer.magicutils.build.module - -import dev.ua.theroer.magicutils.build.support.* -import dev.ua.theroer.magicutils.build.target.* - -import org.gradle.api.Plugin -import org.gradle.api.Project -import org.gradle.api.attributes.Usage -import org.gradle.api.publish.maven.MavenPublication -import org.gradle.kotlin.dsl.* -import org.gradle.language.jvm.tasks.ProcessResources -import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar - -/** - * Builds the standalone `velocity-bundle` plugin: a shaded, drop-in Velocity - * plugin that jar-in-jars every MagicUtils module so proxy owners install one - * runtime. This mirrors [MagicUtilsBukkitBundlePlugin] (Velocity is a plain JVM - * plugin like Bukkit, so it shades via the `bundleShadow` configuration rather - * than jar-in-jarring like the loader-based Fabric/NeoForge bundles). - * - * Unlike bukkit/fabric there is no per-target Velocity API: Velocity's API is - * decoupled from the Minecraft version, so the version comes from the - * `velocityApiVersion` gradle property (default - * [MagicUtilsConsumerVelocityPlugin.DEFAULT_VELOCITY_API]). - */ -class MagicUtilsVelocityBundlePlugin : Plugin { - override fun apply(project: Project) { - project.pluginManager.apply("magicutils.java-library") - project.pluginManager.apply("magicutils.common") - project.pluginManager.apply("magicutils.target") - project.pluginManager.apply("magicutils.shadow") - - val moduleName = project.magicUtilsModuleName() - val velocityApiVersion = project.providers.gradleProperty("velocityApiVersion") - .orElse(DEFAULT_VELOCITY_API).get() - - with(project) { - val bundleShadow = configurations.create("bundleShadow") - bundleShadow.isCanBeConsumed = false - bundleShadow.isCanBeResolved = true - bundleShadow.attributes.attribute( - Usage.USAGE_ATTRIBUTE, - objects.named(Usage::class.java, Usage.JAVA_RUNTIME) - ) - - val bundleProjects = listOf( - project(":platform-api"), - project(":logger"), - project(":commands"), - project(":diagnostics"), - project(":http-client"), - project(":placeholders"), - project(":core"), - project(":platform-velocity") - ) - - val bundleShadedProjects = listOf( - project(":config"), - project(":config-yaml"), - project(":config-toml"), - project(":lang") - ) - - bundleShadedProjects.forEach { dep -> - val depProject = project(dep.path) - val depDependency = dependencies.add("bundleShadow", depProject) as org.gradle.api.artifacts.ProjectDependency - depDependency.targetConfiguration = "shadedElements" - } - - bundleProjects.forEach { dep -> - dependencies.add("bundleShadow", project(dep.path)) - dependencies.add("compileOnly", project(dep.path)) - } - - bundleShadedProjects.forEach { dep -> - dependencies.add("compileOnly", project(dep.path)) - } - - // Velocity API on the compile classpath for the plugin entrypoint - // (@Subscribe/ProxyServer/@DataDirectory). It is provided by the proxy - // at runtime, so it stays out of the bundle jar (compileOnly, not - // bundleShadow). No annotationProcessor: the plugin descriptor is the - // hand-authored velocity-plugin.json below, not the @Plugin AP. - dependencies.add("compileOnly", "com.velocitypowered:velocity-api:$velocityApiVersion") - - // Fill velocity-plugin.json's ${version} from the build version, the - // same token flow bukkit-bundle uses for plugin.yml. - tasks.named("processResources", ProcessResources::class.java).configure { resources -> - resources.inputs.property("version", version) - resources.filesMatching(listOf("velocity-plugin.json")) { details -> - details.expand(mapOf("version" to version)) - } - } - - tasks.named("shadowJar", ShadowJar::class.java).configure { shadowJarTask -> - shadowJarTask.archiveBaseName.set(moduleName) - shadowJarTask.archiveClassifier.set("") - shadowJarTask.configurations.set(setOf(bundleShadow)) - shadowJarTask.mergeServiceFiles() - } - - tasks.configureEach { - if (it.javaClass.simpleName == "GenerateModuleMetadata") { - it.enabled = false - } - } - } - - project.extensions.configure(org.gradle.api.publish.PublishingExtension::class.java) { publishing -> - publishing.publications.create("mavenJava", MavenPublication::class.java) { publication -> - publication.artifactId = moduleName - publication.artifact(project.tasks.named("shadowJar", ShadowJar::class.java).get()) - publication.artifact(project.tasks.named("sourcesJar").get()) - publication.artifact(project.tasks.named("javadocJar").get()) - publication.pom.withXml { xml -> - xml.asElement().getElementsByTagName("dependencies").item(0)?.let { node -> - node.parentNode.removeChild(node) - } - } - } - - project.magicUtilsPublishRepository(publishing) - } - } - - companion object { - const val DEFAULT_VELOCITY_API = "3.1.1" - } -} diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthDsl.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthDsl.kt index 4bf3b04a5..942d7b603 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthDsl.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthDsl.kt @@ -17,7 +17,8 @@ import org.gradle.api.Action * } * } * - * The token is read from the MODRINTH_TOKEN env var at publish time, never here. + * The token is read from the `modrinth_token` property or MODRINTH_TOKEN env at + * publish time, never here. */ open class MagicUtilsModrinthDsl { var projectId: String = "" diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthTasks.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthTasks.kt index 671bffce4..c58732f2a 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthTasks.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthTasks.kt @@ -1,7 +1,9 @@ package dev.ua.theroer.magicutils.build.release +import dev.ua.theroer.magicutils.build.support.findMagicUtilsModrinthToken import dev.ua.theroer.magicutils.build.smoke.SmokePlatformSpec import dev.ua.theroer.magicutils.build.smoke.expandVersionsFull +import dev.ua.theroer.magicutils.build.target.javaSuffixedCoordinate import dev.ua.theroer.magicutils.build.target.resolveMagicUtilsTargetSpec import org.gradle.api.DefaultTask @@ -22,7 +24,8 @@ import java.time.Duration * Kotlin replacement for the reusable part of verified-plugin's * `publish_to_modrinth.sh`: uploads one Modrinth version per declared artifact * (idempotent — skips a version_number that already exists). The token comes - * from the MODRINTH_TOKEN environment variable. + * from the `modrinth_token` Gradle property or the MODRINTH_TOKEN env var, the + * same property-or-env resolution as the Maven publish secrets. */ // v3 for native per-version `environment`; body shape is otherwise the same as v2. private const val MODRINTH_API = "https://api.modrinth.com/v3" @@ -36,7 +39,7 @@ internal fun registerModrinthTasks( ) { project.tasks.register("publishToModrinth", ModrinthPublishTask::class.java) { task -> task.group = "publishing" - task.description = "Upload artifacts to Modrinth (-Pversion=X.Y.Z; MODRINTH_TOKEN env). " + + task.description = "Upload artifacts to Modrinth (-Pversion=X.Y.Z; modrinth_token property or MODRINTH_TOKEN env). " + "Artifacts come from the smoke matrix unless the modrinth {} block lists them." // When the modrinth {} block declares no artifacts, synthesise them from // the smoke matrix — the single source of truth (same rows printReleaseMatrix @@ -59,6 +62,9 @@ internal fun registerModrinthTasks( ?: throw GradleException("Pass the release version via -Pversion=X.Y.Z.") }) task.rootDir.set(project.rootDir.absolutePath) + // Resolved in the configuration phase (property-or-env), so the token + // source is uniform with the Maven secrets. Optional: a dry run needs none. + task.token.set(project.provider { project.findMagicUtilsModrinthToken() }) task.notCompatibleWithConfigurationCache("Performs network uploads.") } } @@ -109,12 +115,10 @@ private fun modrinthArtifactsFromMatrix( ): List = smokeSpecs .filter { it.name !in MODRINTH_NON_PUBLISHED_PLATFORMS } .flatMap { platform -> - // The bundle jar is named by the *library* Minecraft, not the runtime one: - // mc1201 and mc1205 both publish `+1.20.1` (one 1.20.x library coordinate, - // two Java runtimes for the smoke). So two smoke entries can map to a single - // jar. Group entries by their library-Minecraft coordinate and merge their - // advertised game versions into ONE Modrinth version — otherwise we'd upload - // the same file twice and split its supported versions across duplicates. + // Group smoke entries by Java level: every Minecraft version sharing a Java + // level ships the same bundle jar (the coordinate is `+java`), so they + // fold into one Modrinth version whose game_versions is their union — + // otherwise we'd upload the same file once per Minecraft version. val ver = version ?: "{version}" platform.versionMatrix .groupBy { entry -> @@ -122,16 +126,17 @@ private fun modrinthArtifactsFromMatrix( targetsFile = targetsFile, defaultTarget = defaultTarget, explicitTarget = entry.target ?: defaultTarget, - ).libraryMinecraft + ).java } - .map { (libraryMc, entries) -> - val fileName = "magicutils-${platform.name}-bundle-$ver+$libraryMc.jar" - // Merge + de-dup game versions across the entries sharing this jar, - // in matrix order (the versions the release advertises for this file). + .map { (java, entries) -> + // The bundle jar is named by the Java level (the published coordinate + // is `+java`), so all Minecraft versions sharing a Java level + // map to one jar. Merge their advertised game versions into ONE + // Modrinth version instead of re-uploading the same file per MC. + val fileName = "magicutils-${platform.name}-bundle-${javaSuffixedCoordinate(ver, java)}.jar" val gameVersions = entries.flatMap { it.versions.expandVersionsFull() }.distinct() - // Stable key from the library coordinate (dots dropped) so it is a - // valid Modrinth file part and unique per jar. - val key = "${platform.name}-mc${libraryMc.replace(".", "")}" + // Stable, valid Modrinth file part, unique per jar. + val key = "${platform.name}-java$java" ModrinthArtifact( key = key, file = "${platform.name}-bundle/build/libs/$fileName", @@ -151,6 +156,11 @@ abstract class ModrinthPublishTask : DefaultTask() { @get:Input abstract val baseVersion: Property @get:Input abstract val rootDir: Property + // @Internal, not @Input: a secret must never enter the up-to-date hash or the + // build cache. Optional so a dry run (which returns before needing it) works + // without a token configured. + @get:Internal abstract val token: Property + @TaskAction fun run() { val spec = releaseSpec.orNull @@ -181,8 +191,8 @@ abstract class ModrinthPublishTask : DefaultTask() { return } - val token = System.getenv("MODRINTH_TOKEN")?.takeIf { it.isNotBlank() } - ?: throw GradleException("MODRINTH_TOKEN environment variable is not set.") + val token = token.orNull?.takeIf { it.isNotBlank() } + ?: throw GradleException("Modrinth token is not set (modrinth_token property or MODRINTH_TOKEN env).") val client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(15)).build() val existing = fetchExistingVersions(client, token, spec.projectId) @@ -234,17 +244,7 @@ abstract class ModrinthPublishTask : DefaultTask() { if (response.statusCode() !in 200..299) { throw GradleException("Failed to list Modrinth versions (HTTP ${response.statusCode()}): ${response.body()}") } - // Proper JSON parse: a regex can't pair id↔version_number because nested - // file/dependency objects also carry "id". Later duplicates overwrite so - // the map holds the newest id per version_number. - @Suppress("UNCHECKED_CAST") - val versions = groovy.json.JsonSlurper().parseText(response.body()) as? List> - ?: return emptyMap() - return versions.mapNotNull { v -> - val id = v["id"] as? String - val num = v["version_number"] as? String - if (id != null && num != null) num to id else null - }.toMap() + return parseModrinthVersionIds(response.body()) } private fun uploadVersion( diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseModel.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseModel.kt index b00c438f5..192f8a0bf 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseModel.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseModel.kt @@ -58,16 +58,66 @@ internal fun bumpGradleVersion(gradlePropertiesText: String, version: SemanticVe return regex.replaceFirst(gradlePropertiesText, "version=$version") } -/** URL of the POM smoke-tested after a publish, per [MagicUtilsPublishingSpec]. */ -internal fun MagicUtilsPublishingSpec.smokeArtifactUrl(version: SemanticVersion): String { +/** + * URL of the POM smoke-tested after a publish, per [MagicUtilsPublishingSpec]. + * + * [versionCoordinate] is the smoke artifact's ACTUAL published coordinate: + * `X.Y.Z` for a plain library module (which now ships bare) or `X.Y.Z+java` + * for a bundle. The caller applies the module-vs-bundle rule + * (magicUtilsPublishedModuleVersion) before calling, so this points at a POM that + * is really published and the smoke poll does not 404 forever. The `+` (if any) + * is percent-encoded for the HTTP request. + */ +internal fun MagicUtilsPublishingSpec.smokeArtifactUrl(versionCoordinate: String): String { val groupPath = group.replace('.', '/') - return "$repoUrl/$groupPath/$smokeArtifact/$version/$smokeArtifact-$version.pom" + val encoded = versionCoordinate.replace("+", "%2B") + return "$repoUrl/$groupPath/$smokeArtifact/$encoded/$smokeArtifact-$encoded.pom" } +/** + * Parse a Modrinth `GET /project/{id}/version` response body into a map of + * `version_number` -> version `id`. A regex can't pair id<->version_number + * because nested file/dependency objects also carry `"id"`, so this does a + * proper JSON parse. Later duplicates overwrite, so the map holds the newest id + * per version_number. Kept here (pure) so both the Modrinth publish task and the + * release-consistency check parse the response the same way. + */ +internal fun parseModrinthVersionIds(responseBody: String): Map { + @Suppress("UNCHECKED_CAST") + val versions = groovy.json.JsonSlurper().parseText(responseBody) as? List> + ?: return emptyMap() + return versions.mapNotNull { v -> + val id = v["id"] as? String + val num = v["version_number"] as? String + if (id != null && num != null) num to id else null + }.toMap() +} + +/** Maven artifactId the aggregated Javadoc zip is uploaded under. */ +private const val JAVADOC_ARTIFACT = "magicutils-javadoc" + +/** + * Upload URL for the stable "latest" Javadoc zip on the publish repo. The docs + * site always fetches this path, so every release overwrites it. [group] is the + * publishing group (e.g. `dev.ua.theroer`), [repoUrl] the repo base. + */ +internal fun javadocLatestUrl(repoUrl: String, group: String): String = + "${repoUrl.trimEnd('/')}/${group.replace('.', '/')}/$JAVADOC_ARTIFACT/latest/$JAVADOC_ARTIFACT.zip" + +/** Upload URL for the versioned Javadoc zip copy, kept for reference/rollback. */ +internal fun javadocVersionUrl(repoUrl: String, group: String, version: String): String = + "${repoUrl.trimEnd('/')}/${group.replace('.', '/')}/$JAVADOC_ARTIFACT/$version/$JAVADOC_ARTIFACT.zip" + /** * Validate a requested release version against the current gradle.properties * version and the latest already-released version. Returns nothing; throws * [GradleException] with an actionable message on any violation. + * + * Idempotent by design: a release can fail partway (e.g. Maven publish dies on + * one target) and be re-run. On a re-run the bump already happened + * (`requested == current`) and the tag already exists, which is NOT an error — + * it is exactly a resume. Only a fresh release (a version not yet bumped to) + * must be strictly greater than everything and untagged. */ internal fun validateReleaseVersion( requested: SemanticVersion, @@ -75,6 +125,13 @@ internal fun validateReleaseVersion( latestReleased: SemanticVersion?, existingTags: Set, ) { + // Resume: gradle.properties is already at the requested version. A prior run + // bumped it, so the tag/monotonicity checks below (which assume a not-yet-cut + // release) must not fire — otherwise a re-run after a mid-release failure is + // impossible. + if (requested == current) { + return + } if (requested < current) { throw GradleException("Version $requested must not be lower than gradle.properties $current.") } @@ -85,3 +142,78 @@ internal fun validateReleaseVersion( throw GradleException("Version $requested must be greater than latest release $latestReleased.") } } + +/** Presence of a release version across the sources verifyReleaseConsistency checks. */ +enum class SourceState { PRESENT, ABSENT, SKIPPED } + +/** One line of the consistency report: a source name, its state, and detail. */ +data class ReleaseSourceStatus(val source: String, val state: SourceState, val detail: String) + +/** + * Result of comparing a release version across gradle.properties, the git tag, + * Reposilite Maven, and Modrinth. [consistent] is the strict verdict: all + * required sources (everything except Modrinth, which publishes manually and may + * legitimately lag) must agree, or [verifyReleaseConsistency]'s task fails. + */ +data class ReleaseConsistencyReport( + val version: SemanticVersion, + val statuses: List, + val consistent: Boolean, + val problems: List, +) + +/** + * Compare a release [version] across its four publishing surfaces. Each input is + * the already-gathered fact for that source (I/O stays in the Gradle task; this + * is the pure, testable comparison): + * + * - [gradlePropertiesVersion]: the version in gradle.properties. + * - [tagExists]: whether a `vX.Y.Z` git tag exists. + * - [mavenPublished]: whether the java-suffixed POM is reachable on Reposilite. + * - [modrinthPublished]: whether Modrinth has the version; null = check skipped + * (no token / offline), which is a warning, never a strict failure. + * + * Modrinth is advisory because it is published by a separate manual command; the + * other three must agree for the release to be considered consistent. + */ +fun evaluateReleaseConsistency( + version: SemanticVersion, + gradlePropertiesVersion: SemanticVersion, + tagExists: Boolean, + mavenPublished: Boolean, + modrinthPublished: Boolean?, +): ReleaseConsistencyReport { + val statuses = mutableListOf() + val problems = mutableListOf() + + val gradleMatches = gradlePropertiesVersion == version + statuses += ReleaseSourceStatus( + "gradle.properties", + if (gradleMatches) SourceState.PRESENT else SourceState.ABSENT, + if (gradleMatches) "version=$version" else "version=$gradlePropertiesVersion (expected $version)", + ) + if (!gradleMatches) problems += "gradle.properties is at $gradlePropertiesVersion, not $version." + + statuses += ReleaseSourceStatus( + "git tag", + if (tagExists) SourceState.PRESENT else SourceState.ABSENT, + if (tagExists) "v$version present" else "v$version missing", + ) + if (!tagExists) problems += "Git tag v$version does not exist." + + statuses += ReleaseSourceStatus( + "Maven (Reposilite)", + if (mavenPublished) SourceState.PRESENT else SourceState.ABSENT, + if (mavenPublished) "POM published" else "POM not found", + ) + if (!mavenPublished) problems += "Maven POM for $version is not published on Reposilite." + + statuses += when (modrinthPublished) { + true -> ReleaseSourceStatus("Modrinth", SourceState.PRESENT, "version $version published") + false -> ReleaseSourceStatus("Modrinth", SourceState.ABSENT, "version $version not published (publish manually with publishToModrinth)") + null -> ReleaseSourceStatus("Modrinth", SourceState.SKIPPED, "not checked (no MODRINTH_TOKEN or unreachable)") + } + + // Strict verdict excludes Modrinth: it lags legitimately until the manual publish. + return ReleaseConsistencyReport(version, statuses, problems.isEmpty(), problems) +} diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt new file mode 100644 index 000000000..fe7590196 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt @@ -0,0 +1,275 @@ +package dev.ua.theroer.magicutils.build.release + +import dev.ua.theroer.magicutils.build.matrix.MagicUtilsFanoutInvocation +import dev.ua.theroer.magicutils.build.matrix.MagicUtilsMatrixDefinition +import dev.ua.theroer.magicutils.build.matrix.availablePlatformsFor +import dev.ua.theroer.magicutils.build.matrix.magicUtilsGradleWrapperName +import dev.ua.theroer.magicutils.build.matrix.publishUnits +import dev.ua.theroer.magicutils.build.matrix.registerMagicUtilsFanout +import dev.ua.theroer.magicutils.build.publish.MagicUtilsPublishingSpec +import dev.ua.theroer.magicutils.build.support.findMagicUtilsPublishSecret +import dev.ua.theroer.magicutils.build.target.loadAllTargetNames +import dev.ua.theroer.magicutils.build.target.resolveMagicUtilsTargetSpec + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.Project +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Exec +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.TaskAction +import java.io.File +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.file.Path +import java.time.Duration +import java.util.Base64 + +private const val RELEASE_GROUP = "release" + +/** + * Registers the local Maven release fan-out: `releaseMavenAll` publishes the full + * module matrix into the configured Maven repo, one Gradle invocation per publish + * unit (one representative target per Java level, from [publishUnits]), plus a + * separate `publishBuildLogic` for the independently-versioned plugins. This is + * the local, actions-free equivalent of the former publish-maven.yml. + * + * The publish repo URL comes from the publishing spec; credentials are read by + * the module publish plugin from `PUBLISH_USER`/`PUBLISH_TOKEN` (env) or + * `-Ppublish_user/password`. `-Prelease.dryRun=true` prints every command instead + * of running it, so a release can be previewed without publishing anything. + */ +internal fun registerReleaseMavenTasks( + project: Project, + publishingSpec: MagicUtilsPublishingSpec, + definition: MagicUtilsMatrixDefinition, + targetsFile: File, +) { + val dryRun = project.hasProperty("release.dryRun") + val repoUrl = publishingSpec.repoUrl + val units = resolvePublishUnits(definition, targetsFile) + // Each child runs with an isolated Gradle home and cannot read the caller's + // ~/.gradle/gradle.properties, so the publish creds the parent resolved are + // forwarded as env (the child's publish helper reads PUBLISH_USER/PUBLISH_TOKEN). + val publishEnv = magicUtilsPublishEnv(project) + + val perUnitTasks = registerMagicUtilsFanout( + project = project, + taskPrefix = "releaseMaven", + taskGroup = RELEASE_GROUP, + invocations = units.map { unit -> + MagicUtilsFanoutInvocation( + target = unit.target, + // clean + --rerun-tasks so each fresh target invocation republishes + // from scratch. skip_existing keeps it resumable: a coordinate a + // prior (failed) run already uploaded is skipped instead of 409ing + // against the immutable releases repo. + args = buildList { + add("clean") + unit.publishTasks.forEach { add(it) } + add("--rerun-tasks") + add("-Ppublish_repo=$repoUrl") + add("-Pskip_shadow_publish=true") + add("-Pskip_existing") + }, + env = publishEnv, + ) + }, + childHomeSubdir = "release-maven-gradle-home", + dryRun = dryRun, + describe = { "Publish ${it.target} to $repoUrl (${it.args.filterNot { a -> a.startsWith("-") || a == "clean" }.joinToString(" ")})." }, + ) + + // build-logic is a single, target-independent publish (no fan-out), mirroring + // publish-maven.yml's publish-plugins job. + val buildLogicTask = if (dryRun) { + project.tasks.register("publishBuildLogic") { task -> + task.group = RELEASE_GROUP + task.description = "[dry-run] Publish the build-logic plugins to $repoUrl." + task.doLast { + task.logger.lifecycle("[dry-run] ./gradlew -p build-logic publish -Ppublish_repo=$repoUrl") + } + } + } else { + project.tasks.register("publishBuildLogic", Exec::class.java) { task -> + task.group = RELEASE_GROUP + task.description = "Publish the build-logic plugins to $repoUrl." + task.workingDir = project.rootProject.projectDir + task.commandLine(magicUtilsGradleWrapperName(), "-p", "build-logic", "publish", "-Ppublish_repo=$repoUrl", "-Pskip_existing") + publishEnv.forEach { (key, value) -> task.environment(key, value) } + } + } + + project.tasks.register("releaseMavenAll") { task -> + task.group = RELEASE_GROUP + task.description = "Publish the full module matrix + build-logic to $repoUrl " + + "(one invocation per Java level; -Prelease.dryRun to preview)." + task.dependsOn(perUnitTasks) + task.dependsOn(buildLogicTask) + } +} + +/** + * Publish secrets the parent resolved (property-or-env), as an env map to forward + * to child fan-out invocations. The children run with an isolated + * `--gradle-user-home` and cannot read ~/.gradle/gradle.properties, so without + * this their publish would 401. Empty entries are omitted so we never set a blank + * env var. Passed as env (not `-P`) so secrets never appear in `ps`. + */ +private fun magicUtilsPublishEnv(project: Project): Map = buildMap { + project.findMagicUtilsPublishSecret("publish_user", "PUBLISH_USER")?.let { put("PUBLISH_USER", it) } + project.findMagicUtilsPublishSecret("publish_password", "PUBLISH_TOKEN")?.let { put("PUBLISH_TOKEN", it) } +} + +/** + * The publish units (one representative target per Java level, with the publish + * tasks it runs). Shared by the Maven and Modrinth release fan-outs and matching + * printPublishMatrix, so all three agree on which targets represent which Java + * level. The `+java` coordinate is byte-identical across targets of a level, + * so one representative per level is enough. + */ +internal fun resolvePublishUnits(definition: MagicUtilsMatrixDefinition, targetsFile: File) = + definition.publishUnits(loadAllTargetNames(targetsFile)) { target -> + resolveMagicUtilsTargetSpec( + targetsFile = targetsFile, + defaultTarget = definition.defaultTarget, + explicitTarget = target, + ).java + } + +/** + * Registers the local Modrinth release: `releaseModrinth` builds the platform + * bundle jars for every Java level (one representative target per level, fanned + * out) and then runs the existing `publishToModrinth` (matrix-driven, idempotent, + * `MODRINTH_TOKEN`). The per-level bundle jars carry the `+java` suffix in + * their file names, so they accumulate side by side in each `-bundle/ + * build/libs` and publishToModrinth finds all of them. Actions-free equivalent of + * a Modrinth publish job. + */ +internal fun registerReleaseModrinthTask( + project: Project, + definition: MagicUtilsMatrixDefinition, + targetsFile: File, +) { + val dryRun = project.hasProperty("release.dryRun") + val units = resolvePublishUnits(definition, targetsFile) + + val bundleTasks = registerMagicUtilsFanout( + project = project, + taskPrefix = "releaseModrinthBundles", + taskGroup = RELEASE_GROUP, + invocations = units.map { unit -> + // Build only the platforms this representative target actually supports + // (e.g. mc1201/1.20.x has no neoforge) — a fixed workspace scenario would + // fail with "target does not support platforms: neoforge". Each produces + // its +java bundle jar. + val platforms = definition.availablePlatformsFor(unit.target).sorted().joinToString(",") + MagicUtilsFanoutInvocation(unit.target, listOf("build", "-PincludePlatforms=$platforms")) + }, + childHomeSubdir = "release-modrinth-gradle-home", + dryRun = dryRun, + describe = { "Build platform bundles for ${it.target} (Modrinth artifacts)." }, + ) + + project.tasks.register("releaseModrinth") { task -> + task.group = RELEASE_GROUP + task.description = "Build the bundles for every Java level and publish them to Modrinth " + + "(-Prelease.dryRun / -PmodrinthDryRun to preview)." + task.dependsOn(bundleTasks) + // publishToModrinth is matrix-driven and idempotent; run it after the jars + // for all Java levels exist. + task.dependsOn("publishToModrinth") + } + + // Order publishToModrinth after every bundle build. Configured outside the + // register block above: Gradle forbids configuring another task from within a + // task-creation action. + project.tasks.named("publishToModrinth").configure { publish -> + bundleTasks.forEach { publish.mustRunAfter(it) } + } +} + +/** + * Registers `releaseJavadoc`: generate the aggregated Javadoc zip (via the + * existing `aggregatedJavadocZip`) and upload it to the publish repo at the + * stable `latest` path plus a versioned copy. Actions-free equivalent of + * publish-javadoc.yml. `-Prelease.dryRun=true` prints the target URLs instead of + * uploading. + */ +internal fun registerReleaseJavadocTask( + project: Project, + publishingSpec: MagicUtilsPublishingSpec, +) { + val dryRun = project.hasProperty("release.dryRun") + project.tasks.register("releaseJavadoc", UploadJavadocTask::class.java) { task -> + task.group = RELEASE_GROUP + task.description = "Generate and upload the aggregated Javadoc to ${publishingSpec.repoUrl} " + + "(-Prelease.dryRun to preview)." + task.dependsOn("aggregatedJavadocZip") + task.zipPath.set(project.layout.buildDirectory.file("docs/$JAVADOC_ZIP_NAME").map { it.asFile.toPath() }) + task.latestUrl.set(javadocLatestUrl(publishingSpec.repoUrl, publishingSpec.group)) + task.versionUrl.set(project.provider { + // The raw -Pversion, not project.version: the target plugin overwrites + // project.version with the +java suffix, which is not a release version. + project.gradle.startParameter.projectProperties["version"]?.trim()?.takeIf { it.isNotEmpty() } + ?.let { javadocVersionUrl(publishingSpec.repoUrl, publishingSpec.group, it) } + }) + task.username.set(project.provider { project.findMagicUtilsPublishSecret("publish_user", "PUBLISH_USER") }) + task.password.set(project.provider { project.findMagicUtilsPublishSecret("publish_password", "PUBLISH_TOKEN") }) + task.dryRun.set(dryRun) + task.notCompatibleWithConfigurationCache("Performs a network upload.") + } +} + +/** The aggregated Javadoc zip file name produced by aggregatedJavadocZip. */ +private const val JAVADOC_ZIP_NAME = "magicutils-javadoc.zip" + +abstract class UploadJavadocTask : DefaultTask() { + @get:Input abstract val zipPath: Property + @get:Input abstract val latestUrl: Property + @get:[Input Optional] abstract val versionUrl: Property + @get:[Input Optional] abstract val username: Property + @get:[Input Optional] abstract val password: Property + @get:Input abstract val dryRun: Property + + @TaskAction + fun run() { + val zip = zipPath.get().toFile() + val targets = listOfNotNull(latestUrl.get(), versionUrl.orNull) + + if (dryRun.get()) { + logger.lifecycle("[dry-run] would upload ${zip.name} to:") + targets.forEach { logger.lifecycle(" $it") } + logger.lifecycle(" credentials: ${if (username.orNull != null && password.orNull != null) "present" else "absent"}") + return + } + + if (!zip.isFile) throw GradleException("Javadoc zip not found at ${zip.absolutePath} (did aggregatedJavadocZip run?).") + val client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(15)).build() + val authHeader = username.orNull?.let { user -> + password.orNull?.let { pass -> + "Basic " + Base64.getEncoder().encodeToString("$user:$pass".toByteArray()) + } + } + for (url in targets) { + val builder = HttpRequest.newBuilder(URI.create(url)) + .timeout(Duration.ofMinutes(5)) + .PUT(HttpRequest.BodyPublishers.ofFile(zip.toPath())) + authHeader?.let { builder.header("Authorization", it) } + val response = client.send(builder.build(), HttpResponse.BodyHandlers.ofString()) + when { + response.statusCode() in 200..299 -> logger.lifecycle("Uploaded ${zip.name} -> $url") + // The immutable releases repo rejects overwriting an existing path + // with 409. The stable `latest` path already holds a prior release's + // zip, and a re-run re-uploads the versioned path; neither is fatal — + // the docs site keeps serving what is there. Warn and carry on so a + // resumed release does not fail on javadoc that is effectively done. + response.statusCode() == 409 -> logger.warn("Javadoc path already published (HTTP 409, skipping overwrite): $url") + else -> throw GradleException("Javadoc upload to $url failed (HTTP ${response.statusCode()}): ${response.body()}") + } + } + } +} diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseSpec.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseSpec.kt new file mode 100644 index 000000000..b919822bf --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseSpec.kt @@ -0,0 +1,129 @@ +package dev.ua.theroer.magicutils.build.release + +/** + * Which steps the `release` orchestrator runs, and how. Every step is optional so + * a consumer builds exactly the release pipeline they want (a plugin with no + * Modrinth page turns `publishModrinth` off; a fork that tags out-of-band turns + * `tag` off). Defaults describe a full library release: validate, bump, tag + * locally (no push), publish Maven + Modrinth + Javadoc, verify afterwards. + * + * Declared in `magicMatrix { release { ... } }` and overridable per invocation + * with `-Prelease.=true|false` (see [applyReleaseOverrides]). Pure data so + * the plan is unit-testable without a Gradle runtime. + */ +data class MagicUtilsReleaseSpec( + /** Validate the requested version against gradle.properties + tags (releasePreflight). */ + val validateVersion: Boolean = true, + /** Run the build/tests gate before publishing (heavy; disable with -Prelease.validate=false). */ + val validateBuild: Boolean = true, + /** Bump gradle.properties to the requested version and commit it. */ + val bump: Boolean = true, + /** Create the vX.Y.Z git tag. */ + val tag: Boolean = true, + /** Push the tag to origin (off by default: local tag only until you opt in). */ + val push: Boolean = false, + /** Publish the full module matrix + build-logic to the Maven repo. */ + val publishMaven: Boolean = true, + /** Build the bundles and publish them to Modrinth. */ + val publishModrinth: Boolean = true, + /** Generate and upload the aggregated Javadoc. */ + val publishJavadoc: Boolean = true, + /** Verify version consistency across all sources after publishing. */ + val verify: Boolean = true, + /** + * Branch a release is allowed to run from (checked in releasePreflight). + * Publishing off a feature branch means the tag and the immutable Maven + * coordinate point at a commit that may never land on the release branch as + * written, so the release history diverges from it. Defaults to `main`; set + * to `null` to disable the gate (a fork with a different default branch, or + * one that intentionally releases from anywhere). Overridable per invocation + * with `-Prelease.branch=`, and bypassable once with + * `-Prelease.allowAnyBranch=true` for a genuine off-branch hotfix. + */ + val releaseBranch: String? = "main", +) + +/** + * Validates that a release may run from [currentBranch]. + * + * Returns null when allowed, or a human-readable reason when blocked. Pure so the + * gate is unit-testable without a git checkout. The gate is a no-op when + * [requiredBranch] is null (disabled), when [allowAnyBranch] is set (explicit + * bypass), or when the current branch cannot be determined (detached HEAD / no + * git) — in those two last cases the caller logs a warning rather than guessing. + */ +fun releaseBranchViolation( + requiredBranch: String?, + currentBranch: String?, + allowAnyBranch: Boolean, +): String? { + if (requiredBranch == null || allowAnyBranch) return null + if (currentBranch.isNullOrBlank()) return null + if (currentBranch == requiredBranch) return null + return "Release must run from branch '$requiredBranch' but the current branch is " + + "'$currentBranch'. Merge to '$requiredBranch' first, or pass " + + "-Prelease.allowAnyBranch=true to override (e.g. an off-branch hotfix)." +} + +/** A step of the release, in run order, with whether the spec enabled it. */ +data class MagicUtilsReleaseStep(val name: String, val enabled: Boolean) + +/** + * The ordered release plan for [spec]. Order is fixed and meaningful: validate + * and build gate before any mutation; bump/tag before publishing so the tag + * exists when Maven/Modrinth go out; verify last. Callers wire the aggregate + * task's dependsOn/mustRunAfter from this list, and print it so a dry run shows + * exactly what a real run would do. + */ +fun releasePlan(spec: MagicUtilsReleaseSpec): List = listOf( + MagicUtilsReleaseStep("releasePreflight", spec.validateVersion), + MagicUtilsReleaseStep("releaseValidateBuild", spec.validateBuild), + MagicUtilsReleaseStep("bumpVersion", spec.bump), + MagicUtilsReleaseStep("releaseTag", spec.tag), + MagicUtilsReleaseStep("releaseMavenAll", spec.publishMaven), + MagicUtilsReleaseStep("releaseModrinth", spec.publishModrinth), + MagicUtilsReleaseStep("releaseJavadoc", spec.publishJavadoc), + MagicUtilsReleaseStep("verifyReleaseConsistency", spec.verify), +) + +/** + * Applies `-Prelease.=true|false` overrides on top of the DSL [spec]. A + * property whose value isn't a clean boolean is ignored (the DSL default stands) + * rather than failing the build, so a typo never blocks a release. [properties] + * is the raw Gradle project-properties map (property name -> string value). + */ +fun applyReleaseOverrides( + spec: MagicUtilsReleaseSpec, + properties: Map, +): MagicUtilsReleaseSpec { + fun override(key: String, current: Boolean): Boolean = + properties["release.$key"]?.trim()?.lowercase()?.let { + when (it) { + "true" -> true + "false" -> false + else -> current // ignore garbage, keep the DSL value + } + } ?: current + + // -Prelease.branch= overrides the release branch; passing it empty + // (`-Prelease.branch=`) disables the gate (parity with a null DSL default). + // Absent property keeps the DSL value; present-but-empty means null. + val branch = if (properties.containsKey("release.branch")) { + properties["release.branch"]?.trim()?.ifEmpty { null } + } else { + spec.releaseBranch + } + + return spec.copy( + validateVersion = override("validateVersion", spec.validateVersion), + validateBuild = override("validate", spec.validateBuild), + bump = override("bump", spec.bump), + tag = override("tag", spec.tag), + push = override("push", spec.push), + publishMaven = override("maven", spec.publishMaven), + publishModrinth = override("modrinth", spec.publishModrinth), + publishJavadoc = override("javadoc", spec.publishJavadoc), + verify = override("verify", spec.verify), + releaseBranch = branch, + ) +} diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt index 7f9d6ab0c..10c99d3b0 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt @@ -1,11 +1,14 @@ package dev.ua.theroer.magicutils.build.release import dev.ua.theroer.magicutils.build.publish.* +import dev.ua.theroer.magicutils.build.support.findMagicUtilsModrinthToken +import dev.ua.theroer.magicutils.build.target.magicUtilsPublishedModuleVersion import org.gradle.api.DefaultTask import org.gradle.api.Project import org.gradle.api.provider.Property import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal import org.gradle.api.tasks.Optional import org.gradle.api.tasks.TaskAction import org.gradle.process.ExecOperations @@ -33,7 +36,13 @@ import javax.inject.Inject private const val RELEASE_GROUP = "release" -internal fun registerReleaseTasks(project: Project, publishingSpec: MagicUtilsPublishingSpec) { +internal fun registerReleaseTasks( + project: Project, + publishingSpec: MagicUtilsPublishingSpec, + defaultTargetJava: Int, + modrinthProjectId: String?, + releaseSpec: MagicUtilsReleaseSpec, +) { val gradlePropertiesFile = project.rootProject.file("gradle.properties") // Read the raw -Pversion from the start parameter, not project.version: the // target plugin overwrites project.version with the + suffix @@ -43,11 +52,18 @@ internal fun registerReleaseTasks(project: Project, publishingSpec: MagicUtilsPu ?: throw org.gradle.api.GradleException("Pass the release version via -Pversion=X.Y.Z.") } + // The effective spec: DSL defaults with -Prelease.=... overrides applied. + // Computed up front so the preflight branch gate can read the release branch. + val effectiveSpec = applyReleaseOverrides(releaseSpec, stringGradleProperties(project)) + val allowAnyBranch = project.hasProperty("release.allowAnyBranch") + project.tasks.register("releasePreflight", ReleasePreflightTask::class.java) { task -> task.group = RELEASE_GROUP task.description = "Validate a release version against gradle.properties and existing tags (no changes)." task.requestedVersion.set(versionProvider) task.gradlePropertiesText.set(project.provider { gradlePropertiesFile.readText() }) + effectiveSpec.releaseBranch?.let { task.releaseBranch.set(it) } + task.allowAnyBranch.set(allowAnyBranch) task.notCompatibleWithConfigurationCache("Queries git/gh for existing tags.") } @@ -70,20 +86,86 @@ internal fun registerReleaseTasks(project: Project, publishingSpec: MagicUtilsPu project.tasks.register("smokeTest", SmokeTestTask::class.java) { task -> task.group = RELEASE_GROUP task.description = "Poll the published POM for -Pversion until it appears (or times out)." - task.artifactUrl.set(project.provider { publishingSpec.smokeArtifactUrl(SemanticVersion.parse(versionProvider.get())) }) + // Poll the smoke artifact's actual published coordinate. It is a plain + // library module (magicutils-core), which now ships bare (`X.Y.Z`); a + // bundle would instead carry `+java`. magicUtilsPublishedModuleVersion + // applies that rule so the poll targets a POM that is really published. + task.artifactUrl.set(project.provider { + val base = SemanticVersion.parse(versionProvider.get()).toString() + val coordinate = magicUtilsPublishedModuleVersion(publishingSpec.smokeArtifact, base, defaultTargetJava) + publishingSpec.smokeArtifactUrl(coordinate) + }) task.notCompatibleWithConfigurationCache("Performs network polling.") } + project.tasks.register("verifyReleaseConsistency", VerifyReleaseConsistencyTask::class.java) { task -> + task.group = RELEASE_GROUP + task.description = "Verify -Pversion is consistent across gradle.properties, git tag, Maven and Modrinth " + + "(strict fail unless -Preport)." + task.requestedVersion.set(versionProvider) + task.gradlePropertiesText.set(project.provider { gradlePropertiesFile.readText() }) + // Same coordinate the smoke poll uses (the one actually published for the + // smoke artifact), so the two checks agree on what "Maven has it" means. + task.mavenPomUrl.set(project.provider { + val base = SemanticVersion.parse(versionProvider.get()).toString() + val coordinate = magicUtilsPublishedModuleVersion(publishingSpec.smokeArtifact, base, defaultTargetJava) + publishingSpec.smokeArtifactUrl(coordinate) + }) + task.modrinthProjectId.set(project.provider { modrinthProjectId }) + task.modrinthToken.set(project.provider { project.findMagicUtilsModrinthToken() }) + task.reportOnly.set(project.provider { project.hasProperty("report") }) + task.notCompatibleWithConfigurationCache("Queries git and the network.") + } + + project.tasks.register("releaseTag", ReleaseTagTask::class.java) { task -> + task.group = RELEASE_GROUP + task.description = "Create the vX.Y.Z git tag locally; push to origin only when push is enabled." + task.requestedVersion.set(versionProvider) + // Push follows the resolved spec (DSL push, overridable with -Prelease.push). + task.push.set(effectiveSpec.push) + task.notCompatibleWithConfigurationCache("Runs git tag/push.") + } + + // Build/tests gate before publishing: the non-Fabric scenario build (Fabric is + // covered by the matrix ci). Left as a real Exec so --dry-run skips it. + project.tasks.register("releaseValidateBuild", org.gradle.api.tasks.Exec::class.java) { task -> + task.group = RELEASE_GROUP + task.description = "Build the non-Fabric platforms as a pre-publish gate." + task.workingDir = project.rootProject.projectDir + task.commandLine( + dev.ua.theroer.magicutils.build.matrix.magicUtilsGradleWrapperName(), + "buildScenario", "-PincludePlatforms=bukkit,bungee,velocity,neoforge", + ) + } + + // Configurable orchestrator: only the steps the spec enables run, in the fixed + // plan order, each after the previous. dispatchRelease is intentionally NOT in + // the default release anymore (the release is now fully local); it stays as a + // standalone task for anyone who still wants to trigger the CI workflow. + val enabledSteps = releasePlan(effectiveSpec).filter { it.enabled } project.tasks.register("release") { task -> task.group = RELEASE_GROUP - task.description = "Full client release: preflight -> bump -> dispatch (-Pversion=X.Y.Z)." - task.dependsOn("releasePreflight", "bumpVersion", "dispatchRelease") - // Enforce ordering; Gradle runs dependencies in declared order when chained. - project.tasks.named("bumpVersion").configure { it.mustRunAfter("releasePreflight") } - project.tasks.named("dispatchRelease").configure { it.mustRunAfter("bumpVersion") } + task.description = "Local release: runs the steps enabled in release { } (-Pversion=X.Y.Z, -Prelease.)." + enabledSteps.forEach { task.dependsOn(it.name) } + task.doFirst { + task.logger.lifecycle("Release plan (${enabledSteps.size} step(s)): ${enabledSteps.joinToString(" -> ") { it.name }}") + } + } + // Chain the enabled steps in plan order so publishing never races the tag/bump. + // registerReleaseTasks runs after every step task is registered, so named() here + // resolves. Configured outside the register block: Gradle forbids configuring + // another task from within a task-creation action. + enabledSteps.map { it.name }.zipWithNext().forEach { (before, after) -> + project.tasks.named(after).configure { it.mustRunAfter(before) } } } +/** Gradle project properties as a plain String map (for applyReleaseOverrides). */ +private fun stringGradleProperties(project: Project): Map = + project.properties.entries + .mapNotNull { (key, value) -> (value as? String)?.let { key to it } } + .toMap() + /** Runs a command, returning trimmed stdout; throws on non-zero exit. */ private fun ExecOperations.capture(vararg args: String): String { val out = ByteArrayOutputStream() @@ -95,24 +177,72 @@ private fun ExecOperations.capture(vararg args: String): String { return out.toString().trim() } +abstract class ReleaseTagTask @Inject constructor( + private val execOps: ExecOperations, +) : DefaultTask() { + @get:Input abstract val requestedVersion: Property + @get:Input abstract val push: Property + + @TaskAction + fun run() { + val requested = SemanticVersion.parse(requestedVersion.get()) + val tag = "v$requested" + + val localTags = runCatching { execOps.capture("git", "tag", "--list", tag) } + .getOrDefault("") + .lineSequence().map(String::trim).filter(String::isNotEmpty).toSet() + if (tag in localTags) { + logger.lifecycle("Tag $tag already exists locally — not re-tagging.") + } else { + execOps.capture("git", "tag", tag) + logger.lifecycle("Created tag $tag.") + } + + if (push.get()) { + execOps.capture("git", "push", "origin", tag) + logger.lifecycle("Pushed $tag to origin.") + } else { + logger.lifecycle("Not pushing $tag (pass -Prelease.push=true to push to origin).") + } + } +} + abstract class ReleasePreflightTask @Inject constructor( private val execOps: ExecOperations, ) : DefaultTask() { @get:Input abstract val requestedVersion: Property @get:Input abstract val gradlePropertiesText: Property + /** Branch a release must run from; unset (Optional) disables the gate. */ + @get:[Input Optional] abstract val releaseBranch: Property + /** -Prelease.allowAnyBranch bypass. */ + @get:Input abstract val allowAnyBranch: Property @TaskAction fun run() { val requested = SemanticVersion.parse(requestedVersion.get()) val current = readGradleVersion(gradlePropertiesText.get()) + // Branch gate: publishing off the wrong branch pins the tag and the + // immutable Maven coordinate to a commit that may never land on the + // release branch as written. Checked before the version gate so an + // off-branch attempt fails fast without touching tags. + val currentBranch = runCatching { execOps.capture("git", "rev-parse", "--abbrev-ref", "HEAD").trim() } + .getOrNull() + ?.takeIf { it.isNotEmpty() && it != "HEAD" } + releaseBranchViolation(releaseBranch.orNull, currentBranch, allowAnyBranch.get()) + ?.let { throw org.gradle.api.GradleException(it) } + if (currentBranch == null && releaseBranch.orNull != null && !allowAnyBranch.get()) { + logger.warn("Release branch gate: could not determine current branch (detached HEAD?); skipping the check.") + } + val localTags = runCatching { execOps.capture("git", "tag", "--list", "v*") } .getOrDefault("") .lineSequence().map(String::trim).filter(String::isNotEmpty).toSet() val released = localTags.mapNotNull(SemanticVersion::fromTag).maxOrNull() validateReleaseVersion(requested, current, released, localTags) - logger.lifecycle("Preflight OK: $requested (current $current, latest released ${released ?: "none"}).") + val branchNote = releaseBranch.orNull?.let { " on branch '$it'" } ?: "" + logger.lifecycle("Preflight OK: $requested$branchNote (current $current, latest released ${released ?: "none"}).") } } @@ -186,3 +316,91 @@ abstract class SmokeTestTask : DefaultTask() { ) } } + +abstract class VerifyReleaseConsistencyTask @Inject constructor( + private val execOps: ExecOperations, +) : DefaultTask() { + @get:Input abstract val requestedVersion: Property + @get:Input abstract val gradlePropertiesText: Property + @get:Input abstract val mavenPomUrl: Property + @get:[Input Optional] abstract val modrinthProjectId: Property + // @Internal: a secret must not enter the up-to-date hash. Optional: a public + // project's version list needs no token. + @get:Internal abstract val modrinthToken: Property + @get:Input abstract val reportOnly: Property + + @TaskAction + fun run() { + val requested = SemanticVersion.parse(requestedVersion.get()) + val gradleVersion = readGradleVersion(gradlePropertiesText.get()) + + val tags = runCatching { execOps.capture("git", "tag", "--list", "v*") } + .getOrDefault("") + .lineSequence().map(String::trim).filter(String::isNotEmpty).toSet() + val tagExists = "v$requested" in tags + + val client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(15)).build() + val mavenPublished = headOk(client, mavenPomUrl.get()) + val modrinthPublished = modrinthVersionPresent(client, modrinthProjectId.orNull, modrinthToken.orNull, requested.toString()) + + val report = evaluateReleaseConsistency( + version = requested, + gradlePropertiesVersion = gradleVersion, + tagExists = tagExists, + mavenPublished = mavenPublished, + modrinthPublished = modrinthPublished, + ) + + logger.lifecycle("Release consistency for $requested:") + for (status in report.statuses) { + val mark = when (status.state) { + SourceState.PRESENT -> "OK " + SourceState.ABSENT -> "FAIL" + SourceState.SKIPPED -> "SKIP" + } + logger.lifecycle(" [$mark] ${status.source}: ${status.detail}") + } + + if (report.consistent) { + logger.lifecycle("All required sources agree.") + return + } + val summary = "Release $requested is inconsistent:\n - " + report.problems.joinToString("\n - ") + if (reportOnly.get()) { + logger.warn(summary) + } else { + throw org.gradle.api.GradleException("$summary\n(Run with -Preport to print without failing.)") + } + } + + /** True if a HEAD on [url] returns 200 (artifact present), false otherwise. */ + private fun headOk(client: HttpClient, url: String): Boolean { + val request = HttpRequest.newBuilder(URI.create(url)) + .method("HEAD", HttpRequest.BodyPublishers.noBody()) + .timeout(Duration.ofSeconds(15)) + .build() + return runCatching { + client.send(request, HttpResponse.BodyHandlers.discarding()).statusCode() == 200 + }.getOrDefault(false) + } + + /** + * Whether Modrinth already has [versionNumber] for [projectId]. Returns null + * (check skipped, a warning not a failure) when no project is configured or + * the request fails — Modrinth is published manually and may legitimately lag. + * A public project's version list needs no token; MODRINTH_TOKEN is sent when + * present so private/draft projects also resolve. + */ + private fun modrinthVersionPresent(client: HttpClient, projectId: String?, token: String?, versionNumber: String): Boolean? { + if (projectId.isNullOrBlank()) return null + val builder = HttpRequest.newBuilder( + URI.create("https://api.modrinth.com/v3/project/$projectId/version?include_changelog=false") + ).timeout(Duration.ofSeconds(20)).GET() + token?.takeIf { it.isNotBlank() }?.let { builder.header("Authorization", it) } + return runCatching { + val response = client.send(builder.build(), HttpResponse.BodyHandlers.ofString()) + if (response.statusCode() !in 200..299) return null + versionNumber in parseModrinthVersionIds(response.body()).keys + }.getOrNull() + } +} diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsProjectExtensions.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsProjectExtensions.kt index 6ea8016b0..3304cec61 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsProjectExtensions.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsProjectExtensions.kt @@ -3,7 +3,10 @@ package dev.ua.theroer.magicutils.build.support import dev.ua.theroer.magicutils.build.module.* import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.api.publish.PublishingExtension +import org.gradle.api.publish.maven.MavenPublication +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar internal fun Project.magicUtilsModuleName(projectName: String = name): String { val namingSpec = extensions.extraProperties @@ -44,8 +47,97 @@ internal fun Project.magicUtilsPublishRepository(publishing: PublishingExtension } } } + + // -Pskip_existing makes publishing idempotent so a resumed release does not + // 409 on modules a prior run already uploaded to the immutable repo. Each + // PublishToMavenRepository task HEADs its POM and skips if already present. + if (hasProperty("skip_existing")) { + val repoBase = repoUrl.trimEnd('/') + tasks.withType(org.gradle.api.publish.maven.tasks.PublishToMavenRepository::class.java).configureEach { task -> + val pub = task.publication as org.gradle.api.publish.maven.MavenPublication + task.onlyIf { !magicUtilsPomAlreadyPublished(repoBase, pub.groupId, pub.artifactId, pub.version, task.logger) } + } + } } -private fun Project.findMagicUtilsPublishSecret(propertyName: String, envName: String): String? = +/** HEAD the POM coordinate; true if the repo already has it (200). */ +internal fun magicUtilsPomAlreadyPublished( + repoBase: String, + groupId: String, + artifactId: String, + version: String, + logger: org.gradle.api.logging.Logger, +): Boolean { + val path = "${groupId.replace('.', '/')}/$artifactId/$version/$artifactId-$version.pom" + val url = java.net.URI.create("$repoBase/$path").toURL() + val exists = runCatching { + (url.openConnection() as java.net.HttpURLConnection).run { + requestMethod = "HEAD"; connectTimeout = 10000; readTimeout = 10000 + val code = responseCode; disconnect(); code == 200 + } + }.getOrDefault(false) + if (exists) logger.lifecycle("skip_existing: $artifactId:$version already published, skipping.") + return exists +} + +/** + * A publish secret from a Gradle property or an environment variable, in that + * order. Shared by the module publish repo and the Javadoc upload task so the + * `publish_user`/`PUBLISH_USER` (and password/token) resolution lives once. + */ +internal fun Project.findMagicUtilsPublishSecret(propertyName: String, envName: String): String? = (findProperty(propertyName) as? String)?.trim()?.takeIf(String::isNotEmpty) ?: System.getenv(envName)?.trim()?.takeIf(String::isNotEmpty) + +/** + * The Modrinth API token: the `modrinth_token` Gradle property or the + * `MODRINTH_TOKEN` env var. Same property-or-env resolution as the Maven + * publish secrets, so a consumer configures every secret one way (e.g. all in + * ~/.gradle/gradle.properties). Null when neither is set. + */ +internal fun Project.findMagicUtilsModrinthToken(): String? = + findMagicUtilsPublishSecret("modrinth_token", "MODRINTH_TOKEN") + +/** + * Drops the `` node from the generated POM. Bundle publications + * ship a fat/jar-in-jar artifact whose dependencies are already inside the jar, + * so advertising them as Maven deps would make consumers double-resolve them. + * Shared by every bundle plugin instead of copying the `pom.withXml` block. + */ +internal fun MavenPublication.stripPomDependencies() { + pom.withXml { xml -> + xml.asElement().getElementsByTagName("dependencies").item(0)?.let { node -> + node.parentNode.removeChild(node) + } + } +} + +/** + * Bundles the optional Redis messaging transport (Jedis) into a standalone + * bundle and relocates it under the MagicUtils `libs` namespace, the same way + * [MagicUtilsShadedModulePlugin] relocates Jackson. + * + * MagicUtils bundles are drop-in plugins that provide the messaging runtime for + * the whole network, so shipping Jedis makes the Redis transport work out of the + * box once an operator enables it in `messaging.yml`. The default + * plugin-messaging transport needs no extra dependency, so a network can still + * run without Redis. Called by every bundle plugin so the coordinate and + * relocation prefix live in one place. + * + * @param bundleShadowConfiguration name of the shade configuration the bundle jar draws from + */ +internal fun Project.magicUtilsBundleRedis(bundleShadowConfiguration: String) { + val jedis = extensions + .getByType(VersionCatalogsExtension::class.java) + .named("libs") + .findLibrary("jedis") + .get() + .get() + dependencies.add(bundleShadowConfiguration, jedis) + + tasks.named("shadowJar", ShadowJar::class.java).configure { shadowJarTask -> + shadowJarTask.relocate("redis.clients.jedis", "dev.ua.theroer.magicutils.libs.jedis") + // Jedis pulls in Apache Commons Pool for its connection pool. + shadowJarTask.relocate("org.apache.commons.pool2", "dev.ua.theroer.magicutils.libs.commons.pool2") + } +} diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/MagicUtilsTargetConventions.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/MagicUtilsTargetConventions.kt index 1bd3e6760..1b943ffbf 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/MagicUtilsTargetConventions.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/MagicUtilsTargetConventions.kt @@ -32,18 +32,53 @@ val MagicUtilsTargetExtension.mcClassifier: String get() = "mc${libraryMinecraft.get().substringBeforeLast('.')}" /** - * Published MagicUtils version for [baseVersion] on this target, Fabric-style: - * `+` (e.g. `1.22.0+26.2`). Every target carries the - * suffix so each Minecraft version is its own Maven version — its own module - * metadata, Java level and transitive deps — instead of one version whose - * metadata the targets overwrite. Consumers pass the bare base version - * (`magicutils_version=1.22.0`); the consumer plugins add the suffix from the - * resolved target's *library* Minecraft, so no build script writes it by hand. - * The library Minecraft equals the runtime one unless the target overrides - * `library_minecraft` (see [MagicUtilsTargetSpec]). + * Bundle artifact ids end in `-bundle` (`magicutils-fabric-bundle`, + * `magicutils-bukkit-bundle`, ...). Only the five bundle plugins produce them, + * so this suffix is the reliable discriminator — on both the publish side (the + * project's artifact id) and the consumer side (the requested module name) — + * between a bundle and a plain library module, without either side importing the + * publish-category extension. */ -fun MagicUtilsTargetExtension.publishedVersion(baseVersion: String): String = - "$baseVersion+${libraryMinecraft.get()}" +fun magicUtilsModuleIsBundle(moduleName: String): Boolean = moduleName.endsWith("-bundle") + +/** + * Published MagicUtils coordinate version for [moduleName] built at [javaLevel]. + * + * Two kinds of module, two coordinate shapes: + * - **Bundles** (`*-bundle`) are fat jars whose shaded dependency set genuinely + * differs per Java level / Minecraft branch (the 1.20.x, 1.21.x and 26.x + * bundles are not byte-identical), so they keep the `+java` coordinate + * — one real variant per Java level. + * - **Plain library modules** (core/config/commands/lang, the platform and + * fabric modules) are byte-identical across Java levels once the class-file + * version word is normalized: the per-level diffs are pure javac codegen + * artifacts, not behaviour. Publishing three near-duplicate `+java17/21/25` + * copies was redundant, so they now publish once under the **bare** base + * version. A `+java17`-compiled class loads fine on any JRE >= 17, so a + * consumer on Java 21/25 resolving the bare coordinate runs it unchanged. + * + * Consumers pass the bare base version (`magicutils_version=1.27.1`); this is the + * single place that decides whether the resolved coordinate carries `+java`, + * so the publish side and every consumer plugin agree by construction. + */ +fun magicUtilsPublishedModuleVersion(moduleName: String, baseVersion: String, javaLevel: Int): String = + if (magicUtilsModuleIsBundle(moduleName)) javaSuffixedCoordinate(baseVersion, javaLevel) else baseVersion + +/** + * Published MagicUtils version for a [moduleName] on this target. Thin wrapper + * over [magicUtilsPublishedModuleVersion] that supplies the target's Java level; + * consumer plugins call this so the module-vs-bundle rule lives in one place. + */ +fun MagicUtilsTargetExtension.publishedVersion(moduleName: String, baseVersion: String): String = + magicUtilsPublishedModuleVersion(moduleName, baseVersion, java.get()) + +/** + * Pure formatter for the `+java` published coordinate. The `+java` + * suffix format lives here as one function so no caller (the module-version rule, + * the Modrinth bundle file name, the release smoke URL) re-spells it by hand. + */ +fun javaSuffixedCoordinate(baseVersion: String, javaLevel: Int): String = + "$baseVersion+java$javaLevel" /** Loom plugin id for the target: no-remap on deobfuscated, remapping otherwise. */ val MagicUtilsTargetExtension.loomPluginId: String diff --git a/build-logic/src/test/kotlin/MagicUtilsMatrixModelTest.kt b/build-logic/src/test/kotlin/MagicUtilsMatrixModelTest.kt index 534f8c6ba..580a137c3 100644 --- a/build-logic/src/test/kotlin/MagicUtilsMatrixModelTest.kt +++ b/build-logic/src/test/kotlin/MagicUtilsMatrixModelTest.kt @@ -67,30 +67,51 @@ class MagicUtilsMatrixModelTest { assertEquals(setOf("bukkit", "fabric", "neoforge"), def.availablePlatformsFor("mc12110")) } + // Java level per test target, mirroring writeTargets: one target per level, + // so each is its own level's representative. + private val javaLevels = mapOf("mc1201" to 17, "mc12110" to 21, "mc262" to 25) + private val javaLevelOf: (String) -> Int = { javaLevels.getValue(it) } + + @Test + fun `publishUnits publishes one representative per Java level, no suffix`() { + val units = definition().publishUnits(listOf("mc1201", "mc12110", "mc262"), javaLevelOf) + // Three distinct Java levels (17/21/25) -> three representatives. + assertEquals(listOf("mc1201", "mc12110", "mc262"), units.map { it.target }) + units.forEach { unit -> + assertTrue("publishDefaultMatrix" in unit.publishTasks) + assertFalse(unit.suffix) // +java is intrinsic to the coordinate now + } + } + @Test - fun `publishUnits marks default target without suffix and all-categories`() { - val units = definition().publishUnits(listOf("mc1201", "mc12110", "mc262")) - val default = units.single { it.target == "mc12110" } - assertEquals(listOf("publishDefaultMatrix"), default.publishTasks) - assertFalse(default.suffix) + fun `publishUnits collapses targets sharing a Java level to one representative`() { + // mc12110 and a hypothetical mc12111 both Java 21 -> one unit; the default + // target wins as the representative for its level. + val levels = mapOf("mc1201" to 17, "mc12110" to 21, "mc12111" to 21, "mc262" to 25) + val units = definition().publishUnits( + listOf("mc1201", "mc12111", "mc12110", "mc262"), + ) { levels.getValue(it) } + assertEquals(listOf(17, 21, 25).size, units.size) + // The Java-21 representative is the default target (mc12110), not mc12111. + assertTrue(units.any { it.target == "mc12110" }) + assertFalse(units.any { it.target == "mc12111" }) } @Test fun `publishUnits adds fabric only when platform enabled`() { - val units = definition().publishUnits(listOf("mc1201", "mc262")) + val units = definition().publishUnits(listOf("mc1201", "mc262"), javaLevelOf) val mc1201 = units.single { it.target == "mc1201" } val mc262 = units.single { it.target == "mc262" } assertTrue("publishFabricMatrix" in mc1201.publishTasks) assertFalse("publishFabricMatrix" in mc262.publishTasks) // fabric disabled on mc26 - assertTrue(mc1201.suffix) - assertTrue(mc262.suffix) } @Test fun `toMatrixJson emits valid boolean suffix and joined tasks`() { - val json = definition().publishUnits(listOf("mc12110", "mc1201")).toMatrixJson() + val json = definition().publishUnits(listOf("mc12110", "mc1201"), javaLevelOf).toMatrixJson() assertTrue(json.startsWith("[") && json.endsWith("]")) - assertTrue(json.contains(""""target":"mc12110","tasks":"publishDefaultMatrix","suffix":false""")) - assertTrue(json.contains(""""tasks":"publishCommonMatrix publishFabricMatrix","suffix":true""")) + // Both representatives enable fabric here (fabric is only disabled on mc26). + assertTrue(json.contains(""""target":"mc12110","tasks":"publishDefaultMatrix publishFabricMatrix","suffix":false""")) + assertTrue(json.contains(""""target":"mc1201","tasks":"publishDefaultMatrix publishFabricMatrix","suffix":false""")) } } diff --git a/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt b/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt index a1754e9ae..1fecb739e 100644 --- a/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt +++ b/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt @@ -1,11 +1,17 @@ import org.gradle.api.GradleException import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import dev.ua.theroer.magicutils.build.release.* import dev.ua.theroer.magicutils.build.publish.* +import dev.ua.theroer.magicutils.build.target.javaSuffixedCoordinate +import dev.ua.theroer.magicutils.build.target.magicUtilsModuleIsBundle +import dev.ua.theroer.magicutils.build.target.magicUtilsPublishedModuleVersion class MagicUtilsReleaseModelTest { @@ -46,18 +52,69 @@ class MagicUtilsReleaseModelTest { } @Test - fun `smokeArtifactUrl builds POM url`() { + fun `smokeArtifactUrl percent-encodes the java-suffixed coordinate`() { val spec = MagicUtilsPublishingSpec( group = "dev.ua.theroer", repoUrl = "https://maven.theroer.dev/releases", smokeArtifact = "magicutils-core", ) + // A bundle coordinate carries `+java`; the smoke URL percent-encodes the + // `+` for the HTTP request. assertEquals( - "https://maven.theroer.dev/releases/dev/ua/theroer/magicutils-core/1.21.5/magicutils-core-1.21.5.pom", - spec.smokeArtifactUrl(SemanticVersion(1, 21, 5)), + "https://maven.theroer.dev/releases/dev/ua/theroer/magicutils-core/" + + "1.26.0%2Bjava21/magicutils-core-1.26.0%2Bjava21.pom", + spec.smokeArtifactUrl(javaSuffixedCoordinate("1.26.0", 21)), ) } + @Test + fun `only -bundle modules are treated as bundles`() { + assertTrue(magicUtilsModuleIsBundle("magicutils-fabric-bundle")) + assertTrue(magicUtilsModuleIsBundle("magicutils-bukkit-bundle")) + assertTrue(magicUtilsModuleIsBundle("magicutils-neoforge-bundle")) + assertFalse(magicUtilsModuleIsBundle("magicutils-core")) + assertFalse(magicUtilsModuleIsBundle("magicutils-commands")) + // A bundle-like substring that is not the suffix must not match. + assertFalse(magicUtilsModuleIsBundle("magicutils-bundle-utils")) + } + + @Test + fun `published module version bare for libraries, java-suffixed for bundles`() { + // Plain library modules ship one bare coordinate across all Java levels. + assertEquals("1.27.1", magicUtilsPublishedModuleVersion("magicutils-core", "1.27.1", 17)) + assertEquals("1.27.1", magicUtilsPublishedModuleVersion("magicutils-core", "1.27.1", 21)) + assertEquals("1.27.1", magicUtilsPublishedModuleVersion("magicutils-core", "1.27.1", 25)) + // Bundles keep the +java coordinate, one real variant per Java level. + assertEquals("1.27.1+java17", magicUtilsPublishedModuleVersion("magicutils-fabric-bundle", "1.27.1", 17)) + assertEquals("1.27.1+java25", magicUtilsPublishedModuleVersion("magicutils-fabric-bundle", "1.27.1", 25)) + } + + @Test + fun `release branch gate blocks off-branch and allows main`() { + // On the required branch: allowed. + assertNull(releaseBranchViolation("main", "main", allowAnyBranch = false)) + // Off branch: blocked with a reason. + assertNotNull(releaseBranchViolation("main", "feature/x", allowAnyBranch = false)) + // Explicit bypass: allowed even off-branch. + assertNull(releaseBranchViolation("main", "feature/x", allowAnyBranch = true)) + // Gate disabled (null required branch): allowed anywhere. + assertNull(releaseBranchViolation(null, "feature/x", allowAnyBranch = false)) + // Unknown branch (detached HEAD / no git): not blocked (caller warns). + assertNull(releaseBranchViolation("main", null, allowAnyBranch = false)) + } + + @Test + fun `release branch override - absent keeps default, empty disables, value replaces`() { + val spec = MagicUtilsReleaseSpec() + assertEquals("main", spec.releaseBranch) + // Absent property -> keeps DSL default. + assertEquals("main", applyReleaseOverrides(spec, emptyMap()).releaseBranch) + // Present-but-empty -> disables the gate. + assertNull(applyReleaseOverrides(spec, mapOf("release.branch" to "")).releaseBranch) + // Named value -> replaces. + assertEquals("release/2.x", applyReleaseOverrides(spec, mapOf("release.branch" to "release/2.x")).releaseBranch) + } + @Test fun `validateReleaseVersion enforces monotonic increase and no dup tag`() { val current = SemanticVersion(1, 21, 5) @@ -76,4 +133,111 @@ class MagicUtilsReleaseModelTest { validateReleaseVersion(SemanticVersion(1, 21, 6), current, SemanticVersion(1, 21, 6), emptySet()) } } + + @Test + fun `validateReleaseVersion allows a resume when gradle_properties is already at the version`() { + // A prior release run bumped to 1.21.6 and tagged v1.21.6, then failed + // during publish. Re-running must NOT reject the existing tag / version — + // requested == current signals a resume, not a fresh release. + val current = SemanticVersion(1, 21, 6) + validateReleaseVersion( + requested = SemanticVersion(1, 21, 6), + current = current, + latestReleased = SemanticVersion(1, 21, 6), + existingTags = setOf("v1.21.6"), + ) // does not throw + } + + @Test + fun `parseModrinthVersionIds pairs version_number to id and ignores nested ids`() { + val json = """ + [ + {"id":"aaa111","version_number":"1.26.0","files":[{"id":"nested-file-id"}]}, + {"id":"bbb222","version_number":"1.25.0"} + ] + """.trimIndent() + val map = parseModrinthVersionIds(json) + assertEquals(mapOf("1.26.0" to "aaa111", "1.25.0" to "bbb222"), map) + } + + @Test + fun `evaluateReleaseConsistency passes when required sources agree and Modrinth may lag`() { + val v = SemanticVersion(1, 26, 0) + // Maven + tag + gradle.properties agree; Modrinth not yet published (manual) — still consistent. + val report = evaluateReleaseConsistency( + version = v, + gradlePropertiesVersion = v, + tagExists = true, + mavenPublished = true, + modrinthPublished = false, + ) + assertTrue(report.consistent) + assertTrue(report.problems.isEmpty()) + // Modrinth-absent still surfaces as an ABSENT status line for visibility. + assertEquals(SourceState.ABSENT, report.statuses.single { it.source == "Modrinth" }.state) + } + + @Test + fun `evaluateReleaseConsistency fails when a required source disagrees`() { + val v = SemanticVersion(1, 26, 0) + val report = evaluateReleaseConsistency( + version = v, + gradlePropertiesVersion = SemanticVersion(1, 25, 0), + tagExists = false, + mavenPublished = false, + modrinthPublished = null, + ) + assertFalse(report.consistent) + // gradle.properties, tag, Maven each contribute a problem; Modrinth (null) does not. + assertEquals(3, report.problems.size) + assertEquals(SourceState.SKIPPED, report.statuses.single { it.source == "Modrinth" }.state) + } + + @Test + fun `releasePlan lists steps in fixed order with enabled flags from the spec`() { + val spec = MagicUtilsReleaseSpec(publishModrinth = false, push = true) + val plan = releasePlan(spec) + assertEquals( + listOf( + "releasePreflight", "releaseValidateBuild", "bumpVersion", "releaseTag", + "releaseMavenAll", "releaseModrinth", "releaseJavadoc", "verifyReleaseConsistency", + ), + plan.map { it.name }, + ) + assertFalse(plan.single { it.name == "releaseModrinth" }.enabled) + assertTrue(plan.single { it.name == "releaseMavenAll" }.enabled) + } + + @Test + fun `applyReleaseOverrides merges -Prelease flags over the DSL spec`() { + val spec = MagicUtilsReleaseSpec() // all defaults (modrinth on, push off) + val merged = applyReleaseOverrides( + spec, + mapOf("release.modrinth" to "false", "release.push" to "true", "release.maven" to "FALSE"), + ) + assertFalse(merged.publishModrinth) + assertTrue(merged.push) + assertFalse(merged.publishMaven) // case-insensitive + assertTrue(merged.publishJavadoc) // untouched default + } + + @Test + fun `javadoc urls build the latest and versioned coordinate`() { + val repo = "https://maven.theroer.dev/releases" + assertEquals( + "https://maven.theroer.dev/releases/dev/ua/theroer/magicutils-javadoc/latest/magicutils-javadoc.zip", + javadocLatestUrl(repo, "dev.ua.theroer"), + ) + assertEquals( + "https://maven.theroer.dev/releases/dev/ua/theroer/magicutils-javadoc/1.27.0/magicutils-javadoc.zip", + javadocVersionUrl(repo, "dev.ua.theroer", "1.27.0"), + ) + } + + @Test + fun `applyReleaseOverrides ignores non-boolean values and keeps the DSL default`() { + val spec = MagicUtilsReleaseSpec(publishModrinth = true) + val merged = applyReleaseOverrides(spec, mapOf("release.modrinth" to "maybe")) + assertTrue(merged.publishModrinth) // garbage ignored, default stands + } } diff --git a/build.gradle.kts b/build.gradle.kts index de71d207a..3274188a1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,25 +7,38 @@ plugins { } val baseVersion = (project.properties["version"] as String?) ?: "0.0.0" -// Fabric-style version per target: + (e.g. 1.22.0+26.2). Every -// target is its own Maven version, so its module metadata (Java level, -// transitive deps) never collides with another target's. The consumer plugins -// mirror this suffix from their resolved target, so downstream builds keep -// declaring the bare base version. +// Coordinate version is module-aware (see magicUtilsPublishedModuleVersion): +// - bundles (`*-bundle`) keep `+java` — their shaded dependency set +// genuinely differs per Java level / Minecraft branch; +// - plain library modules publish the BARE `` — they are byte-identical +// across Java levels (the per-level diffs are javac codegen artifacts), so a +// single coordinate replaces the former three near-duplicate `+java17/21/25` +// copies. A resumed release with -Pskip_existing HEADs each POM, so the second +// and third Java-level representatives skip the already-uploaded bare module. +// The consumer plugins mirror this exact rule from their resolved target's Java +// level (see publishedVersion), so downstream builds keep declaring the bare base +// version and always resolve a coordinate that exists. val resolvedContext = gradle.extensions.extraProperties.get("magicutilsMatrixResolved") as dev.ua.theroer.magicutils.build.matrix.MagicUtilsMatrixResolvedContext -// Use the library Minecraft (the published coordinate's branch), not the runtime -// one — they differ when a target overrides library_minecraft, and consumers -// resolve against the library branch (publishedVersion mirrors this). -val targetMinecraft = resolvedContext.target.libraryMinecraft -val targetVersion = "$baseVersion+$targetMinecraft" +val javaLevel = resolvedContext.target.java + +val namingSpec = gradle.extensions.extraProperties + .let { if (it.has("magicutilsModuleNaming")) it.get("magicutilsModuleNaming") else null } + as? dev.ua.theroer.magicutils.build.module.MagicUtilsModuleNamingSpec + ?: dev.ua.theroer.magicutils.build.module.MagicUtilsModuleNamingSpec() val publishingSpec = gradle.extensions.extraProperties.get("magicutilsPublishingSpec") as MagicUtilsPublishingSpec allprojects { group = publishingSpec.group - version = targetVersion + // Keyed on the project's published artifact id so `-bundle` projects keep the + // Java suffix and library modules go bare. The root project has no artifact id + // of its own; its version is unused for publication but set for consistency. + val artifactId = namingSpec.moduleName(name) + version = dev.ua.theroer.magicutils.build.target.magicUtilsPublishedModuleVersion( + artifactId, baseVersion, javaLevel, + ) } val reflectionPatterns = listOf( diff --git a/bungee-bundle/src/main/java/dev/ua/theroer/magicutils/bungee/MagicUtilsBungeeBundlePlugin.java b/bungee-bundle/src/main/java/dev/ua/theroer/magicutils/bungee/MagicUtilsBungeeBundlePlugin.java index 98685ec79..0ff495b5c 100644 --- a/bungee-bundle/src/main/java/dev/ua/theroer/magicutils/bungee/MagicUtilsBungeeBundlePlugin.java +++ b/bungee-bundle/src/main/java/dev/ua/theroer/magicutils/bungee/MagicUtilsBungeeBundlePlugin.java @@ -58,7 +58,7 @@ public void onEnable() { .buildRuntime(); runtime = bootstrap.runtime(); - LoggerCore loggerCore = bootstrap.logger(); + LoggerCore loggerCore = bootstrap.logger().getCore(); CommandRegistry commandRegistry = bootstrap.commandRegistry(); if (commandRegistry != null) { // Register the diagnostics suite-name parser so diff --git a/commands-fabric/build.gradle.kts b/commands-fabric/build.gradle.kts index eb34b3326..03d0aae24 100644 --- a/commands-fabric/build.gradle.kts +++ b/commands-fabric/build.gradle.kts @@ -11,6 +11,7 @@ magicutilsPublish { dependencies { api(project(":commands-brigadier")) api(project(":diagnostics")) + api(project(":messaging")) api(project(":platform-fabric")) api(project(":logger-fabric")) diff --git a/commands-fabric/src/main/java/dev/ua/theroer/magicutils/bootstrap/FabricBootstrap.java b/commands-fabric/src/main/java/dev/ua/theroer/magicutils/bootstrap/FabricBootstrap.java index 0285ff5fc..db54cc9c1 100644 --- a/commands-fabric/src/main/java/dev/ua/theroer/magicutils/bootstrap/FabricBootstrap.java +++ b/commands-fabric/src/main/java/dev/ua/theroer/magicutils/bootstrap/FabricBootstrap.java @@ -8,6 +8,9 @@ import dev.ua.theroer.magicutils.diagnostics.DiagnosticsService; import dev.ua.theroer.magicutils.diagnostics.DiagnosticsSupport; import dev.ua.theroer.magicutils.lang.Messages; +import dev.ua.theroer.magicutils.messaging.MessagingService; +import dev.ua.theroer.magicutils.messaging.fabric.FabricMessagingSupport; +import dev.ua.theroer.magicutils.messaging.redis.RedisConfig; import dev.ua.theroer.magicutils.platform.MagicUtilsConsumerRegistry; import dev.ua.theroer.magicutils.platform.Platform; import dev.ua.theroer.magicutils.platform.fabric.FabricPlatformProvider; @@ -51,26 +54,22 @@ public static Builder forMod(String modName, Supplier serverSup public static final class Builder { private final String modName; private final Supplier serverSupplier; + private final LanguageBootstrap lang = new LanguageBootstrap(); private org.slf4j.Logger slf4j; private Path configDir; private Platform platform; private ConfigManager configManager; private Logger logger; private LanguageManager languageManager; - private String language = "en"; - private boolean initLanguage = true; - private boolean bindLoggerLanguage = true; - private boolean setMessagesManager = true; - private boolean registerMessages = true; - private boolean addMagicUtilsMessages = true; - private boolean bindClientLocaleSync = true; - private Consumer translations; private boolean enableCommands; private String permissionPrefix; private int opLevel = 2; private Consumer commandConfigurer; private boolean enableDiagnostics; private Consumer diagnosticsConfigurer; + private boolean enableMessaging; + private RedisConfig.Redis messagingRedis; + private Consumer messagingConfigurer; private Builder(String modName, Supplier serverSupplier) { this.modName = normalizeModName(modName); @@ -143,6 +142,28 @@ public Builder languageManager(LanguageManager languageManager) { return this; } + /** + * Applies the recommended language defaults (every flag enabled). This + * is already the initial state; call it to make intent explicit. + * + * @return builder + */ + public Builder withRecommendedDefaults() { + lang.withRecommendedDefaults(); + return this; + } + + /** + * Disables all automatic language/messages wiring for callers that + * manage localization themselves. + * + * @return builder + */ + public Builder minimal() { + lang.minimal(); + return this; + } + /** * Sets the default language code to initialize. * @@ -150,9 +171,7 @@ public Builder languageManager(LanguageManager languageManager) { * @return builder */ public Builder language(String language) { - if (language != null && !language.isBlank()) { - this.language = language; - } + lang.language(language); return this; } @@ -163,7 +182,7 @@ public Builder language(String language) { * @return builder */ public Builder initLanguage(boolean initLanguage) { - this.initLanguage = initLanguage; + lang.initLanguage(initLanguage); return this; } @@ -174,7 +193,7 @@ public Builder initLanguage(boolean initLanguage) { * @return builder */ public Builder bindLoggerLanguage(boolean bindLoggerLanguage) { - this.bindLoggerLanguage = bindLoggerLanguage; + lang.bindLoggerLanguage(bindLoggerLanguage); return this; } @@ -185,7 +204,7 @@ public Builder bindLoggerLanguage(boolean bindLoggerLanguage) { * @return builder */ public Builder setMessagesManager(boolean setMessagesManager) { - this.setMessagesManager = setMessagesManager; + lang.setMessagesManager(setMessagesManager); return this; } @@ -196,7 +215,7 @@ public Builder setMessagesManager(boolean setMessagesManager) { * @return builder */ public Builder registerMessages(boolean registerMessages) { - this.registerMessages = registerMessages; + lang.registerMessages(registerMessages); return this; } @@ -207,7 +226,7 @@ public Builder registerMessages(boolean registerMessages) { * @return builder */ public Builder addMagicUtilsMessages(boolean addMagicUtilsMessages) { - this.addMagicUtilsMessages = addMagicUtilsMessages; + lang.addMagicUtilsMessages(addMagicUtilsMessages); return this; } @@ -218,7 +237,7 @@ public Builder addMagicUtilsMessages(boolean addMagicUtilsMessages) { * @return builder */ public Builder bindClientLocaleSync(boolean bindClientLocaleSync) { - this.bindClientLocaleSync = bindClientLocaleSync; + lang.bindClientLocaleSync(bindClientLocaleSync); return this; } @@ -229,7 +248,7 @@ public Builder bindClientLocaleSync(boolean bindClientLocaleSync) { * @return builder */ public Builder translations(Consumer translations) { - this.translations = translations; + lang.translations(translations); return this; } @@ -297,6 +316,45 @@ public Builder configureDiagnostics(Consumer diagnosticsConf return this; } + /** + * Enables cross-server messaging. On Fabric only the Redis transport + * reaches other servers; supply Redis settings via + * {@link #messagingRedis(RedisConfig.Redis)}. Without Redis the bus falls + * back to an in-process loopback transport. + * + * @return builder + */ + public Builder enableMessaging() { + this.enableMessaging = true; + return this; + } + + /** + * Supplies Redis settings for messaging and enables messaging. When + * {@code enabled}, the Redis transport is used for cross-server delivery. + * + * @param redis redis settings + * @return builder + */ + public Builder messagingRedis(RedisConfig.Redis redis) { + this.messagingRedis = redis; + this.enableMessaging = true; + return this; + } + + /** + * Allows configuring the messaging service builder before it is built, + * and enables messaging. + * + * @param messagingConfigurer messaging builder callback + * @return builder + */ + public Builder configureMessaging(Consumer messagingConfigurer) { + this.messagingConfigurer = messagingConfigurer; + this.enableMessaging = true; + return this; + } + /** * Builds the bootstrap result without exposing the runtime wrapper. * @@ -324,10 +382,7 @@ public RuntimeResult buildRuntime() { .component(Logger.class, prepared.logger()) .build(); - if (bindClientLocaleSync) { - runtime.manage("language.clientLocaleSync", - prepared.languageManager().bindClientLocaleSync(prepared.platform())); - } + lang.bindClientLocaleSync(runtime, prepared.platform(), prepared.languageManager()); if (prepared.commandRegistry() != null) { runtime.putComponent(CommandRegistry.class, prepared.commandRegistry()); @@ -338,16 +393,10 @@ public RuntimeResult buildRuntime() { if (enableDiagnostics) { DiagnosticsSupport.install(runtime, diagnosticsConfigurer); } - if (registerMessages) { - runtime.onClose("messages.scope", () -> Messages.unregister(modName)); - } - if (setMessagesManager) { - runtime.onClose("messages.default", () -> { - if (Messages.getLanguageManager() == prepared.languageManager()) { - Messages.setLanguageManager(null); - } - }); + if (enableMessaging) { + FabricMessagingSupport.install(runtime, modName, messagingRedis, messagingConfigurer); } + lang.installMessagesCloseHooks(runtime, modName, prepared.languageManager()); // Register this mod in the shared-runtime consumer registry so the // standalone bundle command can list it (/magicutils mods|mod ), @@ -415,24 +464,7 @@ private Prepared prepare() { ? languageManager : new LanguageManager(resolvedPlatform, resolvedConfigManager); - if (initLanguage) { - resolvedLanguageManager.init(language); - } - if (translations != null) { - translations.accept(resolvedLanguageManager); - } - if (addMagicUtilsMessages) { - resolvedLanguageManager.addMagicUtilsMessages(); - } - if (registerMessages) { - Messages.register(modName, resolvedLanguageManager); - } - if (setMessagesManager) { - Messages.setLanguageManager(resolvedLanguageManager); - } - if (bindLoggerLanguage) { - resolvedLogger.setLanguageManager(resolvedLanguageManager); - } + lang.apply(modName, resolvedLanguageManager, resolvedLogger.getCore()); CommandRegistry registry = null; if (enableCommands) { diff --git a/commands-fabric/src/main/java/dev/ua/theroer/magicutils/messaging/fabric/FabricMessagingSupport.java b/commands-fabric/src/main/java/dev/ua/theroer/magicutils/messaging/fabric/FabricMessagingSupport.java new file mode 100644 index 000000000..2f903a2c1 --- /dev/null +++ b/commands-fabric/src/main/java/dev/ua/theroer/magicutils/messaging/fabric/FabricMessagingSupport.java @@ -0,0 +1,76 @@ +package dev.ua.theroer.magicutils.messaging.fabric; + +import java.util.UUID; +import java.util.function.Consumer; + +import dev.ua.theroer.magicutils.bootstrap.MagicRuntime; +import dev.ua.theroer.magicutils.messaging.MessageSource; +import dev.ua.theroer.magicutils.messaging.MessagingService; +import dev.ua.theroer.magicutils.messaging.redis.RedisConfig; +import dev.ua.theroer.magicutils.platform.Platform; +import dev.ua.theroer.magicutils.platform.PlatformLogger; +import org.jetbrains.annotations.Nullable; + +/** + * Wires a {@link MessagingService} into a Fabric mod's {@link MagicRuntime}. + * + *

Fabric mods use the platform-agnostic Redis transport for cross-server + * messaging. Unlike the Bukkit/Bungee/Velocity backends there is no default + * plugin-messaging transport here: riding the proxy's vanilla channel from a mod + * would require registering a foreign custom-payload id, which modern Minecraft + * only allows through the platform networking API. So no {@code defaultTransport} + * is supplied; when Redis is disabled {@link MessagingService} falls back to an + * in-process loopback transport (see {@code MessagingService.resolveTransport}), + * which keeps the bus usable but does not reach other servers. Enable Redis for + * actual cross-server delivery.

+ */ +public final class FabricMessagingSupport { + private FabricMessagingSupport() { + } + + /** + * Installs the messaging service on the runtime. + * + * @param runtime mod runtime + * @param modName owning mod id, used to scope the message source + * @param redis redis settings, or null to leave Redis disabled + * @param configurer optional callback to tweak the service builder + * @return the installed service + */ + public static MessagingService install( + MagicRuntime runtime, + String modName, + @Nullable RedisConfig.Redis redis, + @Nullable Consumer configurer) { + Platform platform = runtime.platform(); + PlatformLogger logger = platform.logger(); + + MessageSource self = MessageSource.backend( + modName + ":" + UUID.randomUUID(), + null); // server name is not known to a backend until the proxy tells it + + MessagingService.Builder builder = MessagingService.builder(self) + .logger(logger) + .redis(redis) + .hostsPlayer(id -> hostsPlayer(platform, id)); + + if (configurer != null) { + configurer.accept(builder); + } + + MessagingService service = builder.build(); + runtime.putComponent(MessagingService.class, service); + runtime.manage("messaging.service", service); + if (service.transportName().equals("loopback")) { + logger.info("MagicUtils messaging enabled (transport: loopback) — " + + "enable Redis for cross-server delivery on Fabric"); + } else { + logger.info("MagicUtils messaging enabled (transport: " + service.transportName() + ")"); + } + return service; + } + + private static boolean hostsPlayer(Platform platform, UUID id) { + return id != null && platform.playerById(id) != null; + } +} diff --git a/commands-neoforge/build.gradle.kts b/commands-neoforge/build.gradle.kts index 963965834..416fbc050 100644 --- a/commands-neoforge/build.gradle.kts +++ b/commands-neoforge/build.gradle.kts @@ -32,6 +32,7 @@ tasks.named("shadowJar") { dependencies { api(project(":commands-brigadier")) api(project(":diagnostics")) + api(project(":messaging")) api(project(":platform-neoforge")) implementation(libs.kyori.adventure.text.serializer.plain) compileOnly(libs.brigadier) diff --git a/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/Logger.java b/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/Logger.java index f9acc84d1..a434810ae 100644 --- a/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/Logger.java +++ b/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/Logger.java @@ -36,7 +36,32 @@ import java.util.Map; /** - * NeoForge logger adapter backed by {@link LoggerCore}. + * NeoForge logger: the single output surface for a mod. + * + *

Despite the name, this is not only for console logs. The same level methods + * send to the console (a log line) and to players (a chat message), so one call + * site covers both. Text is authored once with MiniMessage or legacy {@code &} + * codes and rendered correctly on every platform. + * + *

Everyday use is the short level methods; reach for the fluent + * {@link #log()} builder only when you need several recipients, tag resolvers, + * or a per-message prefix override: + * + *

{@code
+ * // Console log line:
+ * logger.info("Ready, %d loaded", count);
+ *
+ * // Message a player (same levels, different target):
+ * logger.info(player, "Teleported");
+ *
+ * // Composite case -> builder:
+ * logger.warn().to(player).toConsole().send("Low on funds");
+ * }
+ * + *

Backed by {@link LoggerCore}; obtain a prefixed sub-logger with + * {@link #create(String)}. + * + * @see LoggerAdapter */ @LogMethods(staticMethods = false, audienceType = "net.minecraft.server.level.ServerPlayer") public final class Logger extends LoggerMethods implements LoggerAdapter { diff --git a/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/bootstrap/NeoForgeBootstrap.java b/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/bootstrap/NeoForgeBootstrap.java index 64591f907..d559516f3 100644 --- a/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/bootstrap/NeoForgeBootstrap.java +++ b/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/bootstrap/NeoForgeBootstrap.java @@ -7,7 +7,9 @@ import dev.ua.theroer.magicutils.diagnostics.DiagnosticRegistry; import dev.ua.theroer.magicutils.diagnostics.DiagnosticsService; import dev.ua.theroer.magicutils.diagnostics.DiagnosticsSupport; -import dev.ua.theroer.magicutils.lang.Messages; +import dev.ua.theroer.magicutils.messaging.MessagingService; +import dev.ua.theroer.magicutils.messaging.neoforge.NeoForgeMessagingSupport; +import dev.ua.theroer.magicutils.messaging.redis.RedisConfig; import dev.ua.theroer.magicutils.platform.MagicUtilsConsumerRegistry; import dev.ua.theroer.magicutils.platform.Platform; import dev.ua.theroer.magicutils.platform.neoforge.NeoForgePlatformProvider; @@ -51,26 +53,22 @@ public static Builder forMod(String modName, Supplier serverSup public static final class Builder { private final String modName; private final Supplier serverSupplier; + private final LanguageBootstrap lang = new LanguageBootstrap(); private org.slf4j.Logger slf4j; private Path configDir; private Platform platform; private ConfigManager configManager; private Logger logger; private LanguageManager languageManager; - private String language = "en"; - private boolean initLanguage = true; - private boolean bindLoggerLanguage = true; - private boolean setMessagesManager = true; - private boolean registerMessages = true; - private boolean addMagicUtilsMessages = true; - private boolean bindClientLocaleSync = true; - private Consumer translations; private boolean enableCommands; private String permissionPrefix; private int opLevel = 2; private Consumer commandConfigurer; private boolean enableDiagnostics; private Consumer diagnosticsConfigurer; + private boolean enableMessaging; + private RedisConfig.Redis messagingRedis; + private Consumer messagingConfigurer; private Builder(String modName, Supplier serverSupplier) { this.modName = normalizeModName(modName); @@ -143,6 +141,28 @@ public Builder languageManager(LanguageManager languageManager) { return this; } + /** + * Applies the recommended language defaults (every flag enabled). This + * is already the initial state; call it to make intent explicit. + * + * @return this builder + */ + public Builder withRecommendedDefaults() { + lang.withRecommendedDefaults(); + return this; + } + + /** + * Disables all automatic language/messages wiring for callers that + * manage localization themselves. + * + * @return this builder + */ + public Builder minimal() { + lang.minimal(); + return this; + } + /** * Sets the default language. * @@ -150,9 +170,7 @@ public Builder languageManager(LanguageManager languageManager) { * @return this builder */ public Builder language(String language) { - if (language != null && !language.isBlank()) { - this.language = language; - } + lang.language(language); return this; } @@ -163,7 +181,7 @@ public Builder language(String language) { * @return this builder */ public Builder initLanguage(boolean initLanguage) { - this.initLanguage = initLanguage; + lang.initLanguage(initLanguage); return this; } @@ -174,7 +192,7 @@ public Builder initLanguage(boolean initLanguage) { * @return this builder */ public Builder bindLoggerLanguage(boolean bindLoggerLanguage) { - this.bindLoggerLanguage = bindLoggerLanguage; + lang.bindLoggerLanguage(bindLoggerLanguage); return this; } @@ -185,7 +203,7 @@ public Builder bindLoggerLanguage(boolean bindLoggerLanguage) { * @return this builder */ public Builder setMessagesManager(boolean setMessagesManager) { - this.setMessagesManager = setMessagesManager; + lang.setMessagesManager(setMessagesManager); return this; } @@ -196,7 +214,7 @@ public Builder setMessagesManager(boolean setMessagesManager) { * @return this builder */ public Builder registerMessages(boolean registerMessages) { - this.registerMessages = registerMessages; + lang.registerMessages(registerMessages); return this; } @@ -207,7 +225,7 @@ public Builder registerMessages(boolean registerMessages) { * @return this builder */ public Builder addMagicUtilsMessages(boolean addMagicUtilsMessages) { - this.addMagicUtilsMessages = addMagicUtilsMessages; + lang.addMagicUtilsMessages(addMagicUtilsMessages); return this; } @@ -218,7 +236,7 @@ public Builder addMagicUtilsMessages(boolean addMagicUtilsMessages) { * @return this builder */ public Builder bindClientLocaleSync(boolean bindClientLocaleSync) { - this.bindClientLocaleSync = bindClientLocaleSync; + lang.bindClientLocaleSync(bindClientLocaleSync); return this; } @@ -229,7 +247,7 @@ public Builder bindClientLocaleSync(boolean bindClientLocaleSync) { * @return this builder */ public Builder translations(Consumer translations) { - this.translations = translations; + lang.translations(translations); return this; } @@ -297,6 +315,45 @@ public Builder configureDiagnostics(Consumer diagnosticsConf return this; } + /** + * Enables cross-server messaging. On NeoForge only the Redis transport + * reaches other servers; supply Redis settings via + * {@link #messagingRedis(RedisConfig.Redis)}. Without Redis the bus falls + * back to an in-process loopback transport. + * + * @return this builder + */ + public Builder enableMessaging() { + this.enableMessaging = true; + return this; + } + + /** + * Supplies Redis settings for messaging and enables messaging. When + * {@code enabled}, the Redis transport is used for cross-server delivery. + * + * @param redis redis settings + * @return this builder + */ + public Builder messagingRedis(RedisConfig.Redis redis) { + this.messagingRedis = redis; + this.enableMessaging = true; + return this; + } + + /** + * Allows configuring the messaging service builder before it is built, + * and enables messaging. + * + * @param messagingConfigurer messaging builder callback + * @return this builder + */ + public Builder configureMessaging(Consumer messagingConfigurer) { + this.messagingConfigurer = messagingConfigurer; + this.enableMessaging = true; + return this; + } + /** * Builds the bootstrap result without exposing the runtime wrapper. * @@ -324,10 +381,7 @@ public RuntimeResult buildRuntime() { .component(Logger.class, prepared.logger()) .build(); - if (bindClientLocaleSync) { - runtime.manage("language.clientLocaleSync", - prepared.languageManager().bindClientLocaleSync(prepared.platform())); - } + lang.bindClientLocaleSync(runtime, prepared.platform(), prepared.languageManager()); if (prepared.commandRegistry() != null) { runtime.putComponent(CommandRegistry.class, prepared.commandRegistry()); @@ -338,16 +392,11 @@ public RuntimeResult buildRuntime() { if (enableDiagnostics) { DiagnosticsSupport.install(runtime, diagnosticsConfigurer); } - if (registerMessages) { - runtime.onClose("messages.scope", () -> Messages.unregister(modName)); - } - if (setMessagesManager) { - runtime.onClose("messages.default", () -> { - if (Messages.getLanguageManager() == prepared.languageManager()) { - Messages.setLanguageManager(null); - } - }); + if (enableMessaging) { + NeoForgeMessagingSupport.install( + runtime, modName, serverSupplier, messagingRedis, messagingConfigurer); } + lang.installMessagesCloseHooks(runtime, modName, prepared.languageManager()); // Publish this mod into the shared-runtime registry so the bundle's // `/magicutils mods` lists it (mirrors FabricBootstrap). @@ -433,24 +482,7 @@ private Prepared prepare() { ? languageManager : new LanguageManager(resolvedPlatform, resolvedConfigManager); - if (initLanguage) { - resolvedLanguageManager.init(language); - } - if (translations != null) { - translations.accept(resolvedLanguageManager); - } - if (addMagicUtilsMessages) { - resolvedLanguageManager.addMagicUtilsMessages(); - } - if (registerMessages) { - Messages.register(modName, resolvedLanguageManager); - } - if (setMessagesManager) { - Messages.setLanguageManager(resolvedLanguageManager); - } - if (bindLoggerLanguage) { - resolvedLogger.setLanguageManager(resolvedLanguageManager); - } + lang.apply(modName, resolvedLanguageManager, resolvedLogger.getCore()); CommandRegistry registry = null; if (enableCommands) { diff --git a/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java b/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java index d9b5bc76a..b3af468aa 100644 --- a/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java +++ b/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java @@ -1,18 +1,20 @@ package dev.ua.theroer.magicutils.logger; import dev.ua.theroer.magicutils.Logger; -import dev.ua.theroer.magicutils.platform.Audience; -import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; import net.minecraft.commands.CommandSourceStack; import net.minecraft.server.level.ServerPlayer; import java.util.Collection; -import java.util.Map; /** - * NeoForge-specific log builder with player helpers. + * NeoForge-specific log builder with player and command-source helpers. + * + *

The shared fluent methods ({@code to(Audience)}, {@code target}, + * {@code noPrefix}, ...) are inherited from {@link LogBuilderCore} and already + * return {@code LogBuilder} thanks to its self type; this class only adds the + * NeoForge-typed recipient overloads. */ -public class LogBuilder extends LogBuilderCore { +public class LogBuilder extends LogBuilderCore { private final Logger logger; /** @@ -26,24 +28,6 @@ public LogBuilder(Logger logger, LogLevel level) { this.logger = logger; } - /** - * {@inheritDoc} - */ - @Override - public LogBuilder to(Audience audience) { - super.to(audience); - return this; - } - - /** - * {@inheritDoc} - */ - @Override - public LogBuilder target(LogTarget target) { - super.target(target); - return this; - } - /** * Sends the log to a specific player. * @@ -58,7 +42,7 @@ public LogBuilder to(ServerPlayer player) { /** * Sends the log to multiple players. * - * @param players player recipients + * @param players target players * @return this builder */ public LogBuilder to(ServerPlayer... players) { @@ -75,7 +59,7 @@ public LogBuilder to(ServerPlayer... players) { /** * Sends the log to a collection of players. * - * @param players player recipients + * @param players target players * @return this builder */ public LogBuilder to(Collection players) { @@ -92,7 +76,7 @@ public LogBuilder to(Collection players) { /** * Adds a player as a recipient. * - * @param player player recipient + * @param player target player * @return this builder */ public LogBuilder recipient(ServerPlayer player) { @@ -101,7 +85,7 @@ public LogBuilder recipient(ServerPlayer player) { } /** - * Sends the log to a command source. + * Sets command source as the direct audience. * * @param source command source * @return this builder @@ -112,7 +96,7 @@ public LogBuilder to(CommandSourceStack source) { } /** - * Sends the log to a command source with optional op broadcast. + * Sets command source as the direct audience, optionally broadcasting to ops. * * @param source command source * @param broadcastToOps whether to broadcast to ops @@ -124,7 +108,7 @@ public LogBuilder to(CommandSourceStack source, boolean broadcastToOps) { } /** - * Sends the log to a command source in error mode. + * Sets command source as the error audience. * * @param source command source * @return this builder @@ -155,76 +139,4 @@ public LogBuilder recipientError(CommandSourceStack source) { super.recipient(logger.wrapErrorAudience(source)); return this; } - - /** - * {@inheritDoc} - */ - @Override - public LogBuilder toAudiences(Collection audiences) { - super.toAudiences(audiences); - return this; - } - - /** - * {@inheritDoc} - */ - @Override - public LogBuilder toAll() { - super.toAll(); - return this; - } - - /** - * {@inheritDoc} - */ - @Override - public LogBuilder toConsole() { - super.toConsole(); - return this; - } - - /** - * {@inheritDoc} - */ - @Override - public LogBuilder noPrefix() { - super.noPrefix(); - return this; - } - - /** - * {@inheritDoc} - */ - @Override - public LogBuilder prefixMode(PrefixMode mode) { - super.prefixMode(mode); - return this; - } - - /** - * {@inheritDoc} - */ - @Override - public LogBuilder args(Object... args) { - super.args(args); - return this; - } - - /** - * {@inheritDoc} - */ - @Override - public LogBuilder placeholders(Map placeholders) { - super.placeholders(placeholders); - return this; - } - - /** - * {@inheritDoc} - */ - @Override - public LogBuilder withResolvers(TagResolver... resolvers) { - super.withResolvers(resolvers); - return this; - } } diff --git a/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/messaging/neoforge/NeoForgeMessagingSupport.java b/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/messaging/neoforge/NeoForgeMessagingSupport.java new file mode 100644 index 000000000..76e8d1923 --- /dev/null +++ b/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/messaging/neoforge/NeoForgeMessagingSupport.java @@ -0,0 +1,85 @@ +package dev.ua.theroer.magicutils.messaging.neoforge; + +import java.util.UUID; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import dev.ua.theroer.magicutils.bootstrap.MagicRuntime; +import dev.ua.theroer.magicutils.messaging.MessageSource; +import dev.ua.theroer.magicutils.messaging.MessagingService; +import dev.ua.theroer.magicutils.messaging.redis.RedisConfig; +import dev.ua.theroer.magicutils.platform.Platform; +import dev.ua.theroer.magicutils.platform.PlatformLogger; +import net.minecraft.server.MinecraftServer; +import org.jetbrains.annotations.Nullable; + +/** + * Wires a {@link MessagingService} into a NeoForge mod's {@link MagicRuntime}. + * + *

NeoForge mods use the platform-agnostic Redis transport for cross-server + * messaging. Unlike the Bukkit/Bungee/Velocity backends there is no default + * plugin-messaging transport here: riding the proxy's vanilla channel from a mod + * would require registering a foreign custom-payload id through the NeoForge + * networking registrar. So no {@code defaultTransport} is supplied; when Redis is + * disabled {@link MessagingService} falls back to an in-process loopback + * transport (see {@code MessagingService.resolveTransport}), which keeps the bus + * usable but does not reach other servers. Enable Redis for actual cross-server + * delivery.

+ */ +public final class NeoForgeMessagingSupport { + private NeoForgeMessagingSupport() { + } + + /** + * Installs the messaging service on the runtime. + * + * @param runtime mod runtime + * @param modName owning mod id, used to scope the message source + * @param serverSupplier supplier of the current Minecraft server, used to + * check whether a player is online + * @param redis redis settings, or null to leave Redis disabled + * @param configurer optional callback to tweak the service builder + * @return the installed service + */ + public static MessagingService install( + MagicRuntime runtime, + String modName, + Supplier serverSupplier, + @Nullable RedisConfig.Redis redis, + @Nullable Consumer configurer) { + Platform platform = runtime.platform(); + PlatformLogger logger = platform.logger(); + + MessageSource self = MessageSource.backend( + modName + ":" + UUID.randomUUID(), + null); // server name is not known to a backend until the proxy tells it + + MessagingService.Builder builder = MessagingService.builder(self) + .logger(logger) + .redis(redis) + .hostsPlayer(id -> hostsPlayer(serverSupplier, id)); + + if (configurer != null) { + configurer.accept(builder); + } + + MessagingService service = builder.build(); + runtime.putComponent(MessagingService.class, service); + runtime.manage("messaging.service", service); + if (service.transportName().equals("loopback")) { + logger.info("MagicUtils messaging enabled (transport: loopback) — " + + "enable Redis for cross-server delivery on NeoForge"); + } else { + logger.info("MagicUtils messaging enabled (transport: " + service.transportName() + ")"); + } + return service; + } + + private static boolean hostsPlayer(Supplier serverSupplier, UUID id) { + if (id == null || serverSupplier == null) { + return false; + } + MinecraftServer server = serverSupplier.get(); + return server != null && server.getPlayerList().getPlayer(id) != null; + } +} diff --git a/core/src/main/java/dev/ua/theroer/magicutils/bootstrap/LanguageBootstrap.java b/core/src/main/java/dev/ua/theroer/magicutils/bootstrap/LanguageBootstrap.java new file mode 100644 index 000000000..eb79763c2 --- /dev/null +++ b/core/src/main/java/dev/ua/theroer/magicutils/bootstrap/LanguageBootstrap.java @@ -0,0 +1,266 @@ +package dev.ua.theroer.magicutils.bootstrap; + +import dev.ua.theroer.magicutils.lang.LanguageManager; +import dev.ua.theroer.magicutils.lang.Messages; +import dev.ua.theroer.magicutils.logger.LoggerCore; +import dev.ua.theroer.magicutils.platform.Platform; +import java.util.function.Consumer; +import org.jetbrains.annotations.Nullable; + +/** + * Shared language and {@link Messages} wiring for platform bootstrap helpers. + * + *

Historically every platform bootstrap (Bukkit, Bungee, Velocity, Fabric, + * NeoForge) duplicated the same eight language flags, their setters, and the + * apply/close logic. That logic only touches {@link LanguageManager}, + * {@link Messages}, {@link LoggerCore} and {@link Platform}, all of which are + * visible from {@code core}, so it lives here as the single source of truth. + * Platform builders own an instance and delegate their language setters to it; + * command, diagnostics, and messaging wiring stay platform-specific because + * those types differ per platform. + * + *

Terminology: a scope is the owner name a plugin/mod registers its + * messages under (plugin name or mod id). + */ +public final class LanguageBootstrap { + + private String language = "en"; + private boolean initLanguage = true; + private boolean bindLoggerLanguage = true; + private boolean setMessagesManager = true; + private boolean registerMessages = true; + private boolean addMagicUtilsMessages = true; + private boolean bindClientLocaleSync = true; + private @Nullable Consumer translations; + + /** + * Creates a language bootstrap with the recommended defaults already + * applied (every flag on, language {@code "en"}). + */ + public LanguageBootstrap() { + } + + /** + * Sets the default language code to initialize. Blank values are ignored. + * + * @param language language code (for example, {@code "en"}) + * @return this + */ + public LanguageBootstrap language(String language) { + if (language != null && !language.isBlank()) { + this.language = language; + } + return this; + } + + /** + * Toggles automatic language initialization via + * {@link LanguageManager#init(String)}. + * + * @param initLanguage true to initialize the language on build + * @return this + */ + public LanguageBootstrap initLanguage(boolean initLanguage) { + this.initLanguage = initLanguage; + return this; + } + + /** + * Toggles binding the language manager to the logger so log output is + * localized. + * + * @param bindLoggerLanguage true to set the logger language manager + * @return this + */ + public LanguageBootstrap bindLoggerLanguage(boolean bindLoggerLanguage) { + this.bindLoggerLanguage = bindLoggerLanguage; + return this; + } + + /** + * Toggles setting the global {@link Messages} language manager to this + * plugin/mod's manager. + * + * @param setMessagesManager true to set the global manager + * @return this + */ + public LanguageBootstrap setMessagesManager(boolean setMessagesManager) { + this.setMessagesManager = setMessagesManager; + return this; + } + + /** + * Toggles registering a scoped {@link Messages} manager keyed by the + * plugin name or mod id. + * + * @param registerMessages true to register the scope + * @return this + */ + public LanguageBootstrap registerMessages(boolean registerMessages) { + this.registerMessages = registerMessages; + return this; + } + + /** + * Toggles adding the bundled MagicUtils default language entries. + * + * @param addMagicUtilsMessages true to add bundled defaults + * @return this + */ + public LanguageBootstrap addMagicUtilsMessages(boolean addMagicUtilsMessages) { + this.addMagicUtilsMessages = addMagicUtilsMessages; + return this; + } + + /** + * Toggles synchronizing a player's language from client locale updates. + * + * @param bindClientLocaleSync true to bind client locale synchronization + * @return this + */ + public LanguageBootstrap bindClientLocaleSync(boolean bindClientLocaleSync) { + this.bindClientLocaleSync = bindClientLocaleSync; + return this; + } + + /** + * Registers plugin/mod translations against the language manager on build. + * + * @param translations translation registrar + * @return this + */ + public LanguageBootstrap translations(@Nullable Consumer translations) { + this.translations = translations; + return this; + } + + /** + * Applies the recommended defaults: every flag enabled. This is already the + * initial state, so it exists mainly to make intent explicit and to reset a + * builder that called {@link #minimal()}. + * + * @return this + */ + public LanguageBootstrap withRecommendedDefaults() { + this.initLanguage = true; + this.bindLoggerLanguage = true; + this.setMessagesManager = true; + this.registerMessages = true; + this.addMagicUtilsMessages = true; + this.bindClientLocaleSync = true; + return this; + } + + /** + * Disables all automatic language wiring for callers that manage the + * {@link LanguageManager} and {@link Messages} themselves. Leaves + * {@link #language(String)} untouched. + * + * @return this + */ + public LanguageBootstrap minimal() { + this.initLanguage = false; + this.bindLoggerLanguage = false; + this.setMessagesManager = false; + this.registerMessages = false; + this.addMagicUtilsMessages = false; + this.bindClientLocaleSync = false; + return this; + } + + /** + * Whether client-locale synchronization should be bound on the runtime. + * + * @return true when enabled + */ + public boolean bindsClientLocaleSync() { + return bindClientLocaleSync; + } + + /** + * Whether a scoped messages registration was requested (drives the close + * hook that unregisters it). + * + * @return true when enabled + */ + public boolean registersMessages() { + return registerMessages; + } + + /** + * Whether the global messages manager was set (drives the close hook that + * clears it). + * + * @return true when enabled + */ + public boolean setsMessagesManager() { + return setMessagesManager; + } + + /** + * Runs the byte-identical language/messages wiring against a resolved + * language manager and logger. Mirrors the former per-platform + * {@code prepare()} bodies exactly. + * + * @param scope owner name (plugin name or mod id) used for the messages scope + * @param languageManager resolved language manager + * @param logger resolved logger core + */ + public void apply(String scope, LanguageManager languageManager, LoggerCore logger) { + if (initLanguage) { + languageManager.init(language); + } + if (translations != null) { + translations.accept(languageManager); + } + if (addMagicUtilsMessages) { + languageManager.addMagicUtilsMessages(); + } + if (registerMessages) { + Messages.register(scope, languageManager); + } + if (setMessagesManager) { + Messages.setLanguageManager(languageManager); + } + if (bindLoggerLanguage) { + logger.setLanguageManager(languageManager); + } + } + + /** + * Binds client-locale synchronization on the runtime when enabled. Mirrors + * the former per-platform {@code buildRuntime()} block. + * + * @param runtime managed runtime + * @param platform platform adapter + * @param languageManager resolved language manager + */ + public void bindClientLocaleSync(MagicRuntime runtime, Platform platform, LanguageManager languageManager) { + if (bindClientLocaleSync) { + runtime.manage("language.clientLocaleSync", + languageManager.bindClientLocaleSync(platform)); + } + } + + /** + * Installs the messages-related close hooks on the runtime when the + * corresponding flags are enabled. Mirrors the former per-platform + * {@code buildRuntime()} close blocks. + * + * @param runtime managed runtime + * @param scope owner name (plugin name or mod id) used for the messages scope + * @param languageManager resolved language manager + */ + public void installMessagesCloseHooks(MagicRuntime runtime, String scope, LanguageManager languageManager) { + if (registerMessages) { + runtime.onClose("messages.scope", () -> Messages.unregister(scope)); + } + if (setMessagesManager) { + runtime.onClose("messages.default", () -> { + if (Messages.getLanguageManager() == languageManager) { + Messages.setLanguageManager(null); + } + }); + } + } +} diff --git a/core/src/test/java/dev/ua/theroer/magicutils/bootstrap/LanguageBootstrapTest.java b/core/src/test/java/dev/ua/theroer/magicutils/bootstrap/LanguageBootstrapTest.java new file mode 100644 index 000000000..9c799f655 --- /dev/null +++ b/core/src/test/java/dev/ua/theroer/magicutils/bootstrap/LanguageBootstrapTest.java @@ -0,0 +1,189 @@ +package dev.ua.theroer.magicutils.bootstrap; + +import dev.ua.theroer.magicutils.config.ConfigManager; +import dev.ua.theroer.magicutils.lang.LanguageManager; +import dev.ua.theroer.magicutils.lang.Messages; +import dev.ua.theroer.magicutils.logger.LoggerCore; +import dev.ua.theroer.magicutils.platform.Audience; +import dev.ua.theroer.magicutils.platform.Platform; +import dev.ua.theroer.magicutils.platform.PlatformLogger; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LanguageBootstrapTest { + private static final String SCOPE = "LanguageBootstrapTest"; + + @TempDir + Path tempDir; + + private final List openManagers = new java.util.ArrayList<>(); + + @AfterEach + void resetGlobalState() { + // apply()/hooks mutate the global Messages singleton; keep tests isolated. + Messages.unregister(SCOPE); + Messages.setLanguageManager(null); + // Release config watchers/handles so @TempDir cleanup can delete the dir. + for (ConfigManager manager : openManagers) { + manager.shutdown(); + } + openManagers.clear(); + } + + @Test + void recommendedDefaultsKeepEveryFlagEnabled() { + LanguageBootstrap lang = new LanguageBootstrap().withRecommendedDefaults(); + + assertTrue(lang.bindsClientLocaleSync()); + assertTrue(lang.registersMessages()); + assertTrue(lang.setsMessagesManager()); + } + + @Test + void minimalDisablesEveryFlag() { + LanguageBootstrap lang = new LanguageBootstrap().minimal(); + + assertFalse(lang.bindsClientLocaleSync()); + assertFalse(lang.registersMessages()); + assertFalse(lang.setsMessagesManager()); + } + + @Test + void applyWithDefaultsRegistersScopeAndGlobalManager() throws IOException { + Fixture fixture = newFixture("defaults"); + LanguageManager manager = fixture.languageManager(); + + new LanguageBootstrap().apply(SCOPE, manager, fixture.logger()); + + assertSame(manager, Messages.getLanguageManager(SCOPE)); + assertSame(manager, Messages.getLanguageManager()); + assertSame(manager, fixture.logger().getLanguageManager()); + } + + @Test + void applyMinimalSkipsMessagesAndLoggerWiring() throws IOException { + Fixture fixture = newFixture("minimal"); + LanguageManager manager = fixture.languageManager(); + + new LanguageBootstrap().minimal().apply(SCOPE, manager, fixture.logger()); + + assertNull(Messages.getLanguageManager(SCOPE)); + assertNull(Messages.getLanguageManager()); + } + + @Test + void closeHooksRunWhenFlagsEnabled() throws IOException { + Fixture fixture = newFixture("close-hooks"); + LanguageManager manager = fixture.languageManager(); + LanguageBootstrap lang = new LanguageBootstrap(); + lang.apply(SCOPE, manager, fixture.logger()); + + try (MagicRuntime runtime = MagicRuntime.builder( + fixture.platform(), fixture.configManager(), fixture.logger()) + .manageConfigManager(false) + .autoRegisterShutdown(false) + .build()) { + lang.installMessagesCloseHooks(runtime, SCOPE, manager); + + assertSame(manager, Messages.getLanguageManager(SCOPE)); + runtime.close(); + + assertNull(Messages.getLanguageManager(SCOPE)); + assertNull(Messages.getLanguageManager()); + } + } + + private Fixture newFixture(String name) throws IOException { + Path directory = Files.createDirectories(tempDir.resolve(name)); + TestPlatform platform = new TestPlatform(directory); + ConfigManager configManager = new ConfigManager(platform); + openManagers.add(configManager); + LoggerCore logger = new LoggerCore(platform, configManager, this, SCOPE); + LanguageManager manager = new LanguageManager(platform, configManager); + return new Fixture(platform, configManager, logger, manager); + } + + private record Fixture(TestPlatform platform, + ConfigManager configManager, + LoggerCore logger, + LanguageManager languageManager) { + } + + private static final class TestPlatform implements Platform { + private final Path configDir; + private final PlatformLogger logger = new NoopPlatformLogger(); + + private TestPlatform(Path configDir) { + this.configDir = configDir; + } + + @Override + public Path configDir() { + return configDir; + } + + @Override + public PlatformLogger logger() { + return logger; + } + + @Override + public Audience console() { + return null; + } + + @Override + public Collection onlinePlayers() { + return List.of(); + } + + @Override + public void runOnMain(Runnable task) { + if (task != null) { + task.run(); + } + } + + @Override + public boolean isMainThread() { + return true; + } + } + + private static final class NoopPlatformLogger implements PlatformLogger { + @Override + public void info(String message) { + } + + @Override + public void warn(String message) { + } + + @Override + public void warn(String message, Throwable throwable) { + } + + @Override + public void error(String message) { + } + + @Override + public void error(String message, Throwable throwable) { + } + + @Override + public void debug(String message) { + } + } +} diff --git a/gradle.properties b/gradle.properties index 0d8b9ffed..1b0c41d02 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ org.gradle.caching=true org.gradle.parallel=true org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 -version=1.23.0 +version=1.27.0 # Minecraft version is now managed by gradle/targets.properties # You can set it via -Ptarget=... when running Gradle diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d56a56b97..080d0034a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,7 @@ javaGradlePlugin = "8.14.1" annotations = "24.0.1" lombok = "1.18.44" jackson = "2.17.1" +jedis = "5.1.5" kyoriAdventure = "4.24.0" kyoriAdventureLegacy = "4.24.0" kyoriAnsi = "1.1.1" @@ -31,6 +32,8 @@ jetbrains-annotations = { module = "org.jetbrains:annotations", version.ref = "a projectlombok-lombok = { module = "org.projectlombok:lombok", version.ref = "lombok" } # Jackson +# Redis (optional messaging transport) +jedis = { module = "redis.clients:jedis", version.ref = "jedis" } jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } jackson-dataformat-toml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-toml", version.ref = "jackson" } jackson-dataformat-yaml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml", version.ref = "jackson" } diff --git a/gradle/reflection-allowlist.txt b/gradle/reflection-allowlist.txt index c2283ebd7..1cc32a514 100644 --- a/gradle/reflection-allowlist.txt +++ b/gradle/reflection-allowlist.txt @@ -48,7 +48,7 @@ platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitCo platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitConsoleAudience.java:176:.getMethod( platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitConsoleAudience.java:177:.getMethod( platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitConsoleAudience.java:178:.getMethod( -platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitMagicUtilsConsumerRegistry.java:138:.getMethod( +platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitMagicUtilsConsumerRegistry.java:145:.getMethod( platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitPlaceholderRegistrar.java:66:Class.forName( platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitPlaceholderRegistrar.java:71:Class.forName( platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitThreading.java:181:Class.forName( diff --git a/logger-fabric/src/main/java/dev/ua/theroer/magicutils/Logger.java b/logger-fabric/src/main/java/dev/ua/theroer/magicutils/Logger.java index d8a1a3fce..a14ca9349 100644 --- a/logger-fabric/src/main/java/dev/ua/theroer/magicutils/Logger.java +++ b/logger-fabric/src/main/java/dev/ua/theroer/magicutils/Logger.java @@ -35,7 +35,32 @@ import net.minecraft.server.level.ServerPlayer; /** - * Fabric logger adapter backed by {@link LoggerCore}. + * Fabric logger: the single output surface for a mod. + * + *

Despite the name, this is not only for console logs. The same level methods + * send to the console (a log line) and to players (a chat message), so one call + * site covers both. Text is authored once with MiniMessage or legacy {@code &} + * codes and rendered correctly on every platform. + * + *

Everyday use is the short level methods; reach for the fluent + * {@link #log()} builder only when you need several recipients, tag resolvers, + * or a per-message prefix override: + * + *

{@code
+ * // Console log line:
+ * logger.info("Ready, %d loaded", count);
+ *
+ * // Message a player (same levels, different target):
+ * logger.info(player, "Teleported");
+ *
+ * // Composite case -> builder:
+ * logger.warn().to(player).toConsole().send("Low on funds");
+ * }
+ * + *

Backed by {@link LoggerCore}; obtain a prefixed sub-logger with + * {@link #create(String)}. + * + * @see LoggerAdapter */ @LogMethods(staticMethods = false, audienceType = "net.minecraft.server.level.ServerPlayer") public final class Logger extends LoggerMethods implements LoggerAdapter { diff --git a/logger-fabric/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java b/logger-fabric/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java index e7d33dd59..bb111f5dc 100644 --- a/logger-fabric/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java +++ b/logger-fabric/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java @@ -1,17 +1,19 @@ package dev.ua.theroer.magicutils.logger; import dev.ua.theroer.magicutils.Logger; -import dev.ua.theroer.magicutils.platform.Audience; -import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; import net.minecraft.commands.CommandSourceStack; import net.minecraft.server.level.ServerPlayer; import java.util.Collection; -import java.util.Map; /** - * Fabric-specific log builder with player helpers. + * Fabric-specific log builder with player and command-source helpers. + * + *

The shared fluent methods ({@code to(Audience)}, {@code target}, + * {@code noPrefix}, ...) are inherited from {@link LogBuilderCore} and already + * return {@code LogBuilder} thanks to its self type; this class only adds the + * Fabric-typed recipient overloads. */ -public class LogBuilder extends LogBuilderCore { +public class LogBuilder extends LogBuilderCore { private final Logger logger; /** @@ -25,20 +27,6 @@ public LogBuilder(Logger logger, LogLevel level) { this.logger = logger; } - /** {@inheritDoc} */ - @Override - public LogBuilder to(Audience audience) { - super.to(audience); - return this; - } - - /** {@inheritDoc} */ - @Override - public LogBuilder target(LogTarget target) { - super.target(target); - return this; - } - /** * Adds a player recipient. * @@ -150,60 +138,4 @@ public LogBuilder recipientError(CommandSourceStack source) { super.recipient(logger.wrapErrorAudience(source)); return this; } - - /** {@inheritDoc} */ - @Override - public LogBuilder toAudiences(Collection audiences) { - super.toAudiences(audiences); - return this; - } - - /** {@inheritDoc} */ - @Override - public LogBuilder toAll() { - super.toAll(); - return this; - } - - /** {@inheritDoc} */ - @Override - public LogBuilder toConsole() { - super.toConsole(); - return this; - } - - /** {@inheritDoc} */ - @Override - public LogBuilder noPrefix() { - super.noPrefix(); - return this; - } - - /** {@inheritDoc} */ - @Override - public LogBuilder prefixMode(PrefixMode mode) { - super.prefixMode(mode); - return this; - } - - /** {@inheritDoc} */ - @Override - public LogBuilder args(Object... args) { - super.args(args); - return this; - } - - /** {@inheritDoc} */ - @Override - public LogBuilder placeholders(Map placeholders) { - super.placeholders(placeholders); - return this; - } - - /** {@inheritDoc} */ - @Override - public LogBuilder withResolvers(TagResolver... resolvers) { - super.withResolvers(resolvers); - return this; - } } diff --git a/logger-fabric/src/main/java/dev/ua/theroer/magicutils/platform/fabric/FabricExternalPlaceholderEngine.java b/logger-fabric/src/main/java/dev/ua/theroer/magicutils/platform/fabric/FabricExternalPlaceholderEngine.java index 03563db57..5739eab6e 100644 --- a/logger-fabric/src/main/java/dev/ua/theroer/magicutils/platform/fabric/FabricExternalPlaceholderEngine.java +++ b/logger-fabric/src/main/java/dev/ua/theroer/magicutils/platform/fabric/FabricExternalPlaceholderEngine.java @@ -23,7 +23,6 @@ /** * Fabric-specific external placeholder engine adapter. */ -@SuppressWarnings("doclint:missing") public final class FabricExternalPlaceholderEngine implements ExternalPlaceholderEngine { private static final Pattern PB4_PERCENT_PATTERN = Pattern .compile("(?[^%]+)[%]"); diff --git a/logger/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilderCore.java b/logger/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilderCore.java index cacf28f17..c072f3e84 100644 --- a/logger/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilderCore.java +++ b/logger/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilderCore.java @@ -12,8 +12,16 @@ /** * Fluent API builder for logging messages with advanced options. + * + *

Uses a self-referential type parameter so the shared fluent methods return + * the concrete platform builder type. Platform subclasses declare + * {@code class LogBuilder extends LogBuilderCore} and only add their + * player/command-source overloads; they no longer need to re-declare covariant + * overrides of the shared methods just to keep the chain typed. + * + * @param concrete builder type returned by the fluent methods */ -public class LogBuilderCore { +public class LogBuilderCore> { /** Logger core used to dispatch messages. */ protected final LoggerCore logger; /** Log level associated with this builder. */ @@ -46,6 +54,11 @@ public LogBuilderCore(LoggerCore logger, LogLevel level) { this.tagResolvers = new ArrayList<>(); } + @SuppressWarnings("unchecked") + private SELF self() { + return (SELF) this; + } + /** * Returns a snapshot of queued recipients. * @@ -88,9 +101,9 @@ public Map getPlaceholders() { * @param target log target * @return this builder */ - public LogBuilderCore target(LogTarget target) { + public SELF target(LogTarget target) { this.target = target; - return this; + return self(); } /** @@ -99,9 +112,9 @@ public LogBuilderCore target(LogTarget target) { * @param audience target audience * @return this builder */ - public LogBuilderCore to(Audience audience) { + public SELF to(Audience audience) { this.audience = audience; - return this; + return self(); } /** @@ -110,11 +123,11 @@ public LogBuilderCore to(Audience audience) { * @param audiences recipient collection * @return this builder */ - public LogBuilderCore toAudiences(Collection audiences) { + public SELF toAudiences(Collection audiences) { if (audiences != null) { this.recipients.addAll(audiences); } - return this; + return self(); } /** @@ -123,11 +136,11 @@ public LogBuilderCore toAudiences(Collection audiences) { * @param audience recipient * @return this builder */ - public LogBuilderCore recipient(Audience audience) { + public SELF recipient(Audience audience) { if (audience != null) { this.recipients.add(audience); } - return this; + return self(); } /** @@ -135,9 +148,9 @@ public LogBuilderCore recipient(Audience audience) { * * @return this builder */ - public LogBuilderCore toAll() { + public SELF toAll() { this.broadcast = true; - return this; + return self(); } /** @@ -145,9 +158,9 @@ public LogBuilderCore toAll() { * * @return this builder */ - public LogBuilderCore toConsole() { + public SELF toConsole() { this.target = LogTarget.CONSOLE; - return this; + return self(); } /** @@ -155,10 +168,10 @@ public LogBuilderCore toConsole() { * * @return this builder */ - public LogBuilderCore noPrefix() { + public SELF noPrefix() { this.noPrefix = true; this.prefixOverride = PrefixMode.NONE; - return this; + return self(); } /** @@ -167,9 +180,9 @@ public LogBuilderCore noPrefix() { * @param mode prefix mode override * @return this builder */ - public LogBuilderCore prefixMode(PrefixMode mode) { + public SELF prefixMode(PrefixMode mode) { this.prefixOverride = mode; - return this; + return self(); } /** @@ -178,9 +191,9 @@ public LogBuilderCore prefixMode(PrefixMode mode) { * @param args placeholder arguments * @return this builder */ - public LogBuilderCore args(Object... args) { + public SELF args(Object... args) { this.args = args != null ? args.clone() : null; - return this; + return self(); } /** @@ -189,9 +202,9 @@ public LogBuilderCore args(Object... args) { * @param placeholders placeholder map * @return this builder */ - public LogBuilderCore placeholders(Map placeholders) { + public SELF placeholders(Map placeholders) { this.placeholders = placeholders != null ? new HashMap<>(placeholders) : null; - return this; + return self(); } /** @@ -200,7 +213,7 @@ public LogBuilderCore placeholders(Map placeholders) { * @param resolvers tag resolvers * @return this builder */ - public LogBuilderCore withResolvers(TagResolver... resolvers) { + public SELF withResolvers(TagResolver... resolvers) { if (resolvers != null) { for (TagResolver resolver : resolvers) { if (resolver != null) { @@ -208,7 +221,7 @@ public LogBuilderCore withResolvers(TagResolver... resolvers) { } } } - return this; + return self(); } /** diff --git a/logger/src/main/java/dev/ua/theroer/magicutils/logger/LogMessageFormatter.java b/logger/src/main/java/dev/ua/theroer/magicutils/logger/LogMessageFormatter.java index 0438001e3..55ccd7737 100644 --- a/logger/src/main/java/dev/ua/theroer/magicutils/logger/LogMessageFormatter.java +++ b/logger/src/main/java/dev/ua/theroer/magicutils/logger/LogMessageFormatter.java @@ -91,8 +91,10 @@ static FormattedMessage formatDetailed( // Build console text: message only (no prefixes — logger name carries that info) String consoleText = processed; - boolean chatNeedsMini = prefixRender.useGradient() || hasMiniMessageTags(chatText) - || hasExternalResolver(externalResolver); + // Chat always goes through MiniMessage: attachPrefix injects the level + // colour (single colour or gradient) into every chat message, so the + // success/warn/error signal is present even when gradients are disabled. + boolean chatNeedsMini = true; boolean consoleNeedsMini = hasMiniMessageTags(consoleText) || hasExternalResolver(externalResolver); Component chatComponent = deserializeComponent(logger, engine, targetAudience, @@ -245,7 +247,7 @@ private static PrefixRender buildPrefixRender(LoggerCore logger, LogLevel level, : (target == LogTarget.CONSOLE || target == LogTarget.BOTH) ? logger.getConsolePrefixMode() : logger.getChatPrefixMode(); - String prefix = buildPrefix(logger, level, mode); + String prefix = buildPrefix(logger, mode); boolean useGradient = !prefix.isEmpty() && shouldUseGradient(logger, target); return new PrefixRender(prefix, useGradient); } @@ -255,12 +257,19 @@ private static String attachPrefix(PrefixRender prefixRender, LoggerCore logger, LogLevel level, LogTarget target) { - if (prefixRender.useGradient()) { - String[] colors = logger.resolveColorsForLevel(level, target == LogTarget.CONSOLE); + // The level colour is the message's signal of success/warn/error, so it + // is applied whether or not gradients are enabled. useGradient() only + // decides between a two-stop gradient and a single solid colour. + String[] colors = logger.resolveColorsForLevel(level, target == LogTarget.CONSOLE); + if (colors == null || colors.length == 0) { + return "" + combined; + } + if (prefixRender.useGradient() && colors.length >= 2) { String gradientTag = ColorUtils.createGradientTag(colors); return "" + gradientTag + combined + ""; } - return "" + combined; + String color = colors[0]; + return "<" + color + ">" + combined + ""; } private static String combinePrefix(String prefix, String message) { @@ -291,7 +300,7 @@ private record PrefixRender(String text, boolean useGradient) { static record FormattedMessage(Component chatComponent, Component consoleComponent, String prefixText) { } - private static String buildPrefix(LoggerCore logger, LogLevel level, PrefixMode mode) { + private static String buildPrefix(LoggerCore logger, PrefixMode mode) { if (mode == PrefixMode.NONE) { return ""; } @@ -304,9 +313,11 @@ private static String buildPrefix(LoggerCore logger, LogLevel level, PrefixMode default -> ""; }; - if (level != LogLevel.INFO) { - prefixText = prefixText + " " + level.name(); - } + // The prefix is rendered into the chat message only (the console line + // carries the level via the platform logger already). Players do not + // need the log level spelled out as "[Plugin SUCCESS]" — the level is + // conveyed by the message colour instead, so keep the chat prefix to the + // plugin brand only. return "[" + prefixText + "]"; } diff --git a/logger/src/main/java/dev/ua/theroer/magicutils/logger/LoggerAdapter.java b/logger/src/main/java/dev/ua/theroer/magicutils/logger/LoggerAdapter.java index 8d93b1d4d..a8b919ded 100644 --- a/logger/src/main/java/dev/ua/theroer/magicutils/logger/LoggerAdapter.java +++ b/logger/src/main/java/dev/ua/theroer/magicutils/logger/LoggerAdapter.java @@ -93,6 +93,17 @@ default void setExternalPlaceholderEngine(ExternalPlaceholderEngine engine) { getCore().setExternalPlaceholderEngine(engine); } + /** + * Controls whether external placeholders are escaped before MiniMessage + * parsing. Disabled by default; enable it when placeholder values may + * contain untrusted MiniMessage-like input. + * + * @param escapePlaceholders true to escape placeholder output + */ + default void setEscapePlaceholders(boolean escapePlaceholders) { + getCore().setEscapePlaceholders(escapePlaceholders); + } + /** * Returns language manager used for localization. * diff --git a/logger/src/main/java/dev/ua/theroer/magicutils/logger/LoggerCore.java b/logger/src/main/java/dev/ua/theroer/magicutils/logger/LoggerCore.java index 0f96cdc24..a77ec3a9f 100644 --- a/logger/src/main/java/dev/ua/theroer/magicutils/logger/LoggerCore.java +++ b/logger/src/main/java/dev/ua/theroer/magicutils/logger/LoggerCore.java @@ -29,7 +29,6 @@ * Platform-agnostic logger core with configurable formatting and placeholders. */ @LogMethods(staticMethods = false, audienceType = "dev.ua.theroer.magicutils.platform.Audience") -@SuppressWarnings("doclint:missing") public class LoggerCore extends LoggerCoreMethods { private static final String LOGGER_DIR_PLACEHOLDER = "logger_dir"; private static final String LOGGER_DIR_DEFAULT = "."; diff --git a/logger/src/test/java/dev/ua/theroer/magicutils/logger/LoggerCoreTest.java b/logger/src/test/java/dev/ua/theroer/magicutils/logger/LoggerCoreTest.java index b8b3becdf..41a0b0cde 100644 --- a/logger/src/test/java/dev/ua/theroer/magicutils/logger/LoggerCoreTest.java +++ b/logger/src/test/java/dev/ua/theroer/magicutils/logger/LoggerCoreTest.java @@ -111,6 +111,84 @@ void structuredConsoleAudienceKeepsLevelAndSubLoggerWithoutPlainTextParsing() th } } + @Test + void chatPrefixCarriesBrandButNotTheLogLevel() throws Exception { + TestPlatform platform = new TestPlatform(tempDir); + ConfigManager configManager = new ConfigManager(platform); + try { + LoggerCore core = new LoggerCore(platform, configManager, new Object(), "TestPlugin"); + configManager.shutdown(); + + // Chat defaults to FULL, so the brand appears; the level must not. + for (LogLevel level : new LogLevel[] {LogLevel.SUCCESS, LogLevel.WARN, LogLevel.ERROR, LogLevel.INFO}) { + LogMessageFormatter.FormattedMessage formatted = LogMessageFormatter.formatDetailed( + core, "hello", level, LogTarget.CHAT, null, null, null); + String chat = PlainTextComponentSerializer.plainText().serialize(formatted.chatComponent()); + + assertTrue(chat.contains("[TestPlugin]"), + "Chat prefix should keep the plugin brand for " + level + ", was: " + chat); + assertTrue(!chat.contains(level.name()), + "Chat prefix should not spell out the log level " + level + ", was: " + chat); + assertTrue(chat.contains("hello"), "Chat message content should be present for " + level); + } + } finally { + configManager.shutdown(); + platform.shutdown(); + } + } + + @Test + void chatCarriesLevelColourEvenWithGradientsDisabled() throws Exception { + TestPlatform platform = new TestPlatform(tempDir); + ConfigManager configManager = new ConfigManager(platform); + try { + LoggerCore core = new LoggerCore(platform, configManager, new Object(), "TestPlugin"); + configManager.shutdown(); + setChatGradient(core, false); + + String success = chatDownsampledColour(core, LogLevel.SUCCESS); + String error = chatDownsampledColour(core, LogLevel.ERROR); + + // With gradients off, a single solid level colour must still be applied, + // and SUCCESS must not read as ERROR. + assertNotNull(success, "SUCCESS chat message should carry a colour"); + assertNotNull(error, "ERROR chat message should carry a colour"); + assertTrue(!success.equals(error), + "SUCCESS and ERROR chat messages should not share the same colour"); + } finally { + configManager.shutdown(); + platform.shutdown(); + } + } + + private static String chatDownsampledColour(LoggerCore core, LogLevel level) { + LogMessageFormatter.FormattedMessage formatted = LogMessageFormatter.formatDetailed( + core, "hello", level, LogTarget.CHAT, null, null, null); + return findFirstColour(formatted.chatComponent()); + } + + private static String findFirstColour(Component component) { + if (component.color() != null) { + return component.color().asHexString(); + } + for (Component child : component.children()) { + String childColour = findFirstColour(child); + if (childColour != null) { + return childColour; + } + } + return null; + } + + private static void setChatGradient(LoggerCore core, boolean enabled) throws Exception { + Field prefixField = core.getConfig().getClass().getDeclaredField("prefix"); + prefixField.setAccessible(true); + Object prefixSettings = prefixField.get(core.getConfig()); + Field gradientField = prefixSettings.getClass().getDeclaredField("useGradientChat"); + gradientField.setAccessible(true); + gradientField.setBoolean(prefixSettings, enabled); + } + private static void setDebugPlaceholders(LoggerCore core, boolean enabled) throws Exception { Field field = core.getConfig().getClass().getDeclaredField("debugPlaceholders"); field.setAccessible(true); diff --git a/messaging/build.gradle.kts b/messaging/build.gradle.kts new file mode 100644 index 000000000..3b09c2bb2 --- /dev/null +++ b/messaging/build.gradle.kts @@ -0,0 +1,28 @@ +import dev.ua.theroer.magicutils.build.target.* + +plugins { + id("magicutils.java-library") + id("magicutils.annotation-processing") + id("magicutils.publishing") +} + +magicutilsPublish { + category = MagicUtilsPublishCategory.COMMON_MATRIX +} + +dependencies { + api(project(":platform-api")) + api(project(":config")) + implementation(libs.jackson.databind) + // Redis is an optional transport: RedisMessageTransport is only class-loaded + // when Redis is enabled, so Jedis stays compile-only here. Bundles that ship + // the Redis transport add jedis to their runtime classpath (and shade it). + compileOnly(libs.jedis) + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.projectlombok.lombok) + annotationProcessor(libs.projectlombok.lombok) + testImplementation(libs.junit.jupiter) + testImplementation(libs.jedis) + testRuntimeOnly(libs.junit.platform.launcher) + testRuntimeOnly(project(":config-yaml")) +} diff --git a/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/IncomingMessage.java b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/IncomingMessage.java new file mode 100644 index 000000000..51efb5009 --- /dev/null +++ b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/IncomingMessage.java @@ -0,0 +1,73 @@ +package dev.ua.theroer.magicutils.messaging; + +/** + * A received message as seen by a typed subscriber. + * + *

Lazily decodes the payload into the subscriber's requested type on first + * access to {@link #payload()}, so a handler that only inspects the + * {@link #source() source} never pays the deserialization cost.

+ * + * @param payload type + */ +public final class IncomingMessage { + private final MessageEnvelope envelope; + private final MessageCodec codec; + private final Class type; + private boolean decoded; + private T payload; + + IncomingMessage(MessageEnvelope envelope, MessageCodec codec, Class type) { + this.envelope = envelope; + this.codec = codec; + this.type = type; + } + + /** + * Returns the channel this message arrived on. + * + * @return channel name + */ + public String channel() { + return envelope.channel(); + } + + /** + * Returns the member that sent this message. + * + * @return originating source + */ + public MessageSource source() { + return envelope.source(); + } + + /** + * Returns the addressing hint the sender used. + * + * @return target + */ + public Target target() { + return envelope.target(); + } + + /** + * Returns the underlying envelope for advanced use. + * + * @return envelope + */ + public MessageEnvelope envelope() { + return envelope; + } + + /** + * Returns the decoded payload, deserializing on first access. + * + * @return decoded payload (may be null for empty payloads) + */ + public T payload() { + if (!decoded) { + payload = codec.decodePayload(envelope.payload(), type); + decoded = true; + } + return payload; + } +} diff --git a/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/LoopbackTransport.java b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/LoopbackTransport.java new file mode 100644 index 000000000..6d6b10e60 --- /dev/null +++ b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/LoopbackTransport.java @@ -0,0 +1,63 @@ +package dev.ua.theroer.magicutils.messaging; + +import org.jetbrains.annotations.Nullable; + +/** + * In-process transport that delivers published envelopes straight back to the + * local sink. + * + *

Useful as a no-network default (single server), and as a test double. Since + * {@link MessageEnvelope#acceptedBy} rejects a member's own messages, a lone + * member never sees its own traffic; wire two loopback transports together via + * {@link #link(LoopbackTransport)} to simulate two members in a unit test.

+ */ +public final class LoopbackTransport implements MessageTransport { + private volatile EnvelopeSink sink; + private volatile @Nullable LoopbackTransport peer; + + /** + * Creates an unlinked loopback transport. + */ + public LoopbackTransport() { + } + + @Override + public String name() { + return "loopback"; + } + + @Override + public void start(EnvelopeSink sink) { + this.sink = sink; + } + + /** + * Links this transport to a peer so published envelopes are delivered to it, + * emulating a two-member network in-process. + * + * @param other the peer transport + */ + public void link(LoopbackTransport other) { + this.peer = other; + other.peer = this; + } + + @Override + public void publish(MessageEnvelope envelope) { + LoopbackTransport target = peer; + EnvelopeSink localSink = sink; + if (target != null && target.sink != null) { + target.sink.accept(envelope); + } + // Deliver to self too; the bus filters out own-origin messages. + if (localSink != null) { + localSink.accept(envelope); + } + } + + @Override + public void close() { + sink = null; + peer = null; + } +} diff --git a/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageBus.java b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageBus.java new file mode 100644 index 000000000..009d5550b --- /dev/null +++ b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageBus.java @@ -0,0 +1,279 @@ +package dev.ua.theroer.magicutils.messaging; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Predicate; + +import dev.ua.theroer.magicutils.platform.ListenerSubscription; +import dev.ua.theroer.magicutils.platform.PlatformLogger; +import org.jetbrains.annotations.Nullable; + +/** + * Typed cross-server publish/subscribe bus for MagicUtils plugins. + * + *

Wraps a pluggable {@link MessageTransport} (the default per-platform + * plugin-messaging transport, or Redis) with a channel-oriented, typed API: + * {@link #subscribe(String, Class, MessageHandler)} and + * {@link #publish(Target, String, Object)}. Payloads are serialized with a shared + * {@link MessageCodec}. The bus applies {@link Target} addressing on receipt so + * routing behaves identically regardless of which transport is in use.

+ * + *

The bus is {@link AutoCloseable} and is meant to be registered with a + * {@code MagicRuntime} so it is torn down with the plugin.

+ */ +public final class MessageBus implements AutoCloseable { + private final MessageSource self; + private final MessageTransport transport; + private final MessageCodec codec; + private final PlatformLogger logger; + private final Map>> subscriptions = new ConcurrentHashMap<>(); + private volatile @Nullable Predicate hostsPlayer; + private volatile boolean closed; + + private MessageBus(Builder builder) { + this.self = builder.self; + this.transport = builder.transport; + this.codec = builder.codec != null ? builder.codec : new MessageCodec(); + this.logger = builder.logger; + this.hostsPlayer = builder.hostsPlayer; + this.transport.start(this::dispatch); + } + + /** + * Creates a bus builder. + * + * @param self this member's identity + * @param transport underlying transport + * @return builder + */ + public static Builder builder(MessageSource self, MessageTransport transport) { + return new Builder(self, transport); + } + + /** + * Returns this member's identity. + * + * @return self source + */ + public MessageSource self() { + return self; + } + + /** + * Returns the underlying transport. + * + * @return transport + */ + public MessageTransport transport() { + return transport; + } + + /** + * Returns the codec used for payload serialization. + * + * @return message codec + */ + public MessageCodec codec() { + return codec; + } + + /** + * Sets the predicate answering whether this member hosts a given player id. + * + *

Used to honour {@link Target#player(UUID)} on backends. Ignored on the + * proxy. May be updated at any time (for example wired to the online-player + * registry).

+ * + * @param hostsPlayer predicate, or null to disable player-target delivery + */ + public void hostsPlayer(@Nullable Predicate hostsPlayer) { + this.hostsPlayer = hostsPlayer; + } + + /** + * Publishes a payload on a channel to the given target. + * + * @param target addressing hint + * @param channel logical channel name + * @param payload payload object (serialized with the shared codec; may be null) + */ + public void publish(Target target, String channel, @Nullable Object payload) { + if (closed) { + throw new IllegalStateException("MessageBus is closed"); + } + byte[] bytes = codec.encodePayload(payload); + MessageEnvelope envelope = MessageEnvelope.create(channel, target, self, bytes); + try { + transport.publish(envelope); + } catch (RuntimeException error) { + logger.warn("Failed to publish message on channel '" + channel + "'", error); + throw error; + } + } + + /** + * Broadcasts a payload on a channel to every subscriber on the network. + * + * @param channel logical channel name + * @param payload payload object + */ + public void broadcast(String channel, @Nullable Object payload) { + publish(Target.broadcast(), channel, payload); + } + + /** + * Subscribes a typed handler to a channel. + * + * @param channel logical channel name + * @param type payload type + * @param handler message handler + * @param payload type + * @return subscription handle; close it to unsubscribe + */ + public ListenerSubscription subscribe(String channel, Class type, MessageHandler handler) { + Subscription subscription = new Subscription<>(channel, type, handler); + subscriptions.computeIfAbsent(channel, key -> new CopyOnWriteArrayList<>()).add(subscription); + return () -> { + CopyOnWriteArrayList> list = subscriptions.get(channel); + if (list != null) { + list.remove(subscription); + if (list.isEmpty()) { + subscriptions.remove(channel, list); + } + } + }; + } + + private void dispatch(MessageEnvelope envelope) { + if (closed) { + return; + } + if (!envelope.acceptedBy(self, hostsPlayer)) { + return; + } + List> handlers = subscriptions.get(envelope.channel()); + if (handlers == null || handlers.isEmpty()) { + return; + } + for (Subscription subscription : handlers) { + deliver(subscription, envelope); + } + } + + private void deliver(Subscription subscription, MessageEnvelope envelope) { + try { + IncomingMessage message = new IncomingMessage<>(envelope, codec, subscription.type); + subscription.handler.onMessage(message); + } catch (RuntimeException error) { + logger.warn("Messaging handler for channel '" + envelope.channel() + "' threw", error); + } + } + + @Override + public void close() { + if (closed) { + return; + } + closed = true; + subscriptions.clear(); + try { + transport.close(); + } catch (Exception error) { + logger.warn("Failed to close messaging transport '" + transport.name() + "'", error); + } + } + + private record Subscription(String channel, Class type, MessageHandler handler) { + } + + /** + * Builder for {@link MessageBus}. + */ + public static final class Builder { + private final MessageSource self; + private final MessageTransport transport; + private @Nullable MessageCodec codec; + private PlatformLogger logger = NoopLogger.INSTANCE; + private @Nullable Predicate hostsPlayer; + + private Builder(MessageSource self, MessageTransport transport) { + this.self = java.util.Objects.requireNonNull(self, "self"); + this.transport = java.util.Objects.requireNonNull(transport, "transport"); + } + + /** + * Overrides the codec (and thus the Jackson mapper) used for payloads. + * + * @param codec message codec + * @return builder + */ + public Builder codec(MessageCodec codec) { + this.codec = codec; + return this; + } + + /** + * Sets the logger for transport/handler failures. + * + * @param logger platform logger + * @return builder + */ + public Builder logger(PlatformLogger logger) { + if (logger != null) { + this.logger = logger; + } + return this; + } + + /** + * Sets the initial player-hosting predicate for {@link Target#player(UUID)} delivery. + * + * @param hostsPlayer predicate, or null + * @return builder + */ + public Builder hostsPlayer(@Nullable Predicate hostsPlayer) { + this.hostsPlayer = hostsPlayer; + return this; + } + + /** + * Builds and starts the bus. + * + * @return message bus + */ + public MessageBus build() { + return new MessageBus(this); + } + } + + private enum NoopLogger implements PlatformLogger { + INSTANCE; + + @Override + public void info(String message) { + } + + @Override + public void warn(String message) { + } + + @Override + public void warn(String message, Throwable throwable) { + } + + @Override + public void error(String message) { + } + + @Override + public void error(String message, Throwable throwable) { + } + + @Override + public void debug(String message) { + } + } +} diff --git a/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageCodec.java b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageCodec.java new file mode 100644 index 000000000..b98c2ac3a --- /dev/null +++ b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageCodec.java @@ -0,0 +1,163 @@ +package dev.ua.theroer.magicutils.messaging; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.UUID; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * Serializes {@link MessageEnvelope} instances and user payloads to/from bytes. + * + *

The envelope header is encoded as compact JSON; the user payload is encoded + * separately by {@link #encodePayload(Object)} and carried as opaque bytes inside + * the envelope. This keeps the transport free of any knowledge of user types and + * lets a single codec instance be shared across transports.

+ */ +public final class MessageCodec { + private final ObjectMapper mapper; + + /** + * Creates a codec with a default {@link ObjectMapper}. + */ + public MessageCodec() { + this(new ObjectMapper()); + } + + /** + * Creates a codec backed by the given mapper. + * + * @param mapper Jackson mapper used for both envelope and payloads + */ + public MessageCodec(ObjectMapper mapper) { + this.mapper = mapper; + } + + /** + * Returns the underlying mapper (for advanced payload handling). + * + * @return object mapper + */ + public ObjectMapper mapper() { + return mapper; + } + + /** + * Serializes a user payload object to bytes. + * + * @param payload payload object (may be null → empty bytes) + * @return serialized payload bytes + */ + public byte[] encodePayload(Object payload) { + if (payload == null) { + return new byte[0]; + } + try { + return mapper.writeValueAsBytes(payload); + } catch (IOException error) { + throw new UncheckedIOException("Failed to serialize messaging payload", error); + } + } + + /** + * Deserializes payload bytes into the requested type. + * + * @param payload serialized payload bytes + * @param type target type + * @param payload type + * @return deserialized payload, or null when the bytes are empty + */ + public T decodePayload(byte[] payload, Class type) { + if (payload == null || payload.length == 0) { + return null; + } + try { + return mapper.readValue(payload, type); + } catch (IOException error) { + throw new UncheckedIOException("Failed to deserialize messaging payload", error); + } + } + + /** + * Serializes a full envelope (header + embedded payload) to bytes. + * + * @param envelope envelope to encode + * @return serialized envelope bytes + */ + public byte[] encode(MessageEnvelope envelope) { + ObjectNode root = mapper.createObjectNode(); + root.put("id", envelope.id().toString()); + root.put("channel", envelope.channel()); + root.put("ts", envelope.timestamp()); + + ObjectNode target = root.putObject("target"); + target.put("kind", envelope.target().kind().name()); + if (envelope.target().serverName() != null) { + target.put("server", envelope.target().serverName()); + } + if (envelope.target().playerId() != null) { + target.put("player", envelope.target().playerId().toString()); + } + + ObjectNode source = root.putObject("source"); + source.put("id", envelope.source().id()); + source.put("type", envelope.source().type().name()); + if (envelope.source().serverName() != null) { + source.put("server", envelope.source().serverName()); + } + + root.put("payload", envelope.payload()); + try { + return mapper.writeValueAsBytes(root); + } catch (IOException error) { + throw new UncheckedIOException("Failed to serialize messaging envelope", error); + } + } + + /** + * Deserializes envelope bytes back into an {@link MessageEnvelope}. + * + * @param bytes serialized envelope bytes + * @return decoded envelope + */ + public MessageEnvelope decode(byte[] bytes) { + try { + JsonNode root = mapper.readTree(bytes); + UUID id = UUID.fromString(root.path("id").asText()); + String channel = root.path("channel").asText(); + long ts = root.path("ts").asLong(); + + JsonNode targetNode = root.path("target"); + Target target = decodeTarget(targetNode); + + JsonNode sourceNode = root.path("source"); + MessageSource.Type type = MessageSource.Type.valueOf(sourceNode.path("type").asText()); + String sourceId = sourceNode.path("id").asText(); + String sourceServer = sourceNode.hasNonNull("server") ? sourceNode.get("server").asText() : null; + MessageSource source = type == MessageSource.Type.PROXY + ? MessageSource.proxy(sourceId) + : MessageSource.backend(sourceId, sourceServer); + + byte[] payload = root.path("payload").binaryValue(); + if (payload == null) { + payload = new byte[0]; + } + return new MessageEnvelope(id, channel, target, source, payload, ts); + } catch (IOException error) { + throw new UncheckedIOException("Failed to deserialize messaging envelope", error); + } + } + + private static Target decodeTarget(JsonNode targetNode) { + Target.Kind kind = Target.Kind.valueOf(targetNode.path("kind").asText()); + return switch (kind) { + case BROADCAST -> Target.broadcast(); + case ALL_BACKENDS -> Target.allBackends(); + case PROXY -> Target.proxy(); + case SERVER -> Target.server(targetNode.path("server").asText()); + case PLAYER -> Target.player(UUID.fromString(targetNode.path("player").asText())); + }; + } +} diff --git a/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageEnvelope.java b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageEnvelope.java new file mode 100644 index 000000000..15383f084 --- /dev/null +++ b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageEnvelope.java @@ -0,0 +1,144 @@ +package dev.ua.theroer.magicutils.messaging; + +import java.util.Objects; +import java.util.UUID; + +import org.jetbrains.annotations.Nullable; + +/** + * Wire representation of a single message on the bus. + * + *

The envelope is transport-neutral: both the default plugin-messaging + * transport and the Redis transport serialize it to bytes (via + * {@link MessageCodec}) and reconstruct it on the receiving side. It carries the + * logical {@code channel}, the {@link Target addressing hint}, the originating + * {@link MessageSource}, and the already-serialized {@code payload} bytes so the + * transport never needs to understand the user payload type.

+ */ +public final class MessageEnvelope { + private final UUID id; + private final String channel; + private final Target target; + private final MessageSource source; + private final byte[] payload; + private final long timestamp; + + /** + * Creates an envelope. + * + * @param id unique message id (for de-duplication / correlation) + * @param channel logical channel name + * @param target addressing hint + * @param source originating member + * @param payload serialized payload bytes + * @param timestamp epoch millis when the message was created + */ + public MessageEnvelope(UUID id, String channel, Target target, MessageSource source, + byte[] payload, long timestamp) { + this.id = Objects.requireNonNull(id, "id"); + this.channel = Objects.requireNonNull(channel, "channel"); + this.target = Objects.requireNonNull(target, "target"); + this.source = Objects.requireNonNull(source, "source"); + this.payload = Objects.requireNonNull(payload, "payload"); + this.timestamp = timestamp; + } + + /** + * Creates an envelope with a fresh id and the current timestamp. + * + * @param channel logical channel name + * @param target addressing hint + * @param source originating member + * @param payload serialized payload bytes + * @return new envelope + */ + public static MessageEnvelope create(String channel, Target target, MessageSource source, byte[] payload) { + return new MessageEnvelope(UUID.randomUUID(), channel, target, source, payload, System.currentTimeMillis()); + } + + /** + * Returns the unique message id. + * + * @return message id + */ + public UUID id() { + return id; + } + + /** + * Returns the logical channel name. + * + * @return channel name + */ + public String channel() { + return channel; + } + + /** + * Returns the addressing hint. + * + * @return target + */ + public Target target() { + return target; + } + + /** + * Returns the originating member. + * + * @return source + */ + public MessageSource source() { + return source; + } + + /** + * Returns the serialized payload bytes. + * + * @return payload bytes (do not mutate) + */ + public byte[] payload() { + return payload; + } + + /** + * Returns the creation timestamp (epoch millis). + * + * @return timestamp + */ + public long timestamp() { + return timestamp; + } + + /** + * Decides whether a local member should accept this envelope given the target. + * + *

Transports that cannot pre-filter (for example Redis pub/sub, which + * broadcasts to every subscriber) call this on receipt so addressing is honoured + * uniformly regardless of transport.

+ * + * @param self the local member + * @param hostsPlayer predicate answering whether the local member hosts a player id; + * may be null on the proxy or when player hosting is unknown + * @return true when the local member is an intended recipient + */ + public boolean acceptedBy(MessageSource self, @Nullable java.util.function.Predicate hostsPlayer) { + Objects.requireNonNull(self, "self"); + // Never deliver a message back to its own originator. + if (self.id().equals(source.id())) { + return false; + } + return switch (target.kind()) { + case BROADCAST -> true; + case PROXY -> self.isProxy(); + case ALL_BACKENDS -> !self.isProxy(); + case SERVER -> !self.isProxy() + && target.serverName() != null + && target.serverName().equalsIgnoreCase(self.serverName()); + case PLAYER -> !self.isProxy() + && hostsPlayer != null + && target.playerId() != null + && hostsPlayer.test(target.playerId()); + }; + } +} diff --git a/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageHandler.java b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageHandler.java new file mode 100644 index 000000000..9bd80f7a5 --- /dev/null +++ b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageHandler.java @@ -0,0 +1,16 @@ +package dev.ua.theroer.magicutils.messaging; + +/** + * Callback invoked for each message received on a subscribed channel. + * + * @param payload type + */ +@FunctionalInterface +public interface MessageHandler { + /** + * Handles a received message. + * + * @param message received message with lazy payload decoding + */ + void onMessage(IncomingMessage message); +} diff --git a/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageSource.java b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageSource.java new file mode 100644 index 000000000..665c37227 --- /dev/null +++ b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageSource.java @@ -0,0 +1,114 @@ +package dev.ua.theroer.magicutils.messaging; + +import java.util.Objects; + +import org.jetbrains.annotations.Nullable; + +/** + * Identifies a member of the network for message origin and routing. + * + *

A source has a stable {@link #id() id} (unique per running process) and a + * {@link #type() type} distinguishing the proxy from backend servers. Backends + * additionally carry a {@link #serverName() server name} as registered with the + * proxy, which the proxy uses to route {@link Target#server(String)} messages.

+ */ +public final class MessageSource { + /** + * Whether a network member is a proxy or a backend server. + */ + public enum Type { + /** A proxy (Velocity or BungeeCord). */ + PROXY, + /** A backend Minecraft server. */ + BACKEND + } + + private final String id; + private final Type type; + private final @Nullable String serverName; + + private MessageSource(String id, Type type, @Nullable String serverName) { + this.id = Objects.requireNonNull(id, "id"); + this.type = Objects.requireNonNull(type, "type"); + this.serverName = serverName; + } + + /** + * Creates a proxy source. + * + * @param id unique process id + * @return proxy source + */ + public static MessageSource proxy(String id) { + return new MessageSource(id, Type.PROXY, null); + } + + /** + * Creates a backend source. + * + * @param id unique process id + * @param serverName server name registered with the proxy (may be null when unknown) + * @return backend source + */ + public static MessageSource backend(String id, @Nullable String serverName) { + return new MessageSource(id, Type.BACKEND, serverName); + } + + /** + * Returns the unique process id of this member. + * + * @return member id + */ + public String id() { + return id; + } + + /** + * Returns the member type. + * + * @return member type + */ + public Type type() { + return type; + } + + /** + * Returns the backend server name, when known. + * + * @return server name, or null for proxies / unknown backends + */ + public @Nullable String serverName() { + return serverName; + } + + /** + * Returns whether this source is a proxy. + * + * @return true when a proxy + */ + public boolean isProxy() { + return type == Type.PROXY; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof MessageSource that)) { + return false; + } + return id.equals(that.id) && type == that.type && Objects.equals(serverName, that.serverName); + } + + @Override + public int hashCode() { + return Objects.hash(id, type, serverName); + } + + @Override + public String toString() { + return "MessageSource[" + type + ":" + id + + (serverName != null ? "@" + serverName : "") + "]"; + } +} diff --git a/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageTransport.java b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageTransport.java new file mode 100644 index 000000000..84dbe0186 --- /dev/null +++ b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageTransport.java @@ -0,0 +1,72 @@ +package dev.ua.theroer.magicutils.messaging; + +/** + * Low-level transport that moves encoded envelopes between network members. + * + *

A transport knows nothing about payload types or channels beyond routing: + * it accepts an already-encoded {@link MessageEnvelope} on {@link #publish}, + * and pushes decoded envelopes it receives to the {@link EnvelopeSink} supplied + * at {@link #start}. The two shipped implementations are the default + * plugin-messaging transport (per platform) and the Redis pub/sub transport; + * both are interchangeable behind this SPI, so a network can run with or without + * Redis.

+ */ +public interface MessageTransport extends AutoCloseable { + /** + * Receives envelopes arriving from the network. + */ + @FunctionalInterface + interface EnvelopeSink { + /** + * Accepts a received envelope for local dispatch. + * + * @param envelope decoded envelope + */ + void accept(MessageEnvelope envelope); + } + + /** + * Human-readable transport name for diagnostics/logging (for example "plugin-messaging", "redis"). + * + * @return transport name + */ + String name(); + + /** + * Starts the transport and begins delivering received envelopes to the sink. + * + *

Called once before any {@link #publish}. Implementations that need a + * background subscriber thread or channel registration set it up here.

+ * + * @param sink destination for received envelopes + */ + void start(EnvelopeSink sink); + + /** + * Publishes an encoded envelope to the network. + * + *

The envelope's {@link Target} is advisory; transports that cannot route + * precisely broadcast and rely on receiver-side filtering.

+ * + * @param envelope envelope to send + */ + void publish(MessageEnvelope envelope); + + /** + * Returns whether the transport is currently connected/operational. + * + *

The default plugin-messaging transport reports connectivity based on + * whether the platform channel is registered; Redis reports pool health.

+ * + * @return true when the transport can currently deliver messages + */ + default boolean isConnected() { + return true; + } + + /** + * Stops the transport and releases resources. + */ + @Override + void close(); +} diff --git a/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessagingService.java b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessagingService.java new file mode 100644 index 000000000..91616b073 --- /dev/null +++ b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessagingService.java @@ -0,0 +1,248 @@ +package dev.ua.theroer.magicutils.messaging; + +import java.util.Objects; +import java.util.UUID; +import java.util.function.Predicate; +import java.util.function.Supplier; + +import dev.ua.theroer.magicutils.messaging.redis.RedisConfig; +import dev.ua.theroer.magicutils.messaging.redis.RedisMessageTransport; +import dev.ua.theroer.magicutils.platform.ListenerSubscription; +import dev.ua.theroer.magicutils.platform.PlatformLogger; +import org.jetbrains.annotations.Nullable; + +/** + * High-level entry point for MagicUtils cross-server messaging. + * + *

Owns a {@link MessageBus} over a chosen {@link MessageTransport} and is the + * component registered with a {@code MagicRuntime}. The transport is selected at + * build time: when Redis is enabled in {@link RedisConfig.Redis} a + * {@link RedisMessageTransport} is used; otherwise the platform's default + * plugin-messaging transport is used, so the network runs without Redis.

+ */ +public final class MessagingService implements AutoCloseable { + private final MessageBus bus; + private final String transportName; + + private MessagingService(MessageBus bus, String transportName) { + this.bus = bus; + this.transportName = transportName; + } + + /** + * Creates a service builder. + * + * @param self this member's identity + * @return builder + */ + public static Builder builder(MessageSource self) { + return new Builder(self); + } + + /** + * Returns the underlying bus. + * + * @return message bus + */ + public MessageBus bus() { + return bus; + } + + /** + * Returns the name of the active transport ("redis" or "plugin-messaging" etc.). + * + * @return transport name + */ + public String transportName() { + return transportName; + } + + /** + * Returns whether the active transport is currently connected. + * + * @return true when connected + */ + public boolean isConnected() { + return bus.transport().isConnected(); + } + + /** + * Publishes a payload on a channel to a target. Delegates to the bus. + * + * @param target addressing hint + * @param channel channel name + * @param payload payload object + */ + public void publish(Target target, String channel, @Nullable Object payload) { + bus.publish(target, channel, payload); + } + + /** + * Broadcasts a payload on a channel. Delegates to the bus. + * + * @param channel channel name + * @param payload payload object + */ + public void broadcast(String channel, @Nullable Object payload) { + bus.broadcast(channel, payload); + } + + /** + * Subscribes a typed handler to a channel. Delegates to the bus. + * + * @param channel channel name + * @param type payload type + * @param handler handler + * @param payload type + * @return subscription handle + */ + public ListenerSubscription subscribe(String channel, Class type, MessageHandler handler) { + return bus.subscribe(channel, type, handler); + } + + @Override + public void close() { + bus.close(); + } + + /** + * Builder that wires the transport and bus together. + */ + public static final class Builder { + private final MessageSource self; + private @Nullable RedisConfig.Redis redis; + private @Nullable Supplier defaultTransport; + private @Nullable MessageCodec codec; + private PlatformLogger logger = NoopLogger.INSTANCE; + private @Nullable Predicate hostsPlayer; + + private Builder(MessageSource self) { + this.self = Objects.requireNonNull(self, "self"); + } + + /** + * Supplies Redis settings; when {@code enabled}, the Redis transport is used. + * + * @param redis redis settings (nullable → treated as disabled) + * @return builder + */ + public Builder redis(@Nullable RedisConfig.Redis redis) { + this.redis = redis; + return this; + } + + /** + * Supplies the platform's default (plugin-messaging) transport factory, + * used when Redis is disabled or unavailable. + * + * @param defaultTransport factory for the default transport + * @return builder + */ + public Builder defaultTransport(Supplier defaultTransport) { + this.defaultTransport = defaultTransport; + return this; + } + + /** + * Overrides the codec used for envelope/payload serialization. + * + * @param codec message codec + * @return builder + */ + public Builder codec(MessageCodec codec) { + this.codec = codec; + return this; + } + + /** + * Sets the logger. + * + * @param logger platform logger + * @return builder + */ + public Builder logger(PlatformLogger logger) { + if (logger != null) { + this.logger = logger; + } + return this; + } + + /** + * Sets the player-hosting predicate for {@link Target#player(UUID)} delivery. + * + * @param hostsPlayer predicate + * @return builder + */ + public Builder hostsPlayer(@Nullable Predicate hostsPlayer) { + this.hostsPlayer = hostsPlayer; + return this; + } + + /** + * Builds the service, selecting and starting the transport. + * + * @return messaging service + */ + public MessagingService build() { + MessageCodec resolvedCodec = codec != null ? codec : new MessageCodec(); + MessageTransport transport = resolveTransport(resolvedCodec); + MessageBus bus = MessageBus.builder(self, transport) + .codec(resolvedCodec) + .logger(logger) + .hostsPlayer(hostsPlayer) + .build(); + return new MessagingService(bus, transport.name()); + } + + private MessageTransport resolveTransport(MessageCodec resolvedCodec) { + if (redis != null && redis.isEnabled()) { + try { + return new RedisMessageTransport(redis, resolvedCodec, logger); + } catch (RuntimeException | LinkageError error) { + logger.error("Redis transport requested but could not be initialized; " + + "falling back to the default transport", asThrowable(error)); + } + } + if (defaultTransport != null) { + MessageTransport transport = defaultTransport.get(); + if (transport != null) { + return transport; + } + } + // Last resort: a self-contained loopback so the bus is always usable. + return new LoopbackTransport(); + } + + private static Throwable asThrowable(Object error) { + return error instanceof Throwable throwable ? throwable : new RuntimeException(String.valueOf(error)); + } + } + + private enum NoopLogger implements PlatformLogger { + INSTANCE; + + @Override + public void info(String message) { + } + + @Override + public void warn(String message) { + } + + @Override + public void warn(String message, Throwable throwable) { + } + + @Override + public void error(String message) { + } + + @Override + public void error(String message, Throwable throwable) { + } + + @Override + public void debug(String message) { + } + } +} diff --git a/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/Target.java b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/Target.java new file mode 100644 index 000000000..90e31a5b0 --- /dev/null +++ b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/Target.java @@ -0,0 +1,155 @@ +package dev.ua.theroer.magicutils.messaging; + +import java.util.Objects; +import java.util.UUID; + +import org.jetbrains.annotations.Nullable; + +/** + * Addressing hint describing which network members should receive a message. + * + *

Targets are transport-agnostic. The default plugin-messaging transport and + * the Redis transport interpret each {@link Kind} on a best-effort basis; where a + * transport cannot honour a target precisely it falls back to a broadcast and + * lets receivers filter by {@link #serverName()} / {@link #playerId()}.

+ */ +public final class Target { + /** + * Classifies the intended recipients of a message. + */ + public enum Kind { + /** Deliver to every network member subscribed to the channel. */ + BROADCAST, + /** Deliver to every backend server (proxy excluded). */ + ALL_BACKENDS, + /** Deliver to the proxy only. */ + PROXY, + /** Deliver to a single backend server identified by {@link #serverName()}. */ + SERVER, + /** Deliver to the backend currently hosting {@link #playerId()}. */ + PLAYER + } + + private static final Target BROADCAST = new Target(Kind.BROADCAST, null, null); + private static final Target ALL_BACKENDS = new Target(Kind.ALL_BACKENDS, null, null); + private static final Target PROXY = new Target(Kind.PROXY, null, null); + + private final Kind kind; + private final @Nullable String serverName; + private final @Nullable UUID playerId; + + private Target(Kind kind, @Nullable String serverName, @Nullable UUID playerId) { + this.kind = kind; + this.serverName = serverName; + this.playerId = playerId; + } + + /** + * A message for every subscriber on the channel, on every member. + * + * @return broadcast target + */ + public static Target broadcast() { + return BROADCAST; + } + + /** + * A message for every backend server, excluding the proxy. + * + * @return all-backends target + */ + public static Target allBackends() { + return ALL_BACKENDS; + } + + /** + * A message for the proxy only. + * + * @return proxy target + */ + public static Target proxy() { + return PROXY; + } + + /** + * A message for a single backend server by its registered name. + * + * @param serverName backend server name (as known to the proxy) + * @return server target + */ + public static Target server(String serverName) { + return new Target(Kind.SERVER, requireText(serverName, "serverName"), null); + } + + /** + * A message for whichever backend currently hosts the given player. + * + * @param playerId player UUID + * @return player target + */ + public static Target player(UUID playerId) { + return new Target(Kind.PLAYER, null, Objects.requireNonNull(playerId, "playerId")); + } + + /** + * Returns the target kind. + * + * @return target kind + */ + public Kind kind() { + return kind; + } + + /** + * Returns the destination server name for {@link Kind#SERVER}. + * + * @return server name, or null for other kinds + */ + public @Nullable String serverName() { + return serverName; + } + + /** + * Returns the destination player id for {@link Kind#PLAYER}. + * + * @return player id, or null for other kinds + */ + public @Nullable UUID playerId() { + return playerId; + } + + private static String requireText(String value, String field) { + Objects.requireNonNull(value, field); + if (value.isBlank()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return value; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof Target target)) { + return false; + } + return kind == target.kind + && Objects.equals(serverName, target.serverName) + && Objects.equals(playerId, target.playerId); + } + + @Override + public int hashCode() { + return Objects.hash(kind, serverName, playerId); + } + + @Override + public String toString() { + return switch (kind) { + case SERVER -> "Target[server=" + serverName + "]"; + case PLAYER -> "Target[player=" + playerId + "]"; + default -> "Target[" + kind + "]"; + }; + } +} diff --git a/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/redis/RedisConfig.java b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/redis/RedisConfig.java new file mode 100644 index 000000000..0f3d5ae1c --- /dev/null +++ b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/redis/RedisConfig.java @@ -0,0 +1,83 @@ +package dev.ua.theroer.magicutils.messaging.redis; + +import dev.ua.theroer.magicutils.config.annotations.Comment; +import dev.ua.theroer.magicutils.config.annotations.ConfigFile; +import dev.ua.theroer.magicutils.config.annotations.ConfigReloadable; +import dev.ua.theroer.magicutils.config.annotations.ConfigSection; +import dev.ua.theroer.magicutils.config.annotations.ConfigValue; +import lombok.Data; + +/** + * Configuration for the optional Redis messaging transport. + * + *

Stored in {@code messaging.{ext}}. When {@code enabled} is false the + * messaging service falls back to the default plugin-messaging transport, so a + * network can run entirely without Redis.

+ */ +@ConfigFile("messaging.{ext}") +@ConfigReloadable(sections = { "redis" }) +@Comment("MagicUtils cross-server messaging configuration") +@Data +public class RedisConfig { + /** + * Creates a config instance with default values. + */ + public RedisConfig() { + } + + @ConfigSection("redis") + @Comment("Redis pub/sub transport. When disabled, the default plugin-messaging transport is used instead.") + private Redis redis = new Redis(); + + /** + * Redis connection and channel settings. + */ + @Data + public static class Redis { + /** + * Creates default Redis settings. + */ + public Redis() { + } + + @ConfigValue("enabled") + @Comment("Use Redis for cross-server messaging. If false, plugin messaging is used.") + private boolean enabled = false; + + @ConfigValue("host") + @Comment("Redis host") + private String host = "127.0.0.1"; + + @ConfigValue("port") + @Comment("Redis port") + private int port = 6379; + + @ConfigValue("username") + @Comment("Redis username (ACL); leave blank for legacy AUTH") + private String username = ""; + + @ConfigValue("password") + @Comment("Redis password; leave blank when unauthenticated") + private String password = ""; + + @ConfigValue("database") + @Comment("Redis database index") + private int database = 0; + + @ConfigValue("ssl") + @Comment("Connect using TLS") + private boolean ssl = false; + + @ConfigValue("timeout-millis") + @Comment("Socket/connect timeout in milliseconds") + private int timeoutMillis = 2000; + + @ConfigValue("channel") + @Comment("Redis pub/sub channel all MagicUtils members share") + private String channel = "magicutils:bus"; + + @ConfigValue("pool-max-total") + @Comment("Maximum pooled connections") + private int poolMaxTotal = 8; + } +} diff --git a/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/redis/RedisMessageTransport.java b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/redis/RedisMessageTransport.java new file mode 100644 index 000000000..c91d46181 --- /dev/null +++ b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/redis/RedisMessageTransport.java @@ -0,0 +1,186 @@ +package dev.ua.theroer.magicutils.messaging.redis; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; + +import dev.ua.theroer.magicutils.messaging.MessageCodec; +import dev.ua.theroer.magicutils.messaging.MessageEnvelope; +import dev.ua.theroer.magicutils.messaging.MessageTransport; +import dev.ua.theroer.magicutils.platform.PlatformLogger; + +import redis.clients.jedis.BinaryJedisPubSub; +import redis.clients.jedis.HostAndPort; +import redis.clients.jedis.Jedis; +import redis.clients.jedis.JedisPool; +import redis.clients.jedis.JedisPoolConfig; +import redis.clients.jedis.DefaultJedisClientConfig; + +/** + * Redis pub/sub messaging transport (backed by Jedis). + * + *

Publishing borrows a pooled connection and issues {@code PUBLISH channel bytes}; + * receiving runs a dedicated daemon thread blocked in {@code SUBSCRIBE}, reconnecting + * with backoff when the connection drops. Redis fans a message out to every + * subscriber, so {@link MessageEnvelope} carries the {@link dev.ua.theroer.magicutils.messaging.Target} + * and the bus filters on receipt.

+ * + *

This class references Jedis types directly and is only loaded when the Redis + * transport is selected, keeping Jedis an optional dependency of the messaging + * module.

+ */ +public final class RedisMessageTransport implements MessageTransport { + private final RedisConfig.Redis config; + private final MessageCodec codec; + private final PlatformLogger logger; + private final byte[] channelBytes; + + private final AtomicBoolean running = new AtomicBoolean(false); + private volatile JedisPool pool; + private volatile Subscriber subscriber; + private volatile Thread subscriberThread; + private volatile EnvelopeSink sink; + + /** + * Creates a Redis transport from resolved settings. + * + * @param config redis settings + * @param codec envelope codec + * @param logger platform logger + */ + public RedisMessageTransport(RedisConfig.Redis config, MessageCodec codec, PlatformLogger logger) { + this.config = Objects.requireNonNull(config, "config"); + this.codec = Objects.requireNonNull(codec, "codec"); + this.logger = Objects.requireNonNull(logger, "logger"); + this.channelBytes = config.getChannel().getBytes(java.nio.charset.StandardCharsets.UTF_8); + } + + @Override + public String name() { + return "redis"; + } + + @Override + public void start(EnvelopeSink sink) { + if (!running.compareAndSet(false, true)) { + return; + } + this.sink = Objects.requireNonNull(sink, "sink"); + this.pool = buildPool(); + + Thread thread = new Thread(this::runSubscriber, "MagicUtils-Redis-Subscriber"); + thread.setDaemon(true); + this.subscriberThread = thread; + thread.start(); + } + + @Override + public void publish(MessageEnvelope envelope) { + JedisPool current = pool; + if (!running.get() || current == null) { + throw new IllegalStateException("Redis transport is not running"); + } + byte[] bytes = codec.encode(envelope); + try (Jedis jedis = current.getResource()) { + jedis.publish(channelBytes, bytes); + } + } + + @Override + public boolean isConnected() { + JedisPool current = pool; + return running.get() && current != null && !current.isClosed(); + } + + @Override + public void close() { + if (!running.compareAndSet(true, false)) { + return; + } + Subscriber current = subscriber; + if (current != null && current.isSubscribed()) { + try { + current.unsubscribe(); + } catch (RuntimeException ignored) { + // best effort + } + } + Thread thread = subscriberThread; + if (thread != null) { + thread.interrupt(); + } + JedisPool current2 = pool; + if (current2 != null) { + current2.close(); + } + pool = null; + subscriber = null; + subscriberThread = null; + } + + private JedisPool buildPool() { + JedisPoolConfig poolConfig = new JedisPoolConfig(); + poolConfig.setMaxTotal(Math.max(1, config.getPoolMaxTotal())); + poolConfig.setTestOnBorrow(true); + + DefaultJedisClientConfig.Builder clientConfig = DefaultJedisClientConfig.builder() + .connectionTimeoutMillis(config.getTimeoutMillis()) + .socketTimeoutMillis(config.getTimeoutMillis()) + .database(config.getDatabase()) + .ssl(config.isSsl()); + if (!config.getPassword().isBlank()) { + clientConfig.password(config.getPassword()); + } + if (!config.getUsername().isBlank()) { + clientConfig.user(config.getUsername()); + } + HostAndPort address = new HostAndPort(config.getHost(), config.getPort()); + return new JedisPool(poolConfig, address, clientConfig.build()); + } + + private void runSubscriber() { + long backoffMillis = 500L; + while (running.get()) { + Subscriber current = new Subscriber(); + this.subscriber = current; + try (Jedis jedis = pool.getResource()) { + backoffMillis = 500L; + jedis.subscribe(current, channelBytes); + } catch (RuntimeException error) { + if (!running.get()) { + return; + } + logger.warn("Redis subscriber disconnected, reconnecting in " + backoffMillis + "ms", error); + } + if (!running.get()) { + return; + } + try { + Thread.sleep(backoffMillis); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return; + } + backoffMillis = Math.min(backoffMillis * 2, 10_000L); + } + } + + private void onRedisMessage(byte[] payload) { + EnvelopeSink target = sink; + if (target == null) { + return; + } + try { + MessageEnvelope envelope = codec.decode(payload); + target.accept(envelope); + } catch (RuntimeException error) { + logger.warn("Failed to decode message from Redis", error); + } + } + + private final class Subscriber extends BinaryJedisPubSub { + @Override + public void onMessage(byte[] channel, byte[] message) { + onRedisMessage(message); + } + } +} diff --git a/messaging/src/test/java/dev/ua/theroer/magicutils/messaging/MessageBusTest.java b/messaging/src/test/java/dev/ua/theroer/magicutils/messaging/MessageBusTest.java new file mode 100644 index 000000000..93ea2545f --- /dev/null +++ b/messaging/src/test/java/dev/ua/theroer/magicutils/messaging/MessageBusTest.java @@ -0,0 +1,127 @@ +package dev.ua.theroer.magicutils.messaging; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +class MessageBusTest { + record Greeting(String text) { + } + + /** Wires two buses (a proxy and a backend) over linked loopback transports. */ + private static final class Pair implements AutoCloseable { + final MessageBus proxy; + final MessageBus backend; + + Pair(MessageSource proxySource, MessageSource backendSource) { + LoopbackTransport proxyTransport = new LoopbackTransport(); + LoopbackTransport backendTransport = new LoopbackTransport(); + proxyTransport.link(backendTransport); + this.proxy = MessageBus.builder(proxySource, proxyTransport).build(); + this.backend = MessageBus.builder(backendSource, backendTransport).build(); + } + + @Override + public void close() { + proxy.close(); + backend.close(); + } + } + + @Test + void backendReceivesProxyBroadcast() { + MessageSource proxy = MessageSource.proxy("proxy"); + MessageSource backend = MessageSource.backend("b1", "lobby"); + try (Pair pair = new Pair(proxy, backend)) { + List received = new CopyOnWriteArrayList<>(); + pair.backend.subscribe("greet", Greeting.class, msg -> received.add(msg.payload().text())); + + pair.proxy.broadcast("greet", new Greeting("hi")); + + assertEquals(List.of("hi"), received); + } + } + + @Test + void senderDoesNotReceiveOwnMessage() { + MessageSource proxy = MessageSource.proxy("proxy"); + MessageSource backend = MessageSource.backend("b1", "lobby"); + try (Pair pair = new Pair(proxy, backend)) { + List received = new CopyOnWriteArrayList<>(); + pair.proxy.subscribe("greet", Greeting.class, msg -> received.add(msg.payload().text())); + + pair.proxy.broadcast("greet", new Greeting("echo?")); + + assertTrue(received.isEmpty(), "sender must not receive its own broadcast"); + } + } + + @Test + void proxyTargetIsRejectedByBackend() { + MessageSource proxy = MessageSource.proxy("proxy"); + MessageSource backend = MessageSource.backend("b1", "lobby"); + try (Pair pair = new Pair(proxy, backend)) { + List received = new CopyOnWriteArrayList<>(); + pair.backend.subscribe("c", Greeting.class, msg -> received.add(msg.payload().text())); + + pair.proxy.publish(Target.proxy(), "c", new Greeting("for-proxy")); + + assertTrue(received.isEmpty(), "backend must ignore proxy-targeted messages"); + } + } + + @Test + void serverTargetMatchesByName() { + MessageSource proxy = MessageSource.proxy("proxy"); + MessageSource lobby = MessageSource.backend("b1", "lobby"); + try (Pair pair = new Pair(proxy, lobby)) { + List received = new CopyOnWriteArrayList<>(); + pair.backend.subscribe("c", Greeting.class, msg -> received.add(msg.payload().text())); + + pair.proxy.publish(Target.server("survival"), "c", new Greeting("nope")); + assertTrue(received.isEmpty(), "wrong server name must not match"); + + pair.proxy.publish(Target.server("lobby"), "c", new Greeting("yes")); + assertEquals(List.of("yes"), received); + } + } + + @Test + void playerTargetUsesHostsPredicate() { + MessageSource proxy = MessageSource.proxy("proxy"); + MessageSource backend = MessageSource.backend("b1", "lobby"); + UUID hosted = UUID.randomUUID(); + try (Pair pair = new Pair(proxy, backend)) { + pair.backend.hostsPlayer(hosted::equals); + List received = new CopyOnWriteArrayList<>(); + pair.backend.subscribe("c", Greeting.class, msg -> received.add(msg.payload().text())); + + pair.proxy.publish(Target.player(UUID.randomUUID()), "c", new Greeting("other")); + assertTrue(received.isEmpty(), "non-hosted player must not match"); + + pair.proxy.publish(Target.player(hosted), "c", new Greeting("mine")); + assertEquals(List.of("mine"), received); + } + } + + @Test + void unsubscribeStopsDelivery() { + MessageSource proxy = MessageSource.proxy("proxy"); + MessageSource backend = MessageSource.backend("b1", "lobby"); + try (Pair pair = new Pair(proxy, backend)) { + List received = new CopyOnWriteArrayList<>(); + var handle = pair.backend.subscribe("c", Greeting.class, msg -> received.add(msg.payload().text())); + + pair.proxy.broadcast("c", new Greeting("one")); + handle.close(); + pair.proxy.broadcast("c", new Greeting("two")); + + assertEquals(List.of("one"), received); + } + } +} diff --git a/messaging/src/test/java/dev/ua/theroer/magicutils/messaging/MessageCodecTest.java b/messaging/src/test/java/dev/ua/theroer/magicutils/messaging/MessageCodecTest.java new file mode 100644 index 000000000..116ce3541 --- /dev/null +++ b/messaging/src/test/java/dev/ua/theroer/magicutils/messaging/MessageCodecTest.java @@ -0,0 +1,61 @@ +package dev.ua.theroer.magicutils.messaging; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +class MessageCodecTest { + private final MessageCodec codec = new MessageCodec(); + + @Test + void payloadRoundTrips() { + Sample sample = new Sample("hello", 42); + byte[] bytes = codec.encodePayload(sample); + Sample decoded = codec.decodePayload(bytes, Sample.class); + assertEquals(sample, decoded); + } + + @Test + void nullPayloadEncodesEmptyAndDecodesNull() { + byte[] bytes = codec.encodePayload(null); + assertEquals(0, bytes.length); + assertNull(codec.decodePayload(bytes, Sample.class)); + } + + @Test + void envelopeRoundTripsServerTarget() { + MessageSource source = MessageSource.backend("proc-1", "lobby"); + byte[] payload = codec.encodePayload(new Sample("x", 1)); + MessageEnvelope envelope = MessageEnvelope.create("chan", Target.server("survival"), source, payload); + + MessageEnvelope decoded = codec.decode(codec.encode(envelope)); + + assertEquals(envelope.id(), decoded.id()); + assertEquals("chan", decoded.channel()); + assertEquals(Target.Kind.SERVER, decoded.target().kind()); + assertEquals("survival", decoded.target().serverName()); + assertEquals(MessageSource.Type.BACKEND, decoded.source().type()); + assertEquals("lobby", decoded.source().serverName()); + assertArrayEquals(payload, decoded.payload()); + } + + @Test + void envelopeRoundTripsPlayerTarget() { + UUID player = UUID.randomUUID(); + MessageSource source = MessageSource.proxy("proxy-1"); + MessageEnvelope envelope = MessageEnvelope.create("chan", Target.player(player), source, new byte[0]); + + MessageEnvelope decoded = codec.decode(codec.encode(envelope)); + + assertEquals(Target.Kind.PLAYER, decoded.target().kind()); + assertEquals(player, decoded.target().playerId()); + assertEquals(MessageSource.Type.PROXY, decoded.source().type()); + } + + record Sample(String name, int value) { + } +} diff --git a/platform-bukkit/build.gradle.kts b/platform-bukkit/build.gradle.kts index 75a8a4f9c..f7c348cab 100644 --- a/platform-bukkit/build.gradle.kts +++ b/platform-bukkit/build.gradle.kts @@ -16,6 +16,7 @@ val target = project.extensions.getByType(MagicUtilsTargetExtension::class.java) dependencies { api(project(":core")) api(project(":diagnostics")) + api(project(":messaging")) compileOnly("io.papermc.paper:paper-api:${target.paper.get()}") compileOnly(libs.jetbrains.annotations) compileOnly(libs.projectlombok.lombok) diff --git a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/Logger.java b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/Logger.java index fd10fe9c0..947ff35bf 100644 --- a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/Logger.java +++ b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/Logger.java @@ -24,10 +24,34 @@ import java.util.Map; /** - * Bukkit/Paper logger adapter backed by {@link LoggerCore}. + * Bukkit/Paper logger: the single output surface for a plugin. + * + *

Despite the name, this is not only for console logs. The same level methods + * send to the console (a log line) and to players (a chat message), so one call + * site covers both. Text is authored once with MiniMessage or legacy {@code &} + * codes and rendered correctly on every platform. + * + *

Everyday use is the short level methods; reach for the fluent + * {@link #log()} builder only when you need several recipients, tag resolvers, + * or a per-message prefix override: + * + *

{@code
+ * // Console log line:
+ * logger.info("Ready, %d loaded", count);
+ *
+ * // Message a player (same levels, different target):
+ * logger.info(player, "Teleported");
+ *
+ * // Composite case -> builder:
+ * logger.warn().to(player).toConsole().send("Low on funds");
+ * }
+ * + *

Backed by {@link LoggerCore}; obtain a prefixed sub-logger with + * {@link #create(String)}. + * + * @see LoggerAdapter */ @LogMethods(staticMethods = false, audienceType = "org.bukkit.entity.Player") -@SuppressWarnings("doclint:missing") public final class Logger extends LoggerMethods implements LoggerAdapter { @Getter private final LoggerCore core; @@ -64,30 +88,65 @@ public Audience wrapAudience(Player player) { return wrapAudience((CommandSender) player); } + /** + * Starts an INFO-level fluent {@link LogBuilder}. + * + * @return a new INFO log builder + */ public LogBuilder log() { return new LogBuilder(this, LogLevel.INFO); } + /** + * Starts an INFO-level fluent {@link LogBuilder} with the prefix disabled. + * + * @return a new prefix-less INFO log builder + */ public LogBuilder noPrefix() { return new LogBuilder(this, LogLevel.INFO).noPrefix(); } + /** + * Starts an INFO-level fluent {@link LogBuilder}. + * + * @return a new INFO log builder + */ public LogBuilder info() { return new LogBuilder(this, LogLevel.INFO); } + /** + * Starts a WARN-level fluent {@link LogBuilder}. + * + * @return a new WARN log builder + */ public LogBuilder warn() { return new LogBuilder(this, LogLevel.WARN); } + /** + * Starts an ERROR-level fluent {@link LogBuilder}. + * + * @return a new ERROR log builder + */ public LogBuilder error() { return new LogBuilder(this, LogLevel.ERROR); } + /** + * Starts a DEBUG-level fluent {@link LogBuilder}. + * + * @return a new DEBUG log builder + */ public LogBuilder debug() { return new LogBuilder(this, LogLevel.DEBUG); } + /** + * Starts a SUCCESS-level fluent {@link LogBuilder}. + * + * @return a new SUCCESS log builder + */ public LogBuilder success() { return new LogBuilder(this, LogLevel.SUCCESS); } @@ -117,6 +176,12 @@ protected void sendToPlayers(LogLevel level, Object message, Collection translations; private boolean enableCommands; private String permissionPrefix; private Consumer commandConfigurer; private boolean enableDiagnostics; private Consumer diagnosticsConfigurer; + private boolean enableMessaging; + private RedisConfig.Redis messagingRedis; + private Consumer messagingConfigurer; private Builder(JavaPlugin plugin) { this.plugin = Objects.requireNonNull(plugin, "plugin"); @@ -106,6 +104,28 @@ public Builder languageManager(LanguageManager languageManager) { return this; } + /** + * Applies the recommended language defaults (every flag enabled). This + * is already the initial state; call it to make intent explicit. + * + * @return builder + */ + public Builder withRecommendedDefaults() { + lang.withRecommendedDefaults(); + return this; + } + + /** + * Disables all automatic language/messages wiring for callers that + * manage localization themselves. + * + * @return builder + */ + public Builder minimal() { + lang.minimal(); + return this; + } + /** * Sets the default language code to initialize. * @@ -113,9 +133,7 @@ public Builder languageManager(LanguageManager languageManager) { * @return builder */ public Builder language(String language) { - if (language != null && !language.isBlank()) { - this.language = language; - } + lang.language(language); return this; } @@ -126,7 +144,7 @@ public Builder language(String language) { * @return builder */ public Builder initLanguage(boolean initLanguage) { - this.initLanguage = initLanguage; + lang.initLanguage(initLanguage); return this; } @@ -137,7 +155,7 @@ public Builder initLanguage(boolean initLanguage) { * @return builder */ public Builder bindLoggerLanguage(boolean bindLoggerLanguage) { - this.bindLoggerLanguage = bindLoggerLanguage; + lang.bindLoggerLanguage(bindLoggerLanguage); return this; } @@ -148,7 +166,7 @@ public Builder bindLoggerLanguage(boolean bindLoggerLanguage) { * @return builder */ public Builder setMessagesManager(boolean setMessagesManager) { - this.setMessagesManager = setMessagesManager; + lang.setMessagesManager(setMessagesManager); return this; } @@ -159,7 +177,7 @@ public Builder setMessagesManager(boolean setMessagesManager) { * @return builder */ public Builder registerMessages(boolean registerMessages) { - this.registerMessages = registerMessages; + lang.registerMessages(registerMessages); return this; } @@ -170,7 +188,7 @@ public Builder registerMessages(boolean registerMessages) { * @return builder */ public Builder addMagicUtilsMessages(boolean addMagicUtilsMessages) { - this.addMagicUtilsMessages = addMagicUtilsMessages; + lang.addMagicUtilsMessages(addMagicUtilsMessages); return this; } @@ -181,7 +199,7 @@ public Builder addMagicUtilsMessages(boolean addMagicUtilsMessages) { * @return builder */ public Builder bindClientLocaleSync(boolean bindClientLocaleSync) { - this.bindClientLocaleSync = bindClientLocaleSync; + lang.bindClientLocaleSync(bindClientLocaleSync); return this; } @@ -192,7 +210,7 @@ public Builder bindClientLocaleSync(boolean bindClientLocaleSync) { * @return builder */ public Builder translations(Consumer translations) { - this.translations = translations; + lang.translations(translations); return this; } @@ -249,6 +267,43 @@ public Builder configureDiagnostics(Consumer diagnosticsConf return this; } + /** + * Enables cross-server messaging. Uses the default plugin-messaging + * transport unless Redis settings are supplied via {@link #messagingRedis}. + * + * @return builder + */ + public Builder enableMessaging() { + this.enableMessaging = true; + return this; + } + + /** + * Supplies Redis settings for messaging. When {@code enabled}, the Redis + * transport is used; otherwise the default plugin-messaging transport is. + * + * @param redis redis settings + * @return builder + */ + public Builder messagingRedis(RedisConfig.Redis redis) { + this.messagingRedis = redis; + this.enableMessaging = true; + return this; + } + + /** + * Allows configuring the messaging service builder before it is built. + * + * @param messagingConfigurer messaging builder callback + * @return builder + */ + public Builder configureMessaging( + Consumer messagingConfigurer) { + this.messagingConfigurer = messagingConfigurer; + this.enableMessaging = true; + return this; + } + /** * Builds the bootstrap result and wires requested services. * @@ -277,10 +332,7 @@ public RuntimeResult buildRuntime() { .component(Logger.class, prepared.logger()) .build(); - if (bindClientLocaleSync) { - runtime.manage("language.clientLocaleSync", - prepared.languageManager().bindClientLocaleSync(prepared.platform())); - } + lang.bindClientLocaleSync(runtime, prepared.platform(), prepared.languageManager()); if (prepared.commandRegistry() != null) { runtime.putComponent(CommandRegistry.class, prepared.commandRegistry()); @@ -291,21 +343,15 @@ public RuntimeResult buildRuntime() { if (enableDiagnostics) { DiagnosticsSupport.install(runtime, diagnosticsConfigurer); } + if (enableMessaging) { + BukkitMessagingSupport.install(runtime, plugin, messagingRedis, messagingConfigurer); + } // The registry keeps a live payload supplier, so a single registration // suffices — the bundle re-reads the runtime whenever /magicutils mods // runs, no need to re-push on every state/command change. BukkitMagicUtilsConsumerRegistry.register(plugin, runtime, prepared.commandRegistry()); runtime.onClose("magicutils.consumerRegistry", () -> BukkitMagicUtilsConsumerRegistry.unregister(plugin)); - if (registerMessages) { - runtime.onClose("messages.scope", () -> Messages.unregister(plugin.getName())); - } - if (setMessagesManager) { - runtime.onClose("messages.default", () -> { - if (Messages.getLanguageManager() == prepared.languageManager()) { - Messages.setLanguageManager(null); - } - }); - } + lang.installMessagesCloseHooks(runtime, plugin.getName(), prepared.languageManager()); return new RuntimeResult(runtime, prepared.platform(), prepared.configManager(), prepared.logger(), prepared.languageManager(), prepared.commandRegistry()); @@ -328,29 +374,7 @@ private Prepared prepare() { ? languageManager : new LanguageManager(plugin, resolvedConfigManager); - if (initLanguage) { - resolvedLanguageManager.init(language); - } - - if (translations != null) { - translations.accept(resolvedLanguageManager); - } - - if (addMagicUtilsMessages) { - resolvedLanguageManager.addMagicUtilsMessages(); - } - - if (registerMessages) { - Messages.register(plugin.getName(), resolvedLanguageManager); - } - - if (setMessagesManager) { - Messages.setLanguageManager(resolvedLanguageManager); - } - - if (bindLoggerLanguage) { - resolvedLogger.setLanguageManager(resolvedLanguageManager); - } + lang.apply(plugin.getName(), resolvedLanguageManager, resolvedLogger.getCore()); CommandRegistry registry = null; if (enableCommands) { diff --git a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java index 2169f167d..c418be324 100644 --- a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java +++ b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java @@ -1,43 +1,50 @@ package dev.ua.theroer.magicutils.logger; import dev.ua.theroer.magicutils.Logger; -import dev.ua.theroer.magicutils.platform.Audience; -import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; import java.util.Collection; -import java.util.Map; /** * Bukkit-specific log builder with Player/CommandSender helpers. + * + *

The shared fluent methods ({@code to(Audience)}, {@code target}, + * {@code noPrefix}, ...) are inherited from {@link LogBuilderCore} and already + * return {@code LogBuilder} thanks to its self type; this class only adds the + * Bukkit-typed recipient overloads. */ -@SuppressWarnings("doclint:missing") -public class LogBuilder extends LogBuilderCore { +public class LogBuilder extends LogBuilderCore { private final Logger logger; + /** + * Creates a log builder for the given level. + * + * @param logger parent logger + * @param level log level + */ public LogBuilder(Logger logger, LogLevel level) { super(logger.getCore(), level); this.logger = logger; } - @Override - public LogBuilder to(Audience audience) { - super.to(audience); - return this; - } - - @Override - public LogBuilder target(LogTarget target) { - super.target(target); - return this; - } - + /** + * Sets a player as the direct audience. + * + * @param player target player + * @return this builder + */ public LogBuilder to(Player player) { super.to(logger.wrapAudience(player)); return this; } + /** + * Adds several players as recipients. + * + * @param players target players + * @return this builder + */ public LogBuilder to(Player... players) { if (players != null) { for (Player player : players) { @@ -49,6 +56,12 @@ public LogBuilder to(Player... players) { return this; } + /** + * Adds a collection of players as recipients. + * + * @param players target players + * @return this builder + */ public LogBuilder to(Collection players) { if (players != null) { for (Player player : players) { @@ -60,16 +73,34 @@ public LogBuilder to(Collection players) { return this; } + /** + * Adds a command sender (player or console) as a recipient. + * + * @param sender command sender + * @return this builder + */ public LogBuilder to(CommandSender sender) { super.recipient(logger.wrapAudience(sender)); return this; } + /** + * Adds a command sender as a recipient. + * + * @param sender command sender + * @return this builder + */ public LogBuilder recipient(CommandSender sender) { super.recipient(logger.wrapAudience(sender)); return this; } + /** + * Adds a collection of command senders as recipients. + * + * @param senders command senders + * @return this builder + */ public LogBuilder toSenders(Collection senders) { if (senders != null) { for (CommandSender sender : senders) { @@ -80,52 +111,4 @@ public LogBuilder toSenders(Collection senders) { } return this; } - - @Override - public LogBuilder toAudiences(Collection audiences) { - super.toAudiences(audiences); - return this; - } - - @Override - public LogBuilder toAll() { - super.toAll(); - return this; - } - - @Override - public LogBuilder toConsole() { - super.toConsole(); - return this; - } - - @Override - public LogBuilder noPrefix() { - super.noPrefix(); - return this; - } - - @Override - public LogBuilder prefixMode(PrefixMode mode) { - super.prefixMode(mode); - return this; - } - - @Override - public LogBuilder args(Object... args) { - super.args(args); - return this; - } - - @Override - public LogBuilder placeholders(Map placeholders) { - super.placeholders(placeholders); - return this; - } - - @Override - public LogBuilder withResolvers(TagResolver... resolvers) { - super.withResolvers(resolvers); - return this; - } } diff --git a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/logger/PrefixedLogger.java b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/logger/PrefixedLogger.java index 0893bc96e..c8274a695 100644 --- a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/logger/PrefixedLogger.java +++ b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/logger/PrefixedLogger.java @@ -10,11 +10,16 @@ * All log methods are generated by annotation processor. */ @LogMethods(staticMethods = false, audienceType = "org.bukkit.entity.Player") -@SuppressWarnings("doclint:missing") public class PrefixedLogger extends PrefixedLoggerMethods implements PrefixedLoggerAdapter { private final Logger logger; private final PrefixedLoggerCore core; + /** + * Creates a prefixed logger wrapping a prefixed core. + * + * @param logger parent logger + * @param core prefixed core instance + */ public PrefixedLogger(Logger logger, PrefixedLoggerCore core) { this.logger = logger; this.core = core; diff --git a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/messaging/bukkit/BukkitMessagingSupport.java b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/messaging/bukkit/BukkitMessagingSupport.java new file mode 100644 index 000000000..76f656110 --- /dev/null +++ b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/messaging/bukkit/BukkitMessagingSupport.java @@ -0,0 +1,77 @@ +package dev.ua.theroer.magicutils.messaging.bukkit; + +import java.util.UUID; +import java.util.function.Consumer; + +import dev.ua.theroer.magicutils.bootstrap.MagicRuntime; +import dev.ua.theroer.magicutils.messaging.MessageCodec; +import dev.ua.theroer.magicutils.messaging.MessageSource; +import dev.ua.theroer.magicutils.messaging.MessagingService; +import dev.ua.theroer.magicutils.messaging.redis.RedisConfig; +import dev.ua.theroer.magicutils.platform.Platform; +import dev.ua.theroer.magicutils.platform.PlatformLogger; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; +import org.jetbrains.annotations.Nullable; + +/** + * Wires a {@link MessagingService} into a Bukkit plugin's {@link MagicRuntime}. + * + *

Selects the Redis transport when {@code redis.enabled}, otherwise the + * {@link BukkitPluginMessageTransport default plugin-messaging transport}, so the + * plugin works with or without Redis. The service is registered as a runtime + * component and closed with the runtime.

+ */ +public final class BukkitMessagingSupport { + private BukkitMessagingSupport() { + } + + /** + * Installs the messaging service on the runtime. + * + * @param runtime plugin runtime + * @param plugin owning plugin + * @param redis redis settings, or null to force the default transport + * @param configurer optional callback to tweak the service builder + * @return the installed service + */ + public static MessagingService install( + MagicRuntime runtime, + JavaPlugin plugin, + @Nullable RedisConfig.Redis redis, + @Nullable Consumer configurer) { + Platform platform = runtime.platform(); + PlatformLogger logger = platform.logger(); + + MessageSource self = MessageSource.backend( + plugin.getName() + ":" + UUID.randomUUID(), + null); // server name is not known to a backend until the proxy tells it + + MessagingService.Builder builder = MessagingService.builder(self) + .logger(logger) + .redis(redis) + .defaultTransport(() -> new BukkitPluginMessageTransport(plugin, new MessageCodec(), logger)) + .hostsPlayer(id -> hostsPlayer(platform, id)); + + if (configurer != null) { + configurer.accept(builder); + } + + MessagingService service = builder.build(); + runtime.putComponent(MessagingService.class, service); + runtime.manage("messaging.service", service); + logger.info("MagicUtils messaging enabled (transport: " + service.transportName() + ")"); + return service; + } + + private static boolean hostsPlayer(Platform platform, UUID id) { + if (platform.playerById(id) != null) { + return true; + } + // Fallback for platforms whose audience lookup lags the connection state. + Player player = Bukkit.getPlayer(id); + return player != null && player.isOnline(); + } +} diff --git a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/messaging/bukkit/BukkitPluginMessageTransport.java b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/messaging/bukkit/BukkitPluginMessageTransport.java new file mode 100644 index 000000000..c9dd4a886 --- /dev/null +++ b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/messaging/bukkit/BukkitPluginMessageTransport.java @@ -0,0 +1,162 @@ +package dev.ua.theroer.magicutils.messaging.bukkit; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Objects; + +import dev.ua.theroer.magicutils.messaging.MessageCodec; +import dev.ua.theroer.magicutils.messaging.MessageEnvelope; +import dev.ua.theroer.magicutils.messaging.MessageTransport; +import dev.ua.theroer.magicutils.messaging.Target; +import dev.ua.theroer.magicutils.platform.PlatformLogger; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.plugin.messaging.PluginMessageListener; +import org.jetbrains.annotations.Nullable; + +/** + * Default (Redis-less) messaging transport for Bukkit/Paper backends. + * + *

Rides the proxy's BungeeCord plugin-messaging channel. Backends cannot open + * a socket to the proxy directly, so this transport uses the {@code Forward} + * sub-channel: a backend sends {@code Forward ALL } and the proxy + * relays the raw {@code } to every other backend that listens on + * {@code }. That gives full backend↔backend and backend↔proxy delivery over + * the vanilla mechanism, with no Redis required.

+ * + *

The one native constraint: plugin messages travel through a player + * connection, so at least one player must be online for a backend to reach the + * proxy. When no player is online, outgoing messages are dropped (and logged at + * debug); the Redis transport is the way to remove that constraint.

+ */ +public final class BukkitPluginMessageTransport implements MessageTransport, PluginMessageListener { + /** The vanilla BungeeCord/Velocity plugin channel. */ + static final String BUNGEE_CHANNEL = "BungeeCord"; + /** Our sub-channel carried inside BungeeCord Forward frames. */ + static final String FORWARD_SUBCHANNEL = "magicutils:bus"; + + private final JavaPlugin plugin; + private final MessageCodec codec; + private final PlatformLogger logger; + + private volatile EnvelopeSink sink; + private volatile boolean running; + + /** + * Creates the transport. + * + * @param plugin owning plugin + * @param codec envelope codec + * @param logger platform logger + */ + public BukkitPluginMessageTransport(JavaPlugin plugin, MessageCodec codec, PlatformLogger logger) { + this.plugin = Objects.requireNonNull(plugin, "plugin"); + this.codec = Objects.requireNonNull(codec, "codec"); + this.logger = Objects.requireNonNull(logger, "logger"); + } + + @Override + public String name() { + return "plugin-messaging"; + } + + @Override + public void start(EnvelopeSink sink) { + this.sink = Objects.requireNonNull(sink, "sink"); + Bukkit.getMessenger().registerOutgoingPluginChannel(plugin, BUNGEE_CHANNEL); + Bukkit.getMessenger().registerIncomingPluginChannel(plugin, BUNGEE_CHANNEL, this); + this.running = true; + } + + @Override + public void publish(MessageEnvelope envelope) { + if (!running) { + throw new IllegalStateException("Transport is not running"); + } + Player carrier = anyPlayer(); + if (carrier == null) { + logger.debug("Dropping message on channel '" + envelope.channel() + + "': no player online to carry it through the proxy"); + return; + } + byte[] payload = codec.encode(envelope); + byte[] frame = buildForwardFrame(envelope.target(), payload); + carrier.sendPluginMessage(plugin, BUNGEE_CHANNEL, frame); + } + + @Override + public boolean isConnected() { + return running && anyPlayer() != null; + } + + @Override + public void onPluginMessageReceived(String channel, Player player, byte[] message) { + if (!running || !BUNGEE_CHANNEL.equals(channel)) { + return; + } + // The proxy strips the Forward/destination header before delivering, so a + // backend receives a plain frame here. + try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(message))) { + String subChannel = in.readUTF(); + if (!FORWARD_SUBCHANNEL.equals(subChannel)) { + return; + } + short length = in.readShort(); + byte[] payload = new byte[length]; + in.readFully(payload); + EnvelopeSink target = sink; + if (target != null) { + target.accept(codec.decode(payload)); + } + } catch (IOException error) { + logger.warn("Failed to read forwarded plugin message", error); + } + } + + @Override + public void close() { + running = false; + sink = null; + try { + Bukkit.getMessenger().unregisterIncomingPluginChannel(plugin, BUNGEE_CHANNEL, this); + Bukkit.getMessenger().unregisterOutgoingPluginChannel(plugin, BUNGEE_CHANNEL); + } catch (RuntimeException ignored) { + // messenger may already be torn down during shutdown + } + } + + /** + * Builds a BungeeCord {@code Forward} frame. The destination is "ALL" for + * broadcast/all-backends, or the specific server name for a SERVER target; + * PROXY/PLAYER targets go out as ALL and are filtered by the bus on receipt. + */ + private byte[] buildForwardFrame(Target target, byte[] payload) { + String destination = target.kind() == Target.Kind.SERVER && target.serverName() != null + ? target.serverName() + : "ALL"; + try (ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bytes)) { + out.writeUTF("Forward"); + out.writeUTF(destination); + out.writeUTF(FORWARD_SUBCHANNEL); + out.writeShort(payload.length); + out.write(payload); + return bytes.toByteArray(); + } catch (IOException error) { + throw new UncheckedIOException("Failed to build Forward frame", error); + } + } + + private @Nullable Player anyPlayer() { + for (Player player : Bukkit.getOnlinePlayers()) { + return player; + } + return null; + } +} diff --git a/platform-bungee/build.gradle.kts b/platform-bungee/build.gradle.kts index f5831de4b..45177de11 100644 --- a/platform-bungee/build.gradle.kts +++ b/platform-bungee/build.gradle.kts @@ -13,6 +13,7 @@ magicutilsPublish { dependencies { api(project(":core")) api(project(":diagnostics")) + api(project(":messaging")) compileOnly(libs.bungeecord.api) api(libs.kyori.adventure.text.serializer.legacy) implementation(libs.kyori.adventure.text.serializer.plain) diff --git a/platform-bungee/src/main/java/dev/ua/theroer/magicutils/Logger.java b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/Logger.java new file mode 100644 index 000000000..3472fc08b --- /dev/null +++ b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/Logger.java @@ -0,0 +1,173 @@ +package dev.ua.theroer.magicutils; + +import dev.ua.theroer.magicutils.config.ConfigManager; +import dev.ua.theroer.magicutils.logger.LogBuilder; +import dev.ua.theroer.magicutils.logger.LogLevel; +import dev.ua.theroer.magicutils.logger.LogMethods; +import dev.ua.theroer.magicutils.logger.LogTarget; +import dev.ua.theroer.magicutils.logger.LoggerAdapter; +import dev.ua.theroer.magicutils.logger.LoggerCore; +import dev.ua.theroer.magicutils.logger.PrefixedLogger; +import dev.ua.theroer.magicutils.logger.PrefixedLoggerCore; +import dev.ua.theroer.magicutils.platform.Audience; +import dev.ua.theroer.magicutils.platform.Platform; +import dev.ua.theroer.magicutils.platform.bungee.BungeeAudience; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import net.md_5.bungee.api.connection.ProxiedPlayer; + +/** + * BungeeCord logger: the single output surface for a plugin. + * + *

Despite the name, this is not only for console logs. The same level methods + * send to the console (a log line) and to players (a chat message), so one call + * site covers both. Text is authored once with MiniMessage or legacy {@code &} + * codes and rendered correctly on every platform. + * + *

Everyday use is the short level methods; reach for the fluent + * {@link #log()} builder only when you need several recipients, tag resolvers, + * or a per-message prefix override: + * + *

{@code
+ * // Console log line:
+ * logger.info("Ready, %d loaded", count);
+ *
+ * // Message a player (same levels, different target):
+ * logger.info(player, "Connected");
+ *
+ * // Composite case -> builder:
+ * logger.warn().to(player).toConsole().send("Backend down");
+ * }
+ * + *

Backed by {@link LoggerCore}; obtain a prefixed sub-logger with + * {@link #create(String)}. Console routing reuses the platform's structured + * console audience. + * + * @see LoggerAdapter + */ +@LogMethods(staticMethods = false, audienceType = "net.md_5.bungee.api.connection.ProxiedPlayer") +public final class Logger extends LoggerMethods implements LoggerAdapter { + private final LoggerCore core; + private final Map prefixedLoggers = new HashMap<>(); + + /** + * Creates a BungeeCord logger instance. + * + * @param platform platform adapter + * @param manager config manager + * @param placeholderOwner placeholder owner key + * @param pluginName plugin name for the logger prefix + */ + public Logger(Platform platform, ConfigManager manager, Object placeholderOwner, String pluginName) { + this.core = new LoggerCore(platform, manager, placeholderOwner, pluginName); + } + + @Override + public LoggerCore getCore() { + return core; + } + + @Override + public Map getPrefixedLoggers() { + return prefixedLoggers; + } + + @Override + public PrefixedLogger buildPrefixedLogger(PrefixedLoggerCore prefixedCore) { + return new PrefixedLogger(this, prefixedCore); + } + + /** + * Creates an INFO level log builder. + * + * @return log builder + */ + public LogBuilder log() { + return new LogBuilder(this, LogLevel.INFO); + } + + /** + * Creates an INFO level log builder with the prefix disabled. + * + * @return log builder + */ + public LogBuilder noPrefix() { + return new LogBuilder(this, LogLevel.INFO).noPrefix(); + } + + /** + * Creates an INFO level log builder. + * + * @return log builder + */ + public LogBuilder info() { + return new LogBuilder(this, LogLevel.INFO); + } + + /** + * Creates a WARN level log builder. + * + * @return log builder + */ + public LogBuilder warn() { + return new LogBuilder(this, LogLevel.WARN); + } + + /** + * Creates an ERROR level log builder. + * + * @return log builder + */ + public LogBuilder error() { + return new LogBuilder(this, LogLevel.ERROR); + } + + /** + * Creates a DEBUG level log builder. + * + * @return log builder + */ + public LogBuilder debug() { + return new LogBuilder(this, LogLevel.DEBUG); + } + + /** + * Creates a SUCCESS level log builder. + * + * @return log builder + */ + public LogBuilder success() { + return new LogBuilder(this, LogLevel.SUCCESS); + } + + @Override + protected void send(LogLevel level, Object message) { + send(level, message, null, null, getDefaultTarget(), false); + } + + @Override + protected void send(LogLevel level, Object message, ProxiedPlayer player) { + send(level, message, player, null, LogTarget.CHAT, false); + } + + @Override + protected void send(LogLevel level, Object message, ProxiedPlayer player, boolean all) { + send(level, message, player, null, getDefaultTarget(), all); + } + + @Override + protected void sendToConsole(LogLevel level, Object message) { + send(level, message, null, null, LogTarget.CONSOLE, false); + } + + @Override + protected void sendToPlayers(LogLevel level, Object message, Collection players) { + send(level, message, null, players, LogTarget.CHAT, false); + } + + @Override + public Audience wrapAudience(ProxiedPlayer player) { + return player != null ? new BungeeAudience(player) : null; + } +} diff --git a/platform-bungee/src/main/java/dev/ua/theroer/magicutils/bootstrap/BungeeBootstrap.java b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/bootstrap/BungeeBootstrap.java index 865c71431..078241d1d 100644 --- a/platform-bungee/src/main/java/dev/ua/theroer/magicutils/bootstrap/BungeeBootstrap.java +++ b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/bootstrap/BungeeBootstrap.java @@ -6,8 +6,10 @@ import dev.ua.theroer.magicutils.diagnostics.DiagnosticRegistry; import dev.ua.theroer.magicutils.diagnostics.DiagnosticsService; import dev.ua.theroer.magicutils.diagnostics.DiagnosticsSupport; -import dev.ua.theroer.magicutils.lang.Messages; -import dev.ua.theroer.magicutils.logger.LoggerCore; +import dev.ua.theroer.magicutils.Logger; +import dev.ua.theroer.magicutils.messaging.MessagingService; +import dev.ua.theroer.magicutils.messaging.bungee.BungeeMessagingSupport; +import dev.ua.theroer.magicutils.messaging.redis.RedisConfig; import dev.ua.theroer.magicutils.platform.Platform; import dev.ua.theroer.magicutils.platform.bungee.BungeePlatformProvider; import net.md_5.bungee.api.ProxyServer; @@ -17,7 +19,6 @@ import java.util.Objects; import java.util.concurrent.Executor; import java.util.function.Consumer; -import java.util.logging.Logger; import org.jetbrains.annotations.Nullable; /** @@ -28,12 +29,32 @@ private BungeeBootstrap() { } /** - * Creates a bootstrap builder for a Bungee plugin. + * Creates a bootstrap builder for a Bungee plugin, resolving the plugin + * name from the plugin description. + * + *

This is the recommended entry point. The plugin name is taken from + * {@code plugin.getDescription().getName()}; the data directory is the + * plugin's data folder. + * + * @param plugin Bungee plugin instance + * @return bootstrap builder + */ + public static Builder forPlugin(Plugin plugin) { + Objects.requireNonNull(plugin, "plugin"); + String pluginName = plugin.getDescription() != null ? plugin.getDescription().getName() : null; + return new Builder(plugin, pluginName); + } + + /** + * Creates a bootstrap builder for a Bungee plugin with an explicit name. * * @param plugin Bungee plugin instance * @param pluginName logical plugin name for logger/messages * @return bootstrap builder + * @deprecated prefer {@link #forPlugin(Plugin)}, which derives the name from + * the plugin description */ + @Deprecated public static Builder forPlugin(Plugin plugin, String pluginName) { return new Builder(plugin, pluginName); } @@ -45,26 +66,22 @@ public static final class Builder { private final Plugin plugin; private final ProxyServer proxy; private final String pluginName; + private final LanguageBootstrap lang = new LanguageBootstrap(); private Path dataDirectory; - private Logger jul; + private java.util.logging.Logger jul; private Platform platform; private ConfigManager configManager; - private LoggerCore logger; + private Logger logger; private LanguageManager languageManager; - private String language = "en"; - private boolean initLanguage = true; - private boolean bindLoggerLanguage = true; - private boolean setMessagesManager = true; - private boolean registerMessages = true; - private boolean addMagicUtilsMessages = true; - private boolean bindClientLocaleSync = true; - private Consumer translations; private boolean enableCommands; private String permissionPrefix; private Executor asyncExecutor; private Consumer commandConfigurer; private boolean enableDiagnostics; private Consumer diagnosticsConfigurer; + private boolean enableMessaging; + private RedisConfig.Redis messagingRedis; + private Consumer messagingConfigurer; private Builder(Plugin plugin, String pluginName) { this.plugin = Objects.requireNonNull(plugin, "plugin"); @@ -101,7 +118,7 @@ public Builder dataDirectory(Path dataDirectory) { * @param jul logger instance * @return this builder */ - public Builder logger(Logger jul) { + public Builder logger(java.util.logging.Logger jul) { this.jul = jul; return this; } @@ -118,12 +135,12 @@ public Builder configManager(ConfigManager configManager) { } /** - * Sets the custom logger core. + * Sets the custom logger. * - * @param logger logger core + * @param logger logger to use * @return this builder */ - public Builder loggerCore(LoggerCore logger) { + public Builder loggerCore(Logger logger) { this.logger = logger; return this; } @@ -139,6 +156,28 @@ public Builder languageManager(LanguageManager languageManager) { return this; } + /** + * Applies the recommended language defaults (every flag enabled). This + * is already the initial state; call it to make intent explicit. + * + * @return this builder + */ + public Builder withRecommendedDefaults() { + lang.withRecommendedDefaults(); + return this; + } + + /** + * Disables all automatic language/messages wiring for callers that + * manage localization themselves. + * + * @return this builder + */ + public Builder minimal() { + lang.minimal(); + return this; + } + /** * Sets the default language. * @@ -146,9 +185,7 @@ public Builder languageManager(LanguageManager languageManager) { * @return this builder */ public Builder language(String language) { - if (language != null && !language.isBlank()) { - this.language = language; - } + lang.language(language); return this; } @@ -159,7 +196,7 @@ public Builder language(String language) { * @return this builder */ public Builder initLanguage(boolean initLanguage) { - this.initLanguage = initLanguage; + lang.initLanguage(initLanguage); return this; } @@ -170,7 +207,7 @@ public Builder initLanguage(boolean initLanguage) { * @return this builder */ public Builder bindLoggerLanguage(boolean bindLoggerLanguage) { - this.bindLoggerLanguage = bindLoggerLanguage; + lang.bindLoggerLanguage(bindLoggerLanguage); return this; } @@ -181,7 +218,7 @@ public Builder bindLoggerLanguage(boolean bindLoggerLanguage) { * @return this builder */ public Builder setMessagesManager(boolean setMessagesManager) { - this.setMessagesManager = setMessagesManager; + lang.setMessagesManager(setMessagesManager); return this; } @@ -192,7 +229,7 @@ public Builder setMessagesManager(boolean setMessagesManager) { * @return this builder */ public Builder registerMessages(boolean registerMessages) { - this.registerMessages = registerMessages; + lang.registerMessages(registerMessages); return this; } @@ -203,7 +240,7 @@ public Builder registerMessages(boolean registerMessages) { * @return this builder */ public Builder addMagicUtilsMessages(boolean addMagicUtilsMessages) { - this.addMagicUtilsMessages = addMagicUtilsMessages; + lang.addMagicUtilsMessages(addMagicUtilsMessages); return this; } @@ -214,7 +251,7 @@ public Builder addMagicUtilsMessages(boolean addMagicUtilsMessages) { * @return this builder */ public Builder bindClientLocaleSync(boolean bindClientLocaleSync) { - this.bindClientLocaleSync = bindClientLocaleSync; + lang.bindClientLocaleSync(bindClientLocaleSync); return this; } @@ -225,7 +262,7 @@ public Builder bindClientLocaleSync(boolean bindClientLocaleSync) { * @return this builder */ public Builder translations(Consumer translations) { - this.translations = translations; + lang.translations(translations); return this; } @@ -293,6 +330,40 @@ public Builder configureDiagnostics(Consumer diagnosticsConf return this; } + /** + * Enables cross-server messaging using the default plugin-messaging transport. + * + * @return this builder + */ + public Builder enableMessaging() { + this.enableMessaging = true; + return this; + } + + /** + * Supplies Redis settings for messaging; when enabled, Redis is used. + * + * @param redis redis settings + * @return this builder + */ + public Builder messagingRedis(RedisConfig.Redis redis) { + this.messagingRedis = redis; + this.enableMessaging = true; + return this; + } + + /** + * Allows configuring the messaging service builder before it is built. + * + * @param messagingConfigurer messaging builder callback + * @return this builder + */ + public Builder configureMessaging(Consumer messagingConfigurer) { + this.messagingConfigurer = messagingConfigurer; + this.enableMessaging = true; + return this; + } + /** * Builds the bootstrap result. * @@ -313,18 +384,16 @@ public RuntimeResult buildRuntime() { MagicRuntime runtime = MagicRuntime.builder( prepared.platform(), prepared.configManager(), - prepared.logger() + prepared.logger().getCore() ) .languageManager(prepared.languageManager()) .manageConfigManager(configManager == null) .component(Plugin.class, plugin) .component(ProxyServer.class, proxy) + .component(Logger.class, prepared.logger()) .build(); - if (bindClientLocaleSync) { - runtime.manage("language.clientLocaleSync", - prepared.languageManager().bindClientLocaleSync(prepared.platform())); - } + lang.bindClientLocaleSync(runtime, prepared.platform(), prepared.languageManager()); if (prepared.commandRegistry() != null) { runtime.putComponent(CommandRegistry.class, prepared.commandRegistry()); @@ -335,16 +404,11 @@ public RuntimeResult buildRuntime() { if (enableDiagnostics) { DiagnosticsSupport.install(runtime, diagnosticsConfigurer); } - if (registerMessages) { - runtime.onClose("messages.scope", () -> Messages.unregister(pluginName)); - } - if (setMessagesManager) { - runtime.onClose("messages.default", () -> { - if (Messages.getLanguageManager() == prepared.languageManager()) { - Messages.setLanguageManager(null); - } - }); + if (enableMessaging) { + BungeeMessagingSupport.install( + runtime, proxy, plugin, pluginName, messagingRedis, messagingConfigurer); } + lang.installMessagesCloseHooks(runtime, pluginName, prepared.languageManager()); return new RuntimeResult(runtime, prepared.platform(), prepared.configManager(), prepared.logger(), prepared.languageManager(), prepared.commandRegistry()); @@ -357,36 +421,19 @@ private Prepared prepare() { ConfigManager resolvedConfigManager = configManager != null ? configManager : new ConfigManager(resolvedPlatform); - LoggerCore resolvedLogger = logger != null + Logger resolvedLogger = logger != null ? logger - : new LoggerCore(resolvedPlatform, resolvedConfigManager, plugin, pluginName); + : new Logger(resolvedPlatform, resolvedConfigManager, plugin, pluginName); LanguageManager resolvedLanguageManager = languageManager != null ? languageManager : new LanguageManager(resolvedPlatform, resolvedConfigManager); - if (initLanguage) { - resolvedLanguageManager.init(language); - } - if (translations != null) { - translations.accept(resolvedLanguageManager); - } - if (addMagicUtilsMessages) { - resolvedLanguageManager.addMagicUtilsMessages(); - } - if (registerMessages) { - Messages.register(pluginName, resolvedLanguageManager); - } - if (setMessagesManager) { - Messages.setLanguageManager(resolvedLanguageManager); - } - if (bindLoggerLanguage) { - resolvedLogger.setLanguageManager(resolvedLanguageManager); - } + lang.apply(pluginName, resolvedLanguageManager, resolvedLogger.getCore()); CommandRegistry registry = null; if (enableCommands) { String prefix = permissionPrefix != null ? permissionPrefix : pluginName; - registry = CommandRegistry.create(proxy, plugin, prefix, resolvedLogger, asyncExecutor); + registry = CommandRegistry.create(proxy, plugin, prefix, resolvedLogger.getCore(), asyncExecutor); if (commandConfigurer != null) { commandConfigurer.accept(registry); } @@ -405,7 +452,7 @@ private static String normalizePluginName(String pluginName) { private record Prepared( Platform platform, ConfigManager configManager, - LoggerCore logger, + Logger logger, LanguageManager languageManager, CommandRegistry commandRegistry ) { @@ -424,7 +471,7 @@ private record Prepared( public record Result( Platform platform, ConfigManager configManager, - LoggerCore logger, + Logger logger, LanguageManager languageManager, CommandRegistry commandRegistry ) { @@ -444,7 +491,7 @@ public record RuntimeResult( MagicRuntime runtime, Platform platform, ConfigManager configManager, - LoggerCore logger, + Logger logger, LanguageManager languageManager, CommandRegistry commandRegistry ) { diff --git a/platform-bungee/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java new file mode 100644 index 000000000..b22c75ef0 --- /dev/null +++ b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java @@ -0,0 +1,84 @@ +package dev.ua.theroer.magicutils.logger; + +import dev.ua.theroer.magicutils.Logger; +import java.util.Collection; +import net.md_5.bungee.api.connection.ProxiedPlayer; + +/** + * BungeeCord-specific log builder with player helpers. + * + *

The shared fluent methods ({@code to(Audience)}, {@code target}, + * {@code noPrefix}, ...) are inherited from {@link LogBuilderCore} and already + * return {@code LogBuilder} thanks to its self type; this class only adds the + * Bungee-typed recipient overloads. + */ +public class LogBuilder extends LogBuilderCore { + private final Logger logger; + + /** + * Creates a log builder for the specified level. + * + * @param logger parent logger + * @param level log level + */ + public LogBuilder(Logger logger, LogLevel level) { + super(logger.getCore(), level); + this.logger = logger; + } + + /** + * Adds a player recipient. + * + * @param player target player + * @return this builder + */ + public LogBuilder to(ProxiedPlayer player) { + super.to(logger.wrapAudience(player)); + return this; + } + + /** + * Adds multiple player recipients. + * + * @param players target players + * @return this builder + */ + public LogBuilder to(ProxiedPlayer... players) { + if (players != null) { + for (ProxiedPlayer player : players) { + if (player != null) { + super.recipient(logger.wrapAudience(player)); + } + } + } + return this; + } + + /** + * Adds a collection of player recipients. + * + * @param players target players + * @return this builder + */ + public LogBuilder to(Collection players) { + if (players != null) { + for (ProxiedPlayer player : players) { + if (player != null) { + super.recipient(logger.wrapAudience(player)); + } + } + } + return this; + } + + /** + * Adds a player as a recipient. + * + * @param player target player + * @return this builder + */ + public LogBuilder recipient(ProxiedPlayer player) { + super.recipient(logger.wrapAudience(player)); + return this; + } +} diff --git a/platform-bungee/src/main/java/dev/ua/theroer/magicutils/logger/PrefixedLogger.java b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/logger/PrefixedLogger.java new file mode 100644 index 000000000..64de79570 --- /dev/null +++ b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/logger/PrefixedLogger.java @@ -0,0 +1,96 @@ +package dev.ua.theroer.magicutils.logger; + +import dev.ua.theroer.magicutils.Logger; +import java.util.Collection; +import net.md_5.bungee.api.connection.ProxiedPlayer; + +/** + * Logger instance with a custom prefix. + * All log methods are generated by the annotation processor. + */ +@LogMethods(staticMethods = false, audienceType = "net.md_5.bungee.api.connection.ProxiedPlayer") +public class PrefixedLogger extends PrefixedLoggerMethods implements PrefixedLoggerAdapter { + private final Logger logger; + private final PrefixedLoggerCore core; + + /** + * Creates a prefixed logger wrapper. + * + * @param logger parent logger + * @param core prefixed core instance + */ + public PrefixedLogger(Logger logger, PrefixedLoggerCore core) { + this.logger = logger; + this.core = core; + } + + @Override + public PrefixedLoggerCore getCore() { + return core; + } + + @Override + public LoggerAdapter getLoggerAdapter() { + return logger; + } + + @Override + public LogBuilder buildLogBuilder(LogLevel level) { + return new PrefixedLogBuilder(level); + } + + @Override + public void send(LogLevel level, Object message) { + PrefixedLoggerAdapter.super.send(level, message); + } + + @Override + public void send(LogLevel level, Object message, ProxiedPlayer player) { + PrefixedLoggerAdapter.super.send(level, message, player); + } + + @Override + public void send(LogLevel level, Object message, ProxiedPlayer player, boolean all) { + PrefixedLoggerAdapter.super.send(level, message, player, all); + } + + @Override + public void sendToConsole(LogLevel level, Object message) { + PrefixedLoggerAdapter.super.sendToConsole(level, message); + } + + @Override + public void sendToPlayers(LogLevel level, Object message, Collection players) { + PrefixedLoggerAdapter.super.sendToPlayers(level, message, players); + } + + private class PrefixedLogBuilder extends LogBuilder { + PrefixedLogBuilder(LogLevel level) { + super(logger, level); + } + + @Override + protected void performSend(Object message, Object... placeholders) { + if (!core.isEnabled()) { + return; + } + LogTarget finalTarget = getTarget() != null ? getTarget() : logger.getCore().getDefaultTarget(); + Collection audienceRecipients = null; + if (!getRecipients().isEmpty()) { + audienceRecipients = getRecipients(); + } + logger.getCore().send( + level, + message, + getAudience(), + audienceRecipients, + finalTarget, + isBroadcast(), + new ConsoleMessageMetadata(level, core.getName()), + core.getPrefix(), + getPrefixOverride(), + placeholders + ); + } + } +} diff --git a/platform-bungee/src/main/java/dev/ua/theroer/magicutils/messaging/bungee/BungeeMessagingSupport.java b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/messaging/bungee/BungeeMessagingSupport.java new file mode 100644 index 000000000..974ccf1ef --- /dev/null +++ b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/messaging/bungee/BungeeMessagingSupport.java @@ -0,0 +1,85 @@ +package dev.ua.theroer.magicutils.messaging.bungee; + +import java.util.UUID; +import java.util.function.Consumer; + +import dev.ua.theroer.magicutils.bootstrap.MagicRuntime; +import dev.ua.theroer.magicutils.messaging.MessageCodec; +import dev.ua.theroer.magicutils.messaging.MessageSource; +import dev.ua.theroer.magicutils.messaging.MessageTransport; +import dev.ua.theroer.magicutils.messaging.MessagingService; +import dev.ua.theroer.magicutils.messaging.redis.RedisConfig; +import dev.ua.theroer.magicutils.platform.Platform; +import dev.ua.theroer.magicutils.platform.PlatformLogger; + +import net.md_5.bungee.api.ProxyServer; +import net.md_5.bungee.api.plugin.Plugin; +import org.jetbrains.annotations.Nullable; + +/** + * Wires a {@link MessagingService} into a BungeeCord plugin's {@link MagicRuntime}. + * + *

Mirror of the Velocity support: Redis when enabled, otherwise the default + * {@link BungeePluginMessageTransport} registered as a Bungee listener. Service + * registered as a runtime component and closed with the runtime.

+ */ +public final class BungeeMessagingSupport { + private BungeeMessagingSupport() { + } + + /** + * Installs the messaging service on the runtime. + * + * @param runtime plugin runtime + * @param proxy bungee proxy + * @param plugin owning plugin + * @param pluginName plugin name (used to derive the member id) + * @param redis redis settings, or null to force the default transport + * @param configurer optional callback to tweak the service builder + * @return the installed service + */ + public static MessagingService install( + MagicRuntime runtime, + ProxyServer proxy, + Plugin plugin, + String pluginName, + @Nullable RedisConfig.Redis redis, + @Nullable Consumer configurer) { + Platform platform = runtime.platform(); + PlatformLogger logger = platform.logger(); + MessageCodec codec = new MessageCodec(); + + MessageSource self = MessageSource.proxy(pluginName + ":" + UUID.randomUUID()); + + BungeePluginMessageTransport[] defaultTransport = new BungeePluginMessageTransport[1]; + + MessagingService.Builder builder = MessagingService.builder(self) + .logger(logger) + .redis(redis) + .codec(codec) + .defaultTransport(() -> { + BungeePluginMessageTransport transport = + new BungeePluginMessageTransport(proxy, plugin, codec, logger); + defaultTransport[0] = transport; + return transport; + }); + + if (configurer != null) { + configurer.accept(builder); + } + + MessagingService service = builder.build(); + + MessageTransport active = service.bus().transport(); + if (active == defaultTransport[0] && defaultTransport[0] != null) { + proxy.getPluginManager().registerListener(plugin, defaultTransport[0]); + runtime.onClose("messaging.listener", + () -> proxy.getPluginManager().unregisterListener(defaultTransport[0])); + } + + runtime.putComponent(MessagingService.class, service); + runtime.manage("messaging.service", service); + logger.info("MagicUtils messaging enabled (transport: " + service.transportName() + ")"); + return service; + } +} diff --git a/platform-bungee/src/main/java/dev/ua/theroer/magicutils/messaging/bungee/BungeePluginMessageTransport.java b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/messaging/bungee/BungeePluginMessageTransport.java new file mode 100644 index 000000000..ef1645b82 --- /dev/null +++ b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/messaging/bungee/BungeePluginMessageTransport.java @@ -0,0 +1,168 @@ +package dev.ua.theroer.magicutils.messaging.bungee; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Objects; + +import dev.ua.theroer.magicutils.messaging.MessageCodec; +import dev.ua.theroer.magicutils.messaging.MessageEnvelope; +import dev.ua.theroer.magicutils.messaging.MessageTransport; +import dev.ua.theroer.magicutils.messaging.Target; +import dev.ua.theroer.magicutils.platform.PlatformLogger; + +import net.md_5.bungee.api.ProxyServer; +import net.md_5.bungee.api.config.ServerInfo; +import net.md_5.bungee.api.connection.Server; +import net.md_5.bungee.api.event.PluginMessageEvent; +import net.md_5.bungee.api.plugin.Listener; +import net.md_5.bungee.api.plugin.Plugin; +import net.md_5.bungee.event.EventHandler; + +/** + * Default (Redis-less) messaging transport for the BungeeCord/Waterfall proxy. + * + *

Mirror of {@code VelocityPluginMessageTransport}: it listens on the + * BungeeCord plugin channel for {@code Forward} frames from backends (feeding + * proxy-side subscribers) and dispatches proxy-originated envelopes to backend + * servers wrapped in the shared sub-channel frame.

+ * + *

Registered as a Bungee {@link Listener} via + * {@code proxy.getPluginManager().registerListener(plugin, transport)} by the + * bootstrap wiring.

+ */ +public final class BungeePluginMessageTransport implements MessageTransport, Listener { + private static final String BUNGEE_CHANNEL = "BungeeCord"; + private static final String FORWARD_SUBCHANNEL = "magicutils:bus"; + + private final ProxyServer proxy; + private final Plugin plugin; + private final MessageCodec codec; + private final PlatformLogger logger; + + private volatile EnvelopeSink sink; + private volatile boolean running; + + /** + * Creates the transport. + * + * @param proxy bungee proxy + * @param plugin owning plugin + * @param codec envelope codec + * @param logger platform logger + */ + public BungeePluginMessageTransport(ProxyServer proxy, Plugin plugin, MessageCodec codec, PlatformLogger logger) { + this.proxy = Objects.requireNonNull(proxy, "proxy"); + this.plugin = Objects.requireNonNull(plugin, "plugin"); + this.codec = Objects.requireNonNull(codec, "codec"); + this.logger = Objects.requireNonNull(logger, "logger"); + } + + @Override + public String name() { + return "plugin-messaging"; + } + + @Override + public void start(EnvelopeSink sink) { + this.sink = Objects.requireNonNull(sink, "sink"); + proxy.registerChannel(BUNGEE_CHANNEL); + this.running = true; + } + + @Override + public void publish(MessageEnvelope envelope) { + if (!running) { + throw new IllegalStateException("Transport is not running"); + } + byte[] payload = codec.encode(envelope); + byte[] frame = buildFrame(payload); + Target target = envelope.target(); + if (target.kind() == Target.Kind.SERVER && target.serverName() != null) { + ServerInfo server = proxy.getServerInfo(target.serverName()); + if (server != null) { + sendToServer(server, frame); + } + } else { + for (ServerInfo server : proxy.getServers().values()) { + sendToServer(server, frame); + } + } + } + + @Override + public boolean isConnected() { + return running; + } + + /** + * Handles inbound BungeeCord {@code Forward} frames from backends. + * + * @param event plugin message event + */ + @EventHandler + public void onPluginMessage(PluginMessageEvent event) { + if (!running || !BUNGEE_CHANNEL.equals(event.getTag())) { + return; + } + // Only accept frames coming from a backend server connection. + if (!(event.getSender() instanceof Server)) { + return; + } + try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(event.getData()))) { + String subChannel = in.readUTF(); + if (!"Forward".equals(subChannel)) { + return; + } + in.readUTF(); // destination + String forwardChannel = in.readUTF(); + if (!FORWARD_SUBCHANNEL.equals(forwardChannel)) { + return; + } + short length = in.readShort(); + byte[] payload = new byte[length]; + in.readFully(payload); + EnvelopeSink target = sink; + if (target != null) { + target.accept(codec.decode(payload)); + } + } catch (IOException error) { + logger.warn("Failed to read Forward frame on the proxy", error); + } + } + + @Override + public void close() { + running = false; + sink = null; + try { + proxy.unregisterChannel(BUNGEE_CHANNEL); + } catch (RuntimeException ignored) { + // channel may already be unregistered during shutdown + } + } + + private void sendToServer(ServerInfo server, byte[] frame) { + try { + // queue=true so the frame is buffered until the backend connection is ready. + server.sendData(BUNGEE_CHANNEL, frame, true); + } catch (RuntimeException error) { + logger.warn("Failed to send plugin message to server '" + server.getName() + "'", error); + } + } + + private byte[] buildFrame(byte[] payload) { + try (ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bytes)) { + out.writeUTF(FORWARD_SUBCHANNEL); + out.writeShort(payload.length); + out.write(payload); + return bytes.toByteArray(); + } catch (IOException error) { + throw new UncheckedIOException("Failed to build proxy frame", error); + } + } +} diff --git a/platform-bungee/src/main/java/dev/ua/theroer/magicutils/platform/bungee/BungeeAudience.java b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/platform/bungee/BungeeAudience.java index 00ab8158a..cc1d8ff0e 100644 --- a/platform-bungee/src/main/java/dev/ua/theroer/magicutils/platform/bungee/BungeeAudience.java +++ b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/platform/bungee/BungeeAudience.java @@ -10,11 +10,16 @@ /** * Wraps a Bungee command sender as a MagicUtils audience. */ -final class BungeeAudience implements Audience { +public final class BungeeAudience implements Audience { private final CommandSender source; private final UUID id; - BungeeAudience(CommandSender source) { + /** + * Wraps a Bungee command sender (player or console) as an audience. + * + * @param source command sender to wrap + */ + public BungeeAudience(CommandSender source) { this.source = source; this.id = source instanceof ProxiedPlayer player ? player.getUniqueId() : null; } diff --git a/platform-velocity/build.gradle.kts b/platform-velocity/build.gradle.kts index febc7cde1..7a4315925 100644 --- a/platform-velocity/build.gradle.kts +++ b/platform-velocity/build.gradle.kts @@ -13,6 +13,7 @@ magicutilsPublish { dependencies { api(project(":core")) api(project(":diagnostics")) + api(project(":messaging")) compileOnly(libs.velocity.api) compileOnly(libs.slf4j.api) compileOnly(libs.jetbrains.annotations) diff --git a/platform-velocity/src/main/java/dev/ua/theroer/magicutils/Logger.java b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/Logger.java new file mode 100644 index 000000000..f462f30c5 --- /dev/null +++ b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/Logger.java @@ -0,0 +1,173 @@ +package dev.ua.theroer.magicutils; + +import com.velocitypowered.api.proxy.Player; +import dev.ua.theroer.magicutils.config.ConfigManager; +import dev.ua.theroer.magicutils.logger.LogBuilder; +import dev.ua.theroer.magicutils.logger.LogLevel; +import dev.ua.theroer.magicutils.logger.LogMethods; +import dev.ua.theroer.magicutils.logger.LogTarget; +import dev.ua.theroer.magicutils.logger.LoggerAdapter; +import dev.ua.theroer.magicutils.logger.LoggerCore; +import dev.ua.theroer.magicutils.logger.PrefixedLogger; +import dev.ua.theroer.magicutils.logger.PrefixedLoggerCore; +import dev.ua.theroer.magicutils.platform.Audience; +import dev.ua.theroer.magicutils.platform.Platform; +import dev.ua.theroer.magicutils.platform.velocity.VelocityAudience; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +/** + * Velocity logger: the single output surface for a plugin. + * + *

Despite the name, this is not only for console logs. The same level methods + * send to the console (a log line) and to players (a chat message), so one call + * site covers both. Text is authored once with MiniMessage or legacy {@code &} + * codes and rendered correctly on every platform. + * + *

Everyday use is the short level methods; reach for the fluent + * {@link #log()} builder only when you need several recipients, tag resolvers, + * or a per-message prefix override: + * + *

{@code
+ * // Console log line:
+ * logger.info("Ready, %d loaded", count);
+ *
+ * // Message a player (same levels, different target):
+ * logger.info(player, "Connected");
+ *
+ * // Composite case -> builder:
+ * logger.warn().to(player).toConsole().send("Backend down");
+ * }
+ * + *

Backed by {@link LoggerCore}; obtain a prefixed sub-logger with + * {@link #create(String)}. Console routing reuses the platform's structured + * console audience. + * + * @see LoggerAdapter + */ +@LogMethods(staticMethods = false, audienceType = "com.velocitypowered.api.proxy.Player") +public final class Logger extends LoggerMethods implements LoggerAdapter { + private final LoggerCore core; + private final Map prefixedLoggers = new HashMap<>(); + + /** + * Creates a Velocity logger instance. + * + * @param platform platform adapter + * @param manager config manager + * @param placeholderOwner placeholder owner key + * @param pluginName plugin name for the logger prefix + */ + public Logger(Platform platform, ConfigManager manager, Object placeholderOwner, String pluginName) { + this.core = new LoggerCore(platform, manager, placeholderOwner, pluginName); + } + + @Override + public LoggerCore getCore() { + return core; + } + + @Override + public Map getPrefixedLoggers() { + return prefixedLoggers; + } + + @Override + public PrefixedLogger buildPrefixedLogger(PrefixedLoggerCore prefixedCore) { + return new PrefixedLogger(this, prefixedCore); + } + + /** + * Creates an INFO level log builder. + * + * @return log builder + */ + public LogBuilder log() { + return new LogBuilder(this, LogLevel.INFO); + } + + /** + * Creates an INFO level log builder with the prefix disabled. + * + * @return log builder + */ + public LogBuilder noPrefix() { + return new LogBuilder(this, LogLevel.INFO).noPrefix(); + } + + /** + * Creates an INFO level log builder. + * + * @return log builder + */ + public LogBuilder info() { + return new LogBuilder(this, LogLevel.INFO); + } + + /** + * Creates a WARN level log builder. + * + * @return log builder + */ + public LogBuilder warn() { + return new LogBuilder(this, LogLevel.WARN); + } + + /** + * Creates an ERROR level log builder. + * + * @return log builder + */ + public LogBuilder error() { + return new LogBuilder(this, LogLevel.ERROR); + } + + /** + * Creates a DEBUG level log builder. + * + * @return log builder + */ + public LogBuilder debug() { + return new LogBuilder(this, LogLevel.DEBUG); + } + + /** + * Creates a SUCCESS level log builder. + * + * @return log builder + */ + public LogBuilder success() { + return new LogBuilder(this, LogLevel.SUCCESS); + } + + @Override + protected void send(LogLevel level, Object message) { + send(level, message, null, null, getDefaultTarget(), false); + } + + @Override + protected void send(LogLevel level, Object message, Player player) { + send(level, message, player, null, LogTarget.CHAT, false); + } + + @Override + protected void send(LogLevel level, Object message, Player player, boolean all) { + send(level, message, player, null, getDefaultTarget(), all); + } + + @Override + protected void sendToConsole(LogLevel level, Object message) { + send(level, message, null, null, LogTarget.CONSOLE, false); + } + + @Override + protected void sendToPlayers(LogLevel level, Object message, Collection players) { + send(level, message, null, players, LogTarget.CHAT, false); + } + + @Override + public Audience wrapAudience(Player player) { + return player != null ? new VelocityAudience(player) : null; + } +} diff --git a/platform-velocity/src/main/java/dev/ua/theroer/magicutils/bootstrap/VelocityBootstrap.java b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/bootstrap/VelocityBootstrap.java index 37abe2e0c..3b628fc55 100644 --- a/platform-velocity/src/main/java/dev/ua/theroer/magicutils/bootstrap/VelocityBootstrap.java +++ b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/bootstrap/VelocityBootstrap.java @@ -2,6 +2,7 @@ import com.velocitypowered.api.plugin.PluginDescription; import com.velocitypowered.api.proxy.ProxyServer; +import dev.ua.theroer.magicutils.Logger; import dev.ua.theroer.magicutils.bootstrap.MagicUtilsConsumerPayloads; import dev.ua.theroer.magicutils.bootstrap.MagicUtilsConsumerViews; import dev.ua.theroer.magicutils.commands.CommandRegistry; @@ -11,12 +12,13 @@ import dev.ua.theroer.magicutils.diagnostics.DiagnosticsService; import dev.ua.theroer.magicutils.diagnostics.DiagnosticsSupport; import dev.ua.theroer.magicutils.lang.Messages; -import dev.ua.theroer.magicutils.logger.LoggerCore; +import dev.ua.theroer.magicutils.messaging.MessagingService; +import dev.ua.theroer.magicutils.messaging.redis.RedisConfig; +import dev.ua.theroer.magicutils.messaging.velocity.VelocityMessagingSupport; import dev.ua.theroer.magicutils.platform.MagicUtilsConsumerRegistry; import dev.ua.theroer.magicutils.platform.Platform; import dev.ua.theroer.magicutils.platform.velocity.VelocityMagicUtilsConsumerRegistry; import dev.ua.theroer.magicutils.platform.velocity.VelocityPlatformProvider; -import org.slf4j.Logger; import java.nio.file.Path; import java.time.Instant; @@ -36,14 +38,48 @@ private VelocityBootstrap() { } /** - * Creates a bootstrap builder for a Velocity plugin. + * Creates a bootstrap builder for a Velocity plugin, resolving the plugin + * name and data directory from the plugin's {@code @Plugin} metadata. + * + *

This is the recommended entry point. The plugin name is taken from the + * registered {@link PluginDescription} (name, falling back to id); the data + * directory defaults to {@code plugins/}. Use + * {@link Builder#dataDirectory(Path)} to override, for example when the + * plugin injects a {@code @DataDirectory Path}. + * + * @param proxy Velocity proxy server + * @param plugin plugin instance used for event registration and metadata + * @return bootstrap builder + */ + public static Builder forPlugin(ProxyServer proxy, Object plugin) { + Objects.requireNonNull(proxy, "proxy"); + Objects.requireNonNull(plugin, "plugin"); + Optional description = proxy.getPluginManager() + .fromInstance(plugin) + .map(container -> container.getDescription()); + String id = description.map(PluginDescription::getId).filter(s -> !s.isBlank()).orElse("magicutils"); + String name = description + .flatMap(PluginDescription::getName) + .filter(s -> !s.isBlank()) + .orElse(id); + Path dataDirectory = Path.of("plugins", id); + return new Builder(proxy, plugin, name, dataDirectory); + } + + /** + * Creates a bootstrap builder for a Velocity plugin with an explicit name + * and data directory. * * @param proxy Velocity proxy server * @param plugin plugin instance used for event registration * @param pluginName logical plugin name for logger/messages * @param dataDirectory plugin data directory * @return bootstrap builder + * @deprecated prefer {@link #forPlugin(ProxyServer, Object)}, which derives + * the name and data directory from the plugin metadata; override the + * data directory with {@link Builder#dataDirectory(Path)} when needed */ + @Deprecated public static Builder forPlugin(ProxyServer proxy, Object plugin, String pluginName, Path dataDirectory) { return new Builder(proxy, plugin, pluginName, dataDirectory); } @@ -55,26 +91,22 @@ public static final class Builder { private final ProxyServer proxy; private final Object plugin; private final String pluginName; + private final LanguageBootstrap lang = new LanguageBootstrap(); private Path dataDirectory; - private Logger slf4j; + private org.slf4j.Logger slf4j; private Platform platform; private ConfigManager configManager; - private LoggerCore logger; + private Logger logger; private LanguageManager languageManager; - private String language = "en"; - private boolean initLanguage = true; - private boolean bindLoggerLanguage = true; - private boolean setMessagesManager = true; - private boolean registerMessages = true; - private boolean addMagicUtilsMessages = true; - private boolean bindClientLocaleSync = true; - private Consumer translations; private boolean enableCommands; private String permissionPrefix; private Executor asyncExecutor; private Consumer commandConfigurer; private boolean enableDiagnostics; private Consumer diagnosticsConfigurer; + private boolean enableMessaging; + private RedisConfig.Redis messagingRedis; + private Consumer messagingConfigurer; private Builder(ProxyServer proxy, Object plugin, String pluginName, Path dataDirectory) { this.proxy = Objects.requireNonNull(proxy, "proxy"); @@ -111,7 +143,7 @@ public Builder dataDirectory(Path dataDirectory) { * @param slf4j logger to use * @return builder */ - public Builder slf4j(Logger slf4j) { + public Builder slf4j(org.slf4j.Logger slf4j) { this.slf4j = slf4j; return this; } @@ -128,12 +160,12 @@ public Builder configManager(ConfigManager configManager) { } /** - * Overrides the logger core instance. + * Overrides the logger instance. * - * @param logger logger core to use + * @param logger logger to use * @return builder */ - public Builder logger(LoggerCore logger) { + public Builder logger(Logger logger) { this.logger = logger; return this; } @@ -149,6 +181,28 @@ public Builder languageManager(LanguageManager languageManager) { return this; } + /** + * Applies the recommended language defaults (every flag enabled). This + * is already the initial state; call it to make intent explicit. + * + * @return builder + */ + public Builder withRecommendedDefaults() { + lang.withRecommendedDefaults(); + return this; + } + + /** + * Disables all automatic language/messages wiring for callers that + * manage localization themselves. + * + * @return builder + */ + public Builder minimal() { + lang.minimal(); + return this; + } + /** * Sets the default language code to initialize. * @@ -156,9 +210,7 @@ public Builder languageManager(LanguageManager languageManager) { * @return builder */ public Builder language(String language) { - if (language != null && !language.isBlank()) { - this.language = language; - } + lang.language(language); return this; } @@ -169,7 +221,7 @@ public Builder language(String language) { * @return builder */ public Builder initLanguage(boolean initLanguage) { - this.initLanguage = initLanguage; + lang.initLanguage(initLanguage); return this; } @@ -180,7 +232,7 @@ public Builder initLanguage(boolean initLanguage) { * @return builder */ public Builder bindLoggerLanguage(boolean bindLoggerLanguage) { - this.bindLoggerLanguage = bindLoggerLanguage; + lang.bindLoggerLanguage(bindLoggerLanguage); return this; } @@ -191,7 +243,7 @@ public Builder bindLoggerLanguage(boolean bindLoggerLanguage) { * @return builder */ public Builder setMessagesManager(boolean setMessagesManager) { - this.setMessagesManager = setMessagesManager; + lang.setMessagesManager(setMessagesManager); return this; } @@ -202,7 +254,7 @@ public Builder setMessagesManager(boolean setMessagesManager) { * @return builder */ public Builder registerMessages(boolean registerMessages) { - this.registerMessages = registerMessages; + lang.registerMessages(registerMessages); return this; } @@ -213,7 +265,7 @@ public Builder registerMessages(boolean registerMessages) { * @return builder */ public Builder addMagicUtilsMessages(boolean addMagicUtilsMessages) { - this.addMagicUtilsMessages = addMagicUtilsMessages; + lang.addMagicUtilsMessages(addMagicUtilsMessages); return this; } @@ -224,7 +276,7 @@ public Builder addMagicUtilsMessages(boolean addMagicUtilsMessages) { * @return builder */ public Builder bindClientLocaleSync(boolean bindClientLocaleSync) { - this.bindClientLocaleSync = bindClientLocaleSync; + lang.bindClientLocaleSync(bindClientLocaleSync); return this; } @@ -235,7 +287,7 @@ public Builder bindClientLocaleSync(boolean bindClientLocaleSync) { * @return builder */ public Builder translations(Consumer translations) { - this.translations = translations; + lang.translations(translations); return this; } @@ -303,6 +355,40 @@ public Builder configureDiagnostics(Consumer diagnosticsConf return this; } + /** + * Enables cross-server messaging using the default plugin-messaging transport. + * + * @return builder + */ + public Builder enableMessaging() { + this.enableMessaging = true; + return this; + } + + /** + * Supplies Redis settings for messaging; when enabled, Redis is used. + * + * @param redis redis settings + * @return builder + */ + public Builder messagingRedis(RedisConfig.Redis redis) { + this.messagingRedis = redis; + this.enableMessaging = true; + return this; + } + + /** + * Allows configuring the messaging service builder before it is built. + * + * @param messagingConfigurer messaging builder callback + * @return builder + */ + public Builder configureMessaging(Consumer messagingConfigurer) { + this.messagingConfigurer = messagingConfigurer; + this.enableMessaging = true; + return this; + } + /** * Builds the bootstrap result without exposing the runtime wrapper. * @@ -323,17 +409,15 @@ public RuntimeResult buildRuntime() { MagicRuntime runtime = MagicRuntime.builder( prepared.platform(), prepared.configManager(), - prepared.logger() + prepared.logger().getCore() ) .languageManager(prepared.languageManager()) .manageConfigManager(configManager == null) .component(ProxyServer.class, proxy) + .component(Logger.class, prepared.logger()) .build(); - if (bindClientLocaleSync) { - runtime.manage("language.clientLocaleSync", - prepared.languageManager().bindClientLocaleSync(prepared.platform())); - } + lang.bindClientLocaleSync(runtime, prepared.platform(), prepared.languageManager()); if (prepared.commandRegistry() != null) { runtime.putComponent(CommandRegistry.class, prepared.commandRegistry()); @@ -344,16 +428,11 @@ public RuntimeResult buildRuntime() { if (enableDiagnostics) { DiagnosticsSupport.install(runtime, diagnosticsConfigurer); } - if (registerMessages) { - runtime.onClose("messages.scope", () -> Messages.unregister(pluginName)); - } - if (setMessagesManager) { - runtime.onClose("messages.default", () -> { - if (Messages.getLanguageManager() == prepared.languageManager()) { - Messages.setLanguageManager(null); - } - }); + if (enableMessaging) { + VelocityMessagingSupport.install( + runtime, proxy, plugin, pluginName, messagingRedis, messagingConfigurer); } + lang.installMessagesCloseHooks(runtime, pluginName, prepared.languageManager()); // Register this plugin in the shared-runtime consumer registry so the // standalone velocity-bundle command can list it @@ -422,36 +501,19 @@ private Prepared prepare() { ConfigManager resolvedConfigManager = configManager != null ? configManager : new ConfigManager(resolvedPlatform); - LoggerCore resolvedLogger = logger != null + Logger resolvedLogger = logger != null ? logger - : new LoggerCore(resolvedPlatform, resolvedConfigManager, plugin, pluginName); + : new Logger(resolvedPlatform, resolvedConfigManager, plugin, pluginName); LanguageManager resolvedLanguageManager = languageManager != null ? languageManager : new LanguageManager(resolvedPlatform, resolvedConfigManager); - if (initLanguage) { - resolvedLanguageManager.init(language); - } - if (translations != null) { - translations.accept(resolvedLanguageManager); - } - if (addMagicUtilsMessages) { - resolvedLanguageManager.addMagicUtilsMessages(); - } - if (registerMessages) { - Messages.register(pluginName, resolvedLanguageManager); - } - if (setMessagesManager) { - Messages.setLanguageManager(resolvedLanguageManager); - } - if (bindLoggerLanguage) { - resolvedLogger.setLanguageManager(resolvedLanguageManager); - } + lang.apply(pluginName, resolvedLanguageManager, resolvedLogger.getCore()); CommandRegistry registry = null; if (enableCommands) { String prefix = permissionPrefix != null ? permissionPrefix : pluginName; - registry = CommandRegistry.create(proxy, plugin, prefix, resolvedLogger, asyncExecutor); + registry = CommandRegistry.create(proxy, plugin, prefix, resolvedLogger.getCore(), asyncExecutor); if (commandConfigurer != null) { commandConfigurer.accept(registry); } @@ -470,7 +532,7 @@ private static String normalizePluginName(String pluginName) { private record Prepared( Platform platform, ConfigManager configManager, - LoggerCore logger, + Logger logger, LanguageManager languageManager, CommandRegistry commandRegistry ) { @@ -489,7 +551,7 @@ private record Prepared( public record Result( Platform platform, ConfigManager configManager, - LoggerCore logger, + Logger logger, LanguageManager languageManager, CommandRegistry commandRegistry ) { @@ -509,7 +571,7 @@ public record RuntimeResult( MagicRuntime runtime, Platform platform, ConfigManager configManager, - LoggerCore logger, + Logger logger, LanguageManager languageManager, CommandRegistry commandRegistry ) { diff --git a/platform-velocity/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java new file mode 100644 index 000000000..1a14eade8 --- /dev/null +++ b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java @@ -0,0 +1,84 @@ +package dev.ua.theroer.magicutils.logger; + +import com.velocitypowered.api.proxy.Player; +import dev.ua.theroer.magicutils.Logger; +import java.util.Collection; + +/** + * Velocity-specific log builder with player helpers. + * + *

The shared fluent methods ({@code to(Audience)}, {@code target}, + * {@code noPrefix}, ...) are inherited from {@link LogBuilderCore} and already + * return {@code LogBuilder} thanks to its self type; this class only adds the + * Velocity-typed recipient overloads. + */ +public class LogBuilder extends LogBuilderCore { + private final Logger logger; + + /** + * Creates a log builder for the specified level. + * + * @param logger parent logger + * @param level log level + */ + public LogBuilder(Logger logger, LogLevel level) { + super(logger.getCore(), level); + this.logger = logger; + } + + /** + * Adds a player recipient. + * + * @param player target player + * @return this builder + */ + public LogBuilder to(Player player) { + super.to(logger.wrapAudience(player)); + return this; + } + + /** + * Adds multiple player recipients. + * + * @param players target players + * @return this builder + */ + public LogBuilder to(Player... players) { + if (players != null) { + for (Player player : players) { + if (player != null) { + super.recipient(logger.wrapAudience(player)); + } + } + } + return this; + } + + /** + * Adds a collection of player recipients. + * + * @param players target players + * @return this builder + */ + public LogBuilder to(Collection players) { + if (players != null) { + for (Player player : players) { + if (player != null) { + super.recipient(logger.wrapAudience(player)); + } + } + } + return this; + } + + /** + * Adds a player as a recipient. + * + * @param player target player + * @return this builder + */ + public LogBuilder recipient(Player player) { + super.recipient(logger.wrapAudience(player)); + return this; + } +} diff --git a/platform-velocity/src/main/java/dev/ua/theroer/magicutils/logger/PrefixedLogger.java b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/logger/PrefixedLogger.java new file mode 100644 index 000000000..0f9a7e48a --- /dev/null +++ b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/logger/PrefixedLogger.java @@ -0,0 +1,96 @@ +package dev.ua.theroer.magicutils.logger; + +import com.velocitypowered.api.proxy.Player; +import dev.ua.theroer.magicutils.Logger; +import java.util.Collection; + +/** + * Logger instance with a custom prefix. + * All log methods are generated by the annotation processor. + */ +@LogMethods(staticMethods = false, audienceType = "com.velocitypowered.api.proxy.Player") +public class PrefixedLogger extends PrefixedLoggerMethods implements PrefixedLoggerAdapter { + private final Logger logger; + private final PrefixedLoggerCore core; + + /** + * Creates a prefixed logger wrapper. + * + * @param logger parent logger + * @param core prefixed core instance + */ + public PrefixedLogger(Logger logger, PrefixedLoggerCore core) { + this.logger = logger; + this.core = core; + } + + @Override + public PrefixedLoggerCore getCore() { + return core; + } + + @Override + public LoggerAdapter getLoggerAdapter() { + return logger; + } + + @Override + public LogBuilder buildLogBuilder(LogLevel level) { + return new PrefixedLogBuilder(level); + } + + @Override + public void send(LogLevel level, Object message) { + PrefixedLoggerAdapter.super.send(level, message); + } + + @Override + public void send(LogLevel level, Object message, Player player) { + PrefixedLoggerAdapter.super.send(level, message, player); + } + + @Override + public void send(LogLevel level, Object message, Player player, boolean all) { + PrefixedLoggerAdapter.super.send(level, message, player, all); + } + + @Override + public void sendToConsole(LogLevel level, Object message) { + PrefixedLoggerAdapter.super.sendToConsole(level, message); + } + + @Override + public void sendToPlayers(LogLevel level, Object message, Collection players) { + PrefixedLoggerAdapter.super.sendToPlayers(level, message, players); + } + + private class PrefixedLogBuilder extends LogBuilder { + PrefixedLogBuilder(LogLevel level) { + super(logger, level); + } + + @Override + protected void performSend(Object message, Object... placeholders) { + if (!core.isEnabled()) { + return; + } + LogTarget finalTarget = getTarget() != null ? getTarget() : logger.getCore().getDefaultTarget(); + Collection audienceRecipients = null; + if (!getRecipients().isEmpty()) { + audienceRecipients = getRecipients(); + } + logger.getCore().send( + level, + message, + getAudience(), + audienceRecipients, + finalTarget, + isBroadcast(), + new ConsoleMessageMetadata(level, core.getName()), + core.getPrefix(), + getPrefixOverride(), + placeholders + ); + } + } +} diff --git a/platform-velocity/src/main/java/dev/ua/theroer/magicutils/messaging/velocity/VelocityMessagingSupport.java b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/messaging/velocity/VelocityMessagingSupport.java new file mode 100644 index 000000000..aa9dfb0d8 --- /dev/null +++ b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/messaging/velocity/VelocityMessagingSupport.java @@ -0,0 +1,87 @@ +package dev.ua.theroer.magicutils.messaging.velocity; + +import java.util.UUID; +import java.util.function.Consumer; + +import com.velocitypowered.api.proxy.ProxyServer; + +import dev.ua.theroer.magicutils.bootstrap.MagicRuntime; +import dev.ua.theroer.magicutils.messaging.MessageCodec; +import dev.ua.theroer.magicutils.messaging.MessageSource; +import dev.ua.theroer.magicutils.messaging.MessageTransport; +import dev.ua.theroer.magicutils.messaging.MessagingService; +import dev.ua.theroer.magicutils.messaging.redis.RedisConfig; +import dev.ua.theroer.magicutils.platform.Platform; +import dev.ua.theroer.magicutils.platform.PlatformLogger; +import org.jetbrains.annotations.Nullable; + +/** + * Wires a {@link MessagingService} into a Velocity plugin's {@link MagicRuntime}. + * + *

When Redis is enabled the Redis transport is used; otherwise the default + * {@link VelocityPluginMessageTransport} is created and registered as a Velocity + * event listener so it receives backend {@code Forward} frames. Either way the + * service is registered as a runtime component and closed with the runtime.

+ */ +public final class VelocityMessagingSupport { + private VelocityMessagingSupport() { + } + + /** + * Installs the messaging service on the runtime. + * + * @param runtime plugin runtime + * @param proxy velocity proxy + * @param plugin owning plugin instance + * @param pluginName plugin name (used to derive the member id) + * @param redis redis settings, or null to force the default transport + * @param configurer optional callback to tweak the service builder + * @return the installed service + */ + public static MessagingService install( + MagicRuntime runtime, + ProxyServer proxy, + Object plugin, + String pluginName, + @Nullable RedisConfig.Redis redis, + @Nullable Consumer configurer) { + Platform platform = runtime.platform(); + PlatformLogger logger = platform.logger(); + MessageCodec codec = new MessageCodec(); + + MessageSource self = MessageSource.proxy(pluginName + ":" + UUID.randomUUID()); + + // Track the default transport so it can be registered/unregistered as an + // event listener; a Redis transport needs no Velocity listener. + VelocityPluginMessageTransport[] defaultTransport = new VelocityPluginMessageTransport[1]; + + MessagingService.Builder builder = MessagingService.builder(self) + .logger(logger) + .redis(redis) + .codec(codec) + .defaultTransport(() -> { + VelocityPluginMessageTransport transport = + new VelocityPluginMessageTransport(proxy, plugin, codec, logger); + defaultTransport[0] = transport; + return transport; + }); + + if (configurer != null) { + configurer.accept(builder); + } + + MessagingService service = builder.build(); + + MessageTransport active = service.bus().transport(); + if (active == defaultTransport[0] && defaultTransport[0] != null) { + proxy.getEventManager().register(plugin, defaultTransport[0]); + runtime.onClose("messaging.listener", + () -> proxy.getEventManager().unregisterListener(plugin, defaultTransport[0])); + } + + runtime.putComponent(MessagingService.class, service); + runtime.manage("messaging.service", service); + logger.info("MagicUtils messaging enabled (transport: " + service.transportName() + ")"); + return service; + } +} diff --git a/platform-velocity/src/main/java/dev/ua/theroer/magicutils/messaging/velocity/VelocityPluginMessageTransport.java b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/messaging/velocity/VelocityPluginMessageTransport.java new file mode 100644 index 000000000..a815c9933 --- /dev/null +++ b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/messaging/velocity/VelocityPluginMessageTransport.java @@ -0,0 +1,168 @@ +package dev.ua.theroer.magicutils.messaging.velocity; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Objects; + +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.connection.PluginMessageEvent; +import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.api.proxy.ServerConnection; +import com.velocitypowered.api.proxy.messages.LegacyChannelIdentifier; +import com.velocitypowered.api.proxy.server.RegisteredServer; + +import dev.ua.theroer.magicutils.messaging.MessageCodec; +import dev.ua.theroer.magicutils.messaging.MessageEnvelope; +import dev.ua.theroer.magicutils.messaging.MessageTransport; +import dev.ua.theroer.magicutils.messaging.Target; +import dev.ua.theroer.magicutils.platform.PlatformLogger; + +/** + * Default (Redis-less) messaging transport for the Velocity proxy. + * + *

Listens on the BungeeCord plugin channel for {@code Forward} frames sent by + * backends and hands the embedded envelope to the bus so proxy-side subscribers + * receive backend messages. To send from the proxy to backends, it wraps the + * envelope in a {@code Forward} frame and dispatches it to the relevant + * server(s), which Velocity relays to that server's backend plugin.

+ * + *

The transport must be registered as a Velocity event listener via + * {@code proxy.getEventManager().register(plugin, transport)} (done by the + * bootstrap wiring).

+ */ +public final class VelocityPluginMessageTransport implements MessageTransport { + private static final LegacyChannelIdentifier BUNGEE_CHANNEL = new LegacyChannelIdentifier("BungeeCord"); + private static final String FORWARD_SUBCHANNEL = "magicutils:bus"; + + private final ProxyServer proxy; + private final Object plugin; + private final MessageCodec codec; + private final PlatformLogger logger; + + private volatile EnvelopeSink sink; + private volatile boolean running; + + /** + * Creates the transport. + * + * @param proxy velocity proxy + * @param plugin owning plugin instance (for channel registration) + * @param codec envelope codec + * @param logger platform logger + */ + public VelocityPluginMessageTransport(ProxyServer proxy, Object plugin, MessageCodec codec, PlatformLogger logger) { + this.proxy = Objects.requireNonNull(proxy, "proxy"); + this.plugin = Objects.requireNonNull(plugin, "plugin"); + this.codec = Objects.requireNonNull(codec, "codec"); + this.logger = Objects.requireNonNull(logger, "logger"); + } + + @Override + public String name() { + return "plugin-messaging"; + } + + @Override + public void start(EnvelopeSink sink) { + this.sink = Objects.requireNonNull(sink, "sink"); + proxy.getChannelRegistrar().register(BUNGEE_CHANNEL); + this.running = true; + } + + @Override + public void publish(MessageEnvelope envelope) { + if (!running) { + throw new IllegalStateException("Transport is not running"); + } + byte[] payload = codec.encode(envelope); + byte[] frame = buildForwardFrame(payload); + Target target = envelope.target(); + if (target.kind() == Target.Kind.SERVER && target.serverName() != null) { + proxy.getServer(target.serverName()) + .ifPresent(server -> sendToServer(server, frame)); + } else { + // BROADCAST / ALL_BACKENDS / PLAYER: send to every backend, bus filters. + for (RegisteredServer server : proxy.getAllServers()) { + sendToServer(server, frame); + } + } + } + + @Override + public boolean isConnected() { + return running; + } + + /** + * Handles inbound BungeeCord {@code Forward} frames from backends. + * + * @param event plugin message event + */ + @Subscribe + public void onPluginMessage(PluginMessageEvent event) { + if (!running || !(event.getIdentifier().getId().equals(BUNGEE_CHANNEL.getId()))) { + return; + } + // Only handle messages originating from a backend server connection. + if (!(event.getSource() instanceof ServerConnection)) { + return; + } + byte[] data = event.getData(); + try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(data))) { + String subChannel = in.readUTF(); + if (!"Forward".equals(subChannel)) { + return; + } + in.readUTF(); // destination ("ALL" or server name) - Velocity already routes backend↔backend + String forwardChannel = in.readUTF(); + if (!FORWARD_SUBCHANNEL.equals(forwardChannel)) { + return; + } + short length = in.readShort(); + byte[] payload = new byte[length]; + in.readFully(payload); + EnvelopeSink target = sink; + if (target != null) { + target.accept(codec.decode(payload)); + } + } catch (IOException error) { + logger.warn("Failed to read Forward frame on the proxy", error); + } + } + + @Override + public void close() { + running = false; + sink = null; + try { + proxy.getChannelRegistrar().unregister(BUNGEE_CHANNEL); + } catch (RuntimeException ignored) { + // registrar may already be gone during shutdown + } + } + + private void sendToServer(RegisteredServer server, byte[] frame) { + try { + server.sendPluginMessage(BUNGEE_CHANNEL, frame); + } catch (RuntimeException error) { + logger.warn("Failed to send plugin message to server '" + server.getServerInfo().getName() + "'", error); + } + } + + private byte[] buildForwardFrame(byte[] payload) { + try (ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bytes)) { + // Proxy → backend: a plain sub-channel frame the backend reads as our bus. + out.writeUTF(FORWARD_SUBCHANNEL); + out.writeShort(payload.length); + out.write(payload); + return bytes.toByteArray(); + } catch (IOException error) { + throw new UncheckedIOException("Failed to build proxy frame", error); + } + } +} diff --git a/platform-velocity/src/main/java/dev/ua/theroer/magicutils/platform/velocity/VelocityAudience.java b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/platform/velocity/VelocityAudience.java index 675222165..f70bb5de0 100644 --- a/platform-velocity/src/main/java/dev/ua/theroer/magicutils/platform/velocity/VelocityAudience.java +++ b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/platform/velocity/VelocityAudience.java @@ -10,11 +10,16 @@ /** * Wraps a Velocity command source as a MagicUtils audience. */ -final class VelocityAudience implements Audience { +public final class VelocityAudience implements Audience { private final CommandSource source; private final UUID id; - VelocityAudience(CommandSource source) { + /** + * Wraps a Velocity command source (player or console) as an audience. + * + * @param source command source to wrap + */ + public VelocityAudience(CommandSource source) { this.source = source; this.id = source instanceof Player player ? player.getUniqueId() : null; } diff --git a/processor/build.gradle.kts b/processor/build.gradle.kts index b1df435b0..6d26cc6bf 100644 --- a/processor/build.gradle.kts +++ b/processor/build.gradle.kts @@ -8,7 +8,11 @@ plugins { } magicutilsPublish { - category = MagicUtilsPublishCategory.COMMON_MATRIX + // Build-only: applied as an annotationProcessor to the library modules (see + // MagicUtilsAnnotationProcessingPlugin), its generated code is baked into + // those jars and it never appears as a runtime dependency in any published + // POM. Downstreams don't need it, so it is not published. + category = MagicUtilsPublishCategory.NONE } java { diff --git a/settings.gradle.kts b/settings.gradle.kts index e3b2508ab..8c4c005d0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -40,6 +40,7 @@ magicMatrix { "commands-brigadier", "placeholders", "http-client", + "messaging", "config-yaml", "config-toml", "diagnostics", diff --git a/velocity-bundle/src/main/java/dev/ua/theroer/magicutils/velocity/MagicUtilsVelocityBundlePlugin.java b/velocity-bundle/src/main/java/dev/ua/theroer/magicutils/velocity/MagicUtilsVelocityBundlePlugin.java index 79dc86971..e6c0bc201 100644 --- a/velocity-bundle/src/main/java/dev/ua/theroer/magicutils/velocity/MagicUtilsVelocityBundlePlugin.java +++ b/velocity-bundle/src/main/java/dev/ua/theroer/magicutils/velocity/MagicUtilsVelocityBundlePlugin.java @@ -86,7 +86,7 @@ public void onProxyInitialize(ProxyInitializeEvent event) { .buildRuntime(); runtime = bootstrap.runtime(); - LoggerCore loggerCore = bootstrap.logger(); + LoggerCore loggerCore = bootstrap.logger().getCore(); CommandRegistry commandRegistry = bootstrap.commandRegistry(); if (commandRegistry != null) { // Register the diagnostics suite-name parser so