From 4b4abc0a1cbfb2f349669bf765536c65c686a1bb Mon Sep 17 00:00:00 2001 From: LeeGodSRC Date: Tue, 20 Jan 2026 17:44:36 +0800 Subject: [PATCH 01/20] feat: add Hytale platform support with bootstrap, platform module, and gradle plugin --- .../bootstrap/type/PlatformType.java | 3 +- .../hytale-bootstrap/build.gradle.kts | 8 + .../hytale/HytalePlatformBootstrap.java | 60 +++++ .../bootstrap/hytale/HytalePlugin.java | 163 ++++++++++++++ .../bootstrap/hytale/HytalePluginAction.java | 58 +++++ .../hytale/HytalePluginInstance.java | 46 ++++ framework/bootstraps/settings.gradle.kts | 1 + .../java/io/fairyproject/PlatformType.java | 3 +- .../hytale-platform/build.gradle.kts | 9 + .../hytale/FairyHytalePlatform.java | 105 +++++++++ .../hytale/plugin/HytalePluginHandler.java | 44 ++++ framework/platforms/settings.gradle.kts | 1 + .../fairyproject/gradle/FairyGradlePlugin.kt | 2 + .../gradle/extension/FairyExtension.kt | 10 + .../gradle/extension/property/Properties.kt | 66 ++++++ .../gradle/platform/PlatformType.kt | 3 +- .../gradle/resource/FairyResource.kt | 8 +- .../gradle/resource/FairyResourceAction.kt | 16 +- .../gradle/resource/FairyResourcePlugin.kt | 13 +- .../resource/impl/FairyResourceHytaleMeta.kt | 113 ++++++++++ .../runner/hytale/HytaleServerArtifact.kt | 70 ++++++ .../runner/hytale/RunHytaleServerExtension.kt | 83 +++++++ .../runner/hytale/RunHytaleServerPlugin.kt | 208 ++++++++++++++++++ .../hytale/action/CopyHytaleSnapshotAction.kt | 62 ++++++ .../hytale/task/PrepareHytaleBuildTask.kt | 194 ++++++++++++++++ .../task/PrepareHytaleDownloaderTask.kt | 161 ++++++++++++++ .../runner/hytale/task/RunHytaleServerTask.kt | 68 ++++++ 27 files changed, 1568 insertions(+), 10 deletions(-) create mode 100644 framework/bootstraps/hytale-bootstrap/build.gradle.kts create mode 100644 framework/bootstraps/hytale-bootstrap/src/main/java/io/fairyproject/bootstrap/hytale/HytalePlatformBootstrap.java create mode 100644 framework/bootstraps/hytale-bootstrap/src/main/java/io/fairyproject/bootstrap/hytale/HytalePlugin.java create mode 100644 framework/bootstraps/hytale-bootstrap/src/main/java/io/fairyproject/bootstrap/hytale/HytalePluginAction.java create mode 100644 framework/bootstraps/hytale-bootstrap/src/main/java/io/fairyproject/bootstrap/hytale/HytalePluginInstance.java create mode 100644 framework/platforms/hytale-platform/build.gradle.kts create mode 100644 framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/FairyHytalePlatform.java create mode 100644 framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/plugin/HytalePluginHandler.java create mode 100644 gradle-plugin/src/main/kotlin/io/fairyproject/gradle/resource/impl/FairyResourceHytaleMeta.kt create mode 100644 gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/HytaleServerArtifact.kt create mode 100644 gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerExtension.kt create mode 100644 gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerPlugin.kt create mode 100644 gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/action/CopyHytaleSnapshotAction.kt create mode 100644 gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/PrepareHytaleBuildTask.kt create mode 100644 gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/PrepareHytaleDownloaderTask.kt create mode 100644 gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/RunHytaleServerTask.kt diff --git a/framework/bootstraps/core-bootstrap/src/main/java/io/fairyproject/bootstrap/type/PlatformType.java b/framework/bootstraps/core-bootstrap/src/main/java/io/fairyproject/bootstrap/type/PlatformType.java index b9b473a9..510568e3 100644 --- a/framework/bootstraps/core-bootstrap/src/main/java/io/fairyproject/bootstrap/type/PlatformType.java +++ b/framework/bootstraps/core-bootstrap/src/main/java/io/fairyproject/bootstrap/type/PlatformType.java @@ -6,6 +6,7 @@ public enum PlatformType { BUNGEE, VELOCITY, NUKKIT, - APP + APP, + HYTALE } diff --git a/framework/bootstraps/hytale-bootstrap/build.gradle.kts b/framework/bootstraps/hytale-bootstrap/build.gradle.kts new file mode 100644 index 00000000..1c189acb --- /dev/null +++ b/framework/bootstraps/hytale-bootstrap/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + id("io.fairyproject.bootstrap") +} + +dependencies { + api(project(":core-bootstrap")) + compileOnly("io.fairyproject:hytale-platform") +} \ No newline at end of file diff --git a/framework/bootstraps/hytale-bootstrap/src/main/java/io/fairyproject/bootstrap/hytale/HytalePlatformBootstrap.java b/framework/bootstraps/hytale-bootstrap/src/main/java/io/fairyproject/bootstrap/hytale/HytalePlatformBootstrap.java new file mode 100644 index 00000000..7d304c2c --- /dev/null +++ b/framework/bootstraps/hytale-bootstrap/src/main/java/io/fairyproject/bootstrap/hytale/HytalePlatformBootstrap.java @@ -0,0 +1,60 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.fairyproject.bootstrap.hytale; + +import io.fairyproject.FairyPlatform; +import io.fairyproject.bootstrap.platform.AbstractPlatformBootstrap; +import io.fairyproject.bootstrap.type.PlatformType; +import io.fairyproject.hytale.FairyHytalePlatform; +import lombok.Getter; +import org.jetbrains.annotations.Nullable; + +@Getter +class HytalePlatformBootstrap extends AbstractPlatformBootstrap { + + @Override + protected void onFailure(@Nullable Throwable throwable) { + if (throwable != null) { + throwable.printStackTrace(); + } + if (HytalePlugin.INSTANCE != null && HytalePlugin.INSTANCE.getShutdownAction() != null) { + HytalePlugin.INSTANCE.getShutdownAction().run(); + } + } + + @Override + protected PlatformType getPlatformType() { + return PlatformType.HYTALE; + } + + @Override + protected FairyPlatform createPlatform() { + return new FairyHytalePlatform( + HytalePlugin.INSTANCE, + HytalePlugin.INSTANCE.getShutdownAction(), + HytalePlugin.INSTANCE.getDataDirectory().toFile() + ); + } +} diff --git a/framework/bootstraps/hytale-bootstrap/src/main/java/io/fairyproject/bootstrap/hytale/HytalePlugin.java b/framework/bootstraps/hytale-bootstrap/src/main/java/io/fairyproject/bootstrap/hytale/HytalePlugin.java new file mode 100644 index 00000000..e77926b7 --- /dev/null +++ b/framework/bootstraps/hytale-bootstrap/src/main/java/io/fairyproject/bootstrap/hytale/HytalePlugin.java @@ -0,0 +1,163 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.fairyproject.bootstrap.hytale; + +import com.google.gson.JsonObject; +import com.hypixel.hytale.server.core.plugin.JavaPlugin; +import com.hypixel.hytale.server.core.plugin.JavaPluginInit; +import io.fairyproject.bootstrap.PluginClassInitializerFinder; +import io.fairyproject.bootstrap.PluginFileReader; +import io.fairyproject.bootstrap.instance.PluginInstance; +import io.fairyproject.bootstrap.internal.FairyInternalIdentityMeta; +import io.fairyproject.bootstrap.platform.PlatformBootstrap; +import lombok.AccessLevel; +import lombok.Setter; +import org.jetbrains.annotations.NotNull; + +@FairyInternalIdentityMeta +public final class HytalePlugin extends JavaPlugin { + + public static HytalePlugin INSTANCE; + + private final PluginInstance instance; + private final PluginFileReader pluginFileReader; + private final PlatformBootstrap bootstrap; + private Runnable shutdownAction; + + @Setter(AccessLevel.PACKAGE) + private boolean loaded; + + /** + * Constructor with dependency injection for testing purposes. + * + * @param init the Hytale plugin init object + * @param instance the plugin instance + * @param pluginFileReader the plugin file reader + * @param bootstrap the platform bootstrap + * @param shutdownAction the action to run on shutdown (nullable, will use default if null) + */ + public HytalePlugin(@NotNull JavaPluginInit init, + PluginInstance instance, + PluginFileReader pluginFileReader, + PlatformBootstrap bootstrap, + Runnable shutdownAction) { + super(init); + this.instance = instance; + this.pluginFileReader = pluginFileReader; + this.bootstrap = bootstrap; + this.shutdownAction = shutdownAction; + } + + /** + * Default constructor for Hytale server. + * + * @param init the Hytale plugin init object + */ + public HytalePlugin(@NotNull JavaPluginInit init) { + this( + init, + new HytalePluginInstance(PluginClassInitializerFinder.find()), + new PluginFileReader(), + new HytalePlatformBootstrap(), + null // will be initialized lazily + ); + } + + /** + * Get the shutdown action to be used by the platform. + * Lazily initializes to server stop if not set. + * + * @return the shutdown action + */ + public Runnable getShutdownAction() { + if (this.shutdownAction == null) { + // Use reflection to call server stop to avoid compile-time API dependency issues + this.shutdownAction = () -> { + try { + Object server = this.getClass().getMethod("getServer").invoke(this); + if (server != null) { + server.getClass().getMethod("stop").invoke(server); + } + } catch (Exception e) { + System.err.println("[Fairy] Failed to stop server: " + e.getMessage()); + } + }; + } + return this.shutdownAction; + } + + /** + * Called by Hytale during plugin setup phase. + * Maps to Bukkit's onLoad() lifecycle. + * Performs: preload + load + instance.onLoad + */ + @Override + protected void setup() { + if (this.loaded) + return; + INSTANCE = this; + + if (!this.bootstrap.preload()) { + System.err.println("[Fairy] Failed to boot fairy! check stacktrace for the reason of failure!"); + this.getShutdownAction().run(); + return; + } + + JsonObject jsonObject = pluginFileReader.read(this.getClass()); + this.instance.init(jsonObject); + this.bootstrap.load(this.instance.getPlugin()); + this.instance.onLoad(); + + this.loaded = true; + } + + /** + * Called by Hytale during plugin start phase. + * Maps to Bukkit's onEnable() lifecycle. + * Performs: enable + instance.onEnable + */ + @Override + protected void start() { + if (!this.loaded) + throw new IllegalStateException("Plugin not loaded yet!"); + + this.bootstrap.enable(); + this.instance.onEnable(); + } + + /** + * Called by Hytale during plugin shutdown phase. + * Maps to Bukkit's onDisable() lifecycle. + * Performs: instance.onDisable + disable + */ + @Override + protected void shutdown() { + if (!this.loaded) + return; + + this.instance.onDisable(); + this.bootstrap.disable(); + } +} diff --git a/framework/bootstraps/hytale-bootstrap/src/main/java/io/fairyproject/bootstrap/hytale/HytalePluginAction.java b/framework/bootstraps/hytale-bootstrap/src/main/java/io/fairyproject/bootstrap/hytale/HytalePluginAction.java new file mode 100644 index 00000000..c9cfbf35 --- /dev/null +++ b/framework/bootstraps/hytale-bootstrap/src/main/java/io/fairyproject/bootstrap/hytale/HytalePluginAction.java @@ -0,0 +1,58 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.fairyproject.bootstrap.hytale; + +import com.hypixel.hytale.server.core.plugin.JavaPlugin; +import io.fairyproject.plugin.PluginAction; + +import java.nio.file.Path; + +public class HytalePluginAction implements PluginAction { + + private final JavaPlugin hytalePlugin; + private boolean closed; + + public HytalePluginAction(JavaPlugin hytalePlugin) { + this.hytalePlugin = hytalePlugin; + this.closed = false; + } + + @Override + public void close() { + this.closed = true; + // Hytale doesn't have a direct plugin disable mechanism like Bukkit + // The plugin lifecycle is managed by the server + } + + @Override + public boolean isClosed() { + return this.closed; + } + + @Override + public Path getDataFolder() { + return this.hytalePlugin.getDataDirectory(); + } +} diff --git a/framework/bootstraps/hytale-bootstrap/src/main/java/io/fairyproject/bootstrap/hytale/HytalePluginInstance.java b/framework/bootstraps/hytale-bootstrap/src/main/java/io/fairyproject/bootstrap/hytale/HytalePluginInstance.java new file mode 100644 index 00000000..d2c1664a --- /dev/null +++ b/framework/bootstraps/hytale-bootstrap/src/main/java/io/fairyproject/bootstrap/hytale/HytalePluginInstance.java @@ -0,0 +1,46 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.fairyproject.bootstrap.hytale; + +import io.fairyproject.bootstrap.instance.AbstractPluginInstance; +import io.fairyproject.plugin.PluginAction; +import io.fairyproject.plugin.initializer.PluginClassInitializer; + +final class HytalePluginInstance extends AbstractPluginInstance { + + public HytalePluginInstance(PluginClassInitializer initializer) { + super(initializer); + } + + @Override + protected ClassLoader getClassLoader() { + return HytalePlugin.INSTANCE.getClass().getClassLoader(); + } + + @Override + protected PluginAction getPluginAction() { + return new HytalePluginAction(HytalePlugin.INSTANCE); + } +} diff --git a/framework/bootstraps/settings.gradle.kts b/framework/bootstraps/settings.gradle.kts index 09cd1d38..94a86293 100644 --- a/framework/bootstraps/settings.gradle.kts +++ b/framework/bootstraps/settings.gradle.kts @@ -11,4 +11,5 @@ includeBuild("../tests") include(":core-bootstrap") include(":bukkit-bootstrap") include(":app-bootstrap") +include(":hytale-bootstrap") include(":bootstrap-bom") \ No newline at end of file diff --git a/framework/platforms/core-platform/src/main/java/io/fairyproject/PlatformType.java b/framework/platforms/core-platform/src/main/java/io/fairyproject/PlatformType.java index d6f2ad29..2738e13c 100644 --- a/framework/platforms/core-platform/src/main/java/io/fairyproject/PlatformType.java +++ b/framework/platforms/core-platform/src/main/java/io/fairyproject/PlatformType.java @@ -7,6 +7,7 @@ public enum PlatformType { MC, VELOCITY, NUKKIT, - APP + APP, + HYTALE } diff --git a/framework/platforms/hytale-platform/build.gradle.kts b/framework/platforms/hytale-platform/build.gradle.kts new file mode 100644 index 00000000..3df2d311 --- /dev/null +++ b/framework/platforms/hytale-platform/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + id("io.fairyproject.platform") +} + + +dependencies { + api(project(":core-platform")) + compileOnlyApi("dev.imanity.hytale:HytaleServer:2026.01.17-2") +} diff --git a/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/FairyHytalePlatform.java b/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/FairyHytalePlatform.java new file mode 100644 index 00000000..cea17062 --- /dev/null +++ b/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/FairyHytalePlatform.java @@ -0,0 +1,105 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.fairyproject.hytale; + +import com.hypixel.hytale.server.core.plugin.PluginBase; +import io.fairyproject.FairyPlatform; +import io.fairyproject.PlatformType; +import io.fairyproject.hytale.plugin.HytalePluginHandler; +import io.fairyproject.plugin.PluginManager; +import io.fairyproject.util.URLClassLoaderAccess; +import io.fairyproject.util.terminable.Terminable; +import io.fairyproject.util.terminable.TerminableConsumer; +import io.fairyproject.util.terminable.composite.CompositeTerminable; +import org.jetbrains.annotations.NotNull; + +import java.io.File; +import java.net.URLClassLoader; + +public class FairyHytalePlatform extends FairyPlatform implements TerminableConsumer { + + public static PluginBase PLUGIN; + private static Runnable shutdownCallback; + + private final URLClassLoaderAccess classLoader; + private final File dataFolder; + private final CompositeTerminable compositeTerminable; + + public FairyHytalePlatform(PluginBase plugin, Runnable shutdownCallback, File dataFolder) { + FairyPlatform.INSTANCE = this; + PLUGIN = plugin; + FairyHytalePlatform.shutdownCallback = shutdownCallback; + + this.dataFolder = dataFolder; + this.compositeTerminable = CompositeTerminable.create(); + ClassLoader classLoader = this.getClass().getClassLoader(); + if (classLoader instanceof URLClassLoader) { + this.classLoader = URLClassLoaderAccess.create((URLClassLoader) classLoader); + } else { + this.classLoader = URLClassLoaderAccess.create(null); + } + + PluginManager.initialize(new HytalePluginHandler()); + } + + @NotNull + @Override + public T bind(@NotNull T terminable) { + return this.compositeTerminable.bind(terminable); + } + + @Override + public void saveResource(String name, boolean replace) { + // Hytale doesn't have a built-in saveResource mechanism like Bukkit + // This can be implemented later if needed + } + + @Override + public URLClassLoaderAccess getClassloader() { + return this.classLoader; + } + + @Override + public File getDataFolder() { + return this.dataFolder; + } + + @Override + public void shutdown() { + if (shutdownCallback != null) { + shutdownCallback.run(); + } + } + + @Override + public boolean isRunning() { + return true; + } + + @Override + public PlatformType getPlatformType() { + return PlatformType.HYTALE; + } +} diff --git a/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/plugin/HytalePluginHandler.java b/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/plugin/HytalePluginHandler.java new file mode 100644 index 00000000..f619fe95 --- /dev/null +++ b/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/plugin/HytalePluginHandler.java @@ -0,0 +1,44 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.fairyproject.hytale.plugin; + +import io.fairyproject.hytale.FairyHytalePlatform; +import io.fairyproject.plugin.PluginHandler; +import org.jetbrains.annotations.Nullable; + +public class HytalePluginHandler implements PluginHandler { + + @Override + public @Nullable String getPluginByClass(Class type) { + // Hytale doesn't have a built-in mechanism to resolve plugin by class like Bukkit's JavaPluginLoader + // Return the main plugin name as fallback + if (FairyHytalePlatform.PLUGIN != null) { + // getIdentifier() returns ResourceLocation in "Group:Name" format + return FairyHytalePlatform.PLUGIN.getIdentifier().toString(); + } + return null; + } + +} diff --git a/framework/platforms/settings.gradle.kts b/framework/platforms/settings.gradle.kts index f10968bd..c32bf4d8 100644 --- a/framework/platforms/settings.gradle.kts +++ b/framework/platforms/settings.gradle.kts @@ -11,4 +11,5 @@ include(":core-platform") include(":app-platform") include(":mc-platform") include(":bukkit-platform") +include(":hytale-platform") include(":platforms-bom") diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/FairyGradlePlugin.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/FairyGradlePlugin.kt index 1026e843..4b26211e 100644 --- a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/FairyGradlePlugin.kt +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/FairyGradlePlugin.kt @@ -6,6 +6,7 @@ import io.fairyproject.gradle.dependency.DependencyManagementPluginAction import io.fairyproject.gradle.extension.FairyExtension import io.fairyproject.gradle.resource.FairyResourcePlugin import io.fairyproject.gradle.runner.RunServerPlugin +import io.fairyproject.gradle.runner.hytale.RunHytaleServerPlugin import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.plugins.GroovyPlugin @@ -31,6 +32,7 @@ class FairyGradlePlugin : Plugin { project.plugins.apply(JavaBasePlugin::class.java) project.plugins.apply(FairyResourcePlugin::class.java) project.plugins.apply(RunServerPlugin::class.java) + project.plugins.apply(RunHytaleServerPlugin::class.java) sourceSets = project.extensions.getByType(JavaPluginExtension::class.java).sourceSets project.plugins.withType(JavaPlugin::class.java) { configurePlugin(project, "java") } diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/extension/FairyExtension.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/extension/FairyExtension.kt index 8b9a585e..a7521449 100644 --- a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/extension/FairyExtension.kt +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/extension/FairyExtension.kt @@ -1,6 +1,7 @@ package io.fairyproject.gradle.extension import io.fairyproject.gradle.extension.property.BukkitProperties +import io.fairyproject.gradle.extension.property.HytaleProperties import io.fairyproject.gradle.extension.property.Properties import io.fairyproject.gradle.platform.PlatformType import org.gradle.api.model.ObjectFactory @@ -28,5 +29,14 @@ open class FairyExtension(objectFactory: ObjectFactory) { */ fun bukkitPropertiesRaw(): Map = this.properties.computeIfAbsent(PlatformType.BUKKIT) { BukkitProperties() } + /** + * Get properties for hytale platform + */ + fun hytaleProperties(): HytaleProperties = this.properties.computeIfAbsent(PlatformType.HYTALE) { HytaleProperties() } as HytaleProperties + + /** + * Get properties for hytale platform in raw format + */ + fun hytalePropertiesRaw(): Map = this.properties.computeIfAbsent(PlatformType.HYTALE) { HytaleProperties() } } \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/extension/property/Properties.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/extension/property/Properties.kt index cab01d8e..3da95093 100644 --- a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/extension/property/Properties.kt +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/extension/property/Properties.kt @@ -63,4 +63,70 @@ class BukkitProperties : Properties(PlatformType.BUKKIT) { list } +} + +/** + * Author information for Hytale plugin. + */ +data class HytaleAuthor( + var name: String = "", + var email: String = "", + var url: String = "" +) + +/** + * The properties of a Hytale plugin. + */ +class HytaleProperties : Properties(PlatformType.HYTALE) { + + var group: String + get() = this["Group"] as? String ?: "" + set(value) { this["Group"] = value } + + var website: String + get() = this["Website"] as? String ?: "" + set(value) { this["Website"] = value } + + var serverVersion: String + get() = this["ServerVersion"] as? String ?: "" + set(value) { this["ServerVersion"] = value } + + var disabledByDefault: Boolean + get() = this["DisabledByDefault"] as? Boolean ?: false + set(value) { this["DisabledByDefault"] = value } + + var includesAssetPack: Boolean + get() = this["IncludesAssetPack"] as? Boolean ?: true + set(value) { this["IncludesAssetPack"] = value } + + val authors: MutableList by lazy { + val list = mutableListOf() + this["Authors"] = list + list + } + + val dependencies: MutableMap by lazy { + val map = mutableMapOf() + this["Dependencies"] = map + map + } + + val optionalDependencies: MutableMap by lazy { + val map = mutableMapOf() + this["OptionalDependencies"] = map + map + } + + val loadBefore: MutableMap by lazy { + val map = mutableMapOf() + this["LoadBefore"] = map + map + } + + val subPlugins: MutableList by lazy { + val list = mutableListOf() + this["SubPlugins"] = list + list + } + } \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/platform/PlatformType.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/platform/PlatformType.kt index b403657b..3c0764b4 100644 --- a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/platform/PlatformType.kt +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/platform/PlatformType.kt @@ -6,7 +6,8 @@ package io.fairyproject.gradle.platform enum class PlatformType { BUKKIT, APP, - CORE; + CORE, + HYTALE; val dependencyName: String get() = name.lowercase() diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/resource/FairyResource.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/resource/FairyResource.kt index 5bdfc28f..f821137e 100644 --- a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/resource/FairyResource.kt +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/resource/FairyResource.kt @@ -1,6 +1,7 @@ package io.fairyproject.gradle.resource import io.fairyproject.gradle.resource.impl.FairyResourceBukkitMeta +import io.fairyproject.gradle.resource.impl.FairyResourceHytaleMeta import io.fairyproject.gradle.resource.impl.FairyResourcePluginMeta /** @@ -20,7 +21,8 @@ interface FairyResource { val ALL = arrayOf( FairyResourcePluginMeta(), - FairyResourceBukkitMeta() + FairyResourceBukkitMeta(), + FairyResourceHytaleMeta() ) } @@ -35,10 +37,12 @@ data class FairyResourceGenerateContext( val projectVersion: String, val projectDescription: String, val hasBukkitPlatform: Boolean, + val hasHytalePlatform: Boolean, private val _pluginName: String?, val mainPackage: String?, val fairyPackage: String?, - val props: Map + val props: Map, + val hytaleProps: Map ) { val pluginName: String get() = _pluginName ?: projectName diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/resource/FairyResourceAction.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/resource/FairyResourceAction.kt index e1a25855..71be3dae 100644 --- a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/resource/FairyResourceAction.kt +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/resource/FairyResourceAction.kt @@ -31,6 +31,9 @@ abstract class FairyResourceAction @Inject constructor() : Action { @get:Input abstract val hasBukkitPlatform: Property + @get:Input + abstract val hasHytalePlatform: Property + override fun execute(task: Task) { val jar = task as Jar val file = jar.archiveFile.get().asFile @@ -66,8 +69,10 @@ abstract class FairyResourceAction @Inject constructor() : Action { return } - classMapper[ClassType.BUKKIT_PLUGIN] ?: run { - println("[Fairy] Bukkit plugin class not found, no resources will be generated.") + val hasBukkitPluginClass = classMapper.containsKey(ClassType.BUKKIT_PLUGIN) + val hasHytalePluginClass = classMapper.containsKey(ClassType.HYTALE_PLUGIN) + if (!hasBukkitPluginClass && !hasHytalePluginClass) { + println("[Fairy] No platform plugin class found (BukkitPlugin or HytalePlugin), no resources will be generated.") return } @@ -80,10 +85,12 @@ abstract class FairyResourceAction @Inject constructor() : Action { info.version, info.description, hasBukkitPlatform.get(), + hasHytalePlatform.get(), extension.get().name.orNull, extension.get().mainPackage.orNull, extension.get().fairyPackage.orNull, - extension.get().bukkitPropertiesRaw() + extension.get().bukkitPropertiesRaw(), + extension.get().hytalePropertiesRaw() ), classMapper )?.let { resource -> output.putNextEntry(JarEntry(resource.name)) @@ -156,6 +163,7 @@ abstract class FairyResourceAction @Inject constructor() : Action { private fun shouldExcludeFile(jarEntry: JarEntry): Boolean { if (jarEntry.name.equals("module.json")) return true if (jarEntry.name.equals("plugin.yml")) return true + if (jarEntry.name.equals("manifest.json")) return true return false } @@ -176,7 +184,7 @@ abstract class FairyResourceAction @Inject constructor() : Action { * Class type. */ enum class ClassType(vararg val names: String) { - MAIN_CLASS, MAIN_CLASS_INTERFACE("Plugin", "Application"), BUKKIT_PLUGIN("BukkitPlugin"); + MAIN_CLASS, MAIN_CLASS_INTERFACE("Plugin", "Application"), BUKKIT_PLUGIN("BukkitPlugin"), HYTALE_PLUGIN("HytalePlugin"); } /** diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/resource/FairyResourcePlugin.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/resource/FairyResourcePlugin.kt index 1642bbfd..0eaa2384 100644 --- a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/resource/FairyResourcePlugin.kt +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/resource/FairyResourcePlugin.kt @@ -23,13 +23,20 @@ class FairyResourcePlugin: Plugin { .any { it.isBukkitPlatform } } + val hasHytalePlatform by lazy { + project.configurations + .flatMap { it.dependencies } + .any { it.isHytalePlatform } + } + val action = project.objects.newInstance(FairyResourceAction::class.java).apply { this.extension.set(extension) - // Set the value of hasBukkitPlatform and projectInfo after the project is evaluated + // Set the value of hasBukkitPlatform, hasHytalePlatform and projectInfo after the project is evaluated project.afterEvaluate { val projectInfo = ProjectInfo(project.name, project.version.toString(), project.description ?: "") this.projectInfo.set(projectInfo) this.hasBukkitPlatform.set(hasBukkitPlatform) + this.hasHytalePlatform.set(hasHytalePlatform) } } @@ -39,4 +46,8 @@ class FairyResourcePlugin: Plugin { private val Dependency.isBukkitPlatform: Boolean get() = group == "io.fairyproject" && name in listOf("bukkit-platform", "bukkit-bundles", "bukkit-bootstrap") + + private val Dependency.isHytalePlatform: Boolean + get() = group == "io.fairyproject" && + name in listOf("hytale-platform", "hytale-bundles", "hytale-bootstrap") } \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/resource/impl/FairyResourceHytaleMeta.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/resource/impl/FairyResourceHytaleMeta.kt new file mode 100644 index 00000000..5cec9183 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/resource/impl/FairyResourceHytaleMeta.kt @@ -0,0 +1,113 @@ +package io.fairyproject.gradle.resource.impl + +import com.google.gson.GsonBuilder +import io.fairyproject.gradle.extension.property.HytaleAuthor +import io.fairyproject.gradle.resource.* + +/** + * The resource generator for Hytale manifest.json + */ +class FairyResourceHytaleMeta : FairyResource { + + private val gson = GsonBuilder().setPrettyPrinting().create() + + override fun generate( + context: FairyResourceGenerateContext, + classMapper: Map + ): ResourceInfo? { + if (!context.hasHytalePlatform) + return null + + classMapper[ClassType.HYTALE_PLUGIN] ?: return null + + val manifest = mutableMapOf() + + // Set Main class - this is the HytalePlugin class that extends Hytale's JavaPlugin + val hytalePluginClass = classMapper[ClassType.HYTALE_PLUGIN]!! + manifest["Main"] = hytalePluginClass.name.replace("/", ".") + + // Set Group from hytaleProps or default + val group = context.hytaleProps["Group"] as? String + manifest["Group"] = if (group.isNullOrEmpty()) "io.fairyproject" else group + + // Set Name from context + manifest["Name"] = context.pluginName + + // Set Version from context - must be valid semver format + manifest["Version"] = normalizeToSemver(context.projectVersion) + + // Set Description from context + manifest["Description"] = context.projectDescription + + // Set Authors + val authors = context.hytaleProps["Authors"] + manifest["Authors"] = if (authors is List<*> && authors.isNotEmpty()) { + authors.map { author -> + when (author) { + is HytaleAuthor -> mapOf( + "Name" to author.name, + "Email" to author.email, + "Url" to author.url + ) + else -> mapOf("Name" to "", "Email" to "", "Url" to "") + } + } + } else { + emptyList>() + } + + // Set Website + manifest["Website"] = context.hytaleProps["Website"] as? String ?: "" + + // Set ServerVersion + manifest["ServerVersion"] = context.hytaleProps["ServerVersion"] as? String ?: "" + + // Set Dependencies + manifest["Dependencies"] = context.hytaleProps["Dependencies"] as? Map<*, *> ?: emptyMap() + + // Set OptionalDependencies + manifest["OptionalDependencies"] = context.hytaleProps["OptionalDependencies"] as? Map<*, *> ?: emptyMap() + + // Set LoadBefore + manifest["LoadBefore"] = context.hytaleProps["LoadBefore"] as? Map<*, *> ?: emptyMap() + + // Set DisabledByDefault + manifest["DisabledByDefault"] = context.hytaleProps["DisabledByDefault"] as? Boolean ?: false + + // Set IncludesAssetPack + manifest["IncludesAssetPack"] = context.hytaleProps["IncludesAssetPack"] as? Boolean ?: true + + // Set SubPlugins + manifest["SubPlugins"] = context.hytaleProps["SubPlugins"] as? List<*> ?: emptyList() + + val json = gson.toJson(manifest) + return resourceOf("manifest.json", json.encodeToByteArray()) + } + + /** + * Normalize version string to valid semver format. + * Hytale requires versions in semver format (e.g., "1.0.0"). + */ + private fun normalizeToSemver(version: String): String { + // If version is unspecified or empty, return default + if (version.isBlank() || version == "unspecified") { + return "0.0.1" + } + + // Simple semver regex pattern: major.minor.patch with optional pre-release/build + val semverRegex = Regex("""^\d+\.\d+\.\d+(-[\w.]+)?(\+[\w.]+)?$""") + if (semverRegex.matches(version)) { + return version + } + + // Try to extract numbers from version string + val numbers = Regex("""\d+""").findAll(version).map { it.value }.toList() + return when { + numbers.size >= 3 -> "${numbers[0]}.${numbers[1]}.${numbers[2]}" + numbers.size == 2 -> "${numbers[0]}.${numbers[1]}.0" + numbers.size == 1 -> "${numbers[0]}.0.0" + else -> "0.0.1" + } + } + +} diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/HytaleServerArtifact.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/HytaleServerArtifact.kt new file mode 100644 index 00000000..65ab570c --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/HytaleServerArtifact.kt @@ -0,0 +1,70 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.fairyproject.gradle.runner.hytale + +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.io.path.readText + +/** + * Artifact tracking for Hytale server files. + * + * @since 0.7 + * @author LeeGod + * @see RunHytaleServerPlugin + */ +class HytaleServerArtifact(private val workDirectory: Path) { + + /** + * Path to HytaleServer.jar + */ + val serverJarPath: Path + get() = workDirectory.resolve("Server/HytaleServer.jar") + + /** + * Path to Assets.zip + */ + val assetsPath: Path + get() = workDirectory.resolve("Assets.zip") + + /** + * Path to version tracking file + */ + val versionFilePath: Path + get() = workDirectory.resolve(".fairy-hytale-version") + + /** + * Check if server artifacts are ready to run + */ + val hasArtifact: Boolean + get() = serverJarPath.exists() && assetsPath.exists() + + /** + * Get the currently downloaded version, or null if not downloaded + */ + val currentVersion: String? + get() = if (versionFilePath.exists()) versionFilePath.readText().trim() else null + +} diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerExtension.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerExtension.kt new file mode 100644 index 00000000..2d152e94 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerExtension.kt @@ -0,0 +1,83 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.fairyproject.gradle.runner.hytale + +import org.gradle.api.JavaVersion +import org.gradle.api.Project +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property + +/** + * Extension for [RunHytaleServerPlugin]. + * + * @since 0.7 + * @author LeeGod + * @see RunHytaleServerPlugin + */ +open class RunHytaleServerExtension(objectFactory: ObjectFactory) { + + /** + * Patchline: "pre-release" (default) or "release". + */ + val patchline: Property = objectFactory.property(String::class.java).convention("pre-release") + + /** + * Clean work directory before run. + */ + val cleanup: Property = objectFactory.property(Boolean::class.java).convention(false) + + /** + * JVM arguments. + */ + val args: ListProperty = objectFactory.listProperty(String::class.java) + + /** + * Additional mod projects to include. + */ + val projects: ListProperty = objectFactory.listProperty(Project::class.java).convention(listOf()) + + /** + * Java version (default Java 25 for Hytale). + */ + val javaVersion: Property = objectFactory.property(JavaVersion::class.java).convention(JavaVersion.VERSION_24) + + /** + * Bind address (default "0.0.0.0:5520"). + */ + val bindAddress: Property = objectFactory.property(String::class.java).convention("0.0.0.0:5520") + + /** + * URL for downloading the Hytale Downloader CLI ZIP. + */ + val downloaderUrl: Property = objectFactory.property(String::class.java) + .convention("https://downloader.hytale.com/hytale-downloader.zip") + + /** + * Custom path to hytale-downloader binary (optional, overrides download). + */ + val downloaderPath: Property = objectFactory.property(String::class.java) + +} diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerPlugin.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerPlugin.kt new file mode 100644 index 00000000..d342ae82 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerPlugin.kt @@ -0,0 +1,208 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.fairyproject.gradle.runner.hytale + +import io.fairyproject.gradle.FairyGradlePlugin +import io.fairyproject.gradle.runner.ClasspathRegistry +import io.fairyproject.gradle.runner.hytale.action.CopyHytaleSnapshotAction +import io.fairyproject.gradle.runner.hytale.task.PrepareHytaleBuildTask +import io.fairyproject.gradle.runner.hytale.task.PrepareHytaleDownloaderTask +import io.fairyproject.gradle.runner.hytale.task.RunHytaleServerTask +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.file.DuplicatesStrategy +import org.gradle.api.tasks.Copy +import org.gradle.api.tasks.Delete +import org.gradle.jvm.tasks.Jar +import java.nio.file.Files +import java.nio.file.Path + +/** + * Plugin for running Hytale server. One-click solution to boot up a Hytale test environment. + * + * @since 0.7 + * @author LeeGod + */ +open class RunHytaleServerPlugin : Plugin { + + private val group = "runHytaleServer" + private lateinit var project: Project + private lateinit var extension: RunHytaleServerExtension + + override fun apply(project: Project) { + this.project = project + extension = project.extensions.create("runHytaleServer", RunHytaleServerExtension::class.java) + + project.afterEvaluate { + configureProject(extension, project) + } + } + + private fun configureProject( + extension: RunHytaleServerExtension, + project: Project + ) { + extension.projects.get().forEach { includedProject -> + includedProject.afterEvaluate { + if (!it.plugins.hasPlugin(FairyGradlePlugin::class.java)) { + it.logger.warn("Project ${it.name} does not have the FairyProject plugin applied and was included to run Hytale server.") + } + } + } + + // Directory structure + val baseDir = project.projectDir.toPath().resolve("server/hytale") + val downloaderDir = baseDir.resolve("downloader") + val downloadsDir = baseDir.resolve("downloads") + val workDir = baseDir.resolve("work") + val snapshotDir = baseDir.resolve("snapshot") + + // Create directories + Files.createDirectories(downloaderDir) + Files.createDirectories(downloadsDir) + Files.createDirectories(workDir) + Files.createDirectories(snapshotDir) + + val artifact = HytaleServerArtifact(workDir) + + configurePrepareHytaleDownloader(downloaderDir) + configurePrepareHytaleBuild(downloaderDir, downloadsDir, workDir, artifact) + configureCopyHytaleModJar(workDir) + configureCleanHytaleServer(workDir) + configureRunHytaleServer(artifact, workDir, snapshotDir) + } + + private fun configurePrepareHytaleDownloader(downloaderDir: Path) { + project.tasks.register( + "prepareHytaleDownloader", + PrepareHytaleDownloaderTask::class.java, + downloaderDir, + extension + ).configure { + it.group = group + it.description = "Downloads the Hytale Downloader CLI" + } + } + + private fun configurePrepareHytaleBuild( + downloaderDir: Path, + downloadsDir: Path, + workDir: Path, + artifact: HytaleServerArtifact + ) { + project.tasks.register( + "prepareHytaleBuild", + PrepareHytaleBuildTask::class.java, + downloaderDir, + downloadsDir, + workDir, + artifact, + extension + ).configure { + it.group = group + it.description = "Downloads and extracts Hytale server files" + it.dependsOn("prepareHytaleDownloader") + } + } + + private fun configureCopyHytaleModJar(workDir: Path) { + project.tasks.register("copyHytaleModJar", Copy::class.java) { + it.includeProjectJarCopy(project) + project.extensions.configure(RunHytaleServerExtension::class.java) { ext -> + ext.projects.get().forEach { includedProject -> + it.includeProjectJarCopy(includedProject) + } + } + + it.into(workDir.resolve("mods")) + it.duplicatesStrategy = DuplicatesStrategy.INCLUDE + it.group = group + it.description = "Copies mod JARs to the Hytale server mods directory" + } + } + + private fun Copy.includeProjectJarCopy(project: Project) { + val jarTask = if (project.tasks.findByName("shadowJar") != null) + project.tasks.getByName("shadowJar") as Jar + else + project.tasks.getByName("jar") as Jar + + from(jarTask.archiveFile.get()) { + it.rename { _ -> + "runServer-${project.name}.jar" + } + } + dependsOn(jarTask) + } + + private fun configureCleanHytaleServer(workDir: Path) { + project.tasks.register("cleanHytaleServer", Delete::class.java) { + it.delete(workDir.toFile().listFiles()?.filter { file -> + // Don't delete mods directory on clean + file.name != "mods" && file.name != ".fairy-hytale-version" + } ?: emptyList()) + it.group = group + it.description = "Cleans the Hytale server work directory" + } + } + + private fun configureRunHytaleServer( + artifact: HytaleServerArtifact, + workDir: Path, + snapshotDir: Path + ) { + project.afterEvaluate { + project.tasks.register( + "runHytaleServer", + RunHytaleServerTask::class.java, + extension.javaVersion.get(), + artifact, + workDir, + extension + ).configure { + if (extension.cleanup.get()) { + it.dependsOn("cleanHytaleServer") + } + it.doFirst(CopyHytaleSnapshotAction(snapshotDir, workDir)) + it.dependsOn("copyHytaleModJar") + it.dependsOn("prepareHytaleBuild") + + it.group = group + it.description = "Runs the Hytale server with mods" + it.jvmArgs = extension.args.get() + + val classpathRegistry = ClasspathRegistry() + classpathRegistry.register(project) + + extension.projects.get().forEach { included -> + classpathRegistry.register(included) + } + + it.systemProperties["io.fairyproject.devtools.classpath"] = classpathRegistry.toString() + } + } + } + +} diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/action/CopyHytaleSnapshotAction.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/action/CopyHytaleSnapshotAction.kt new file mode 100644 index 00000000..5d612bd4 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/action/CopyHytaleSnapshotAction.kt @@ -0,0 +1,62 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.fairyproject.gradle.runner.hytale.action + +import org.gradle.api.Action +import org.gradle.api.Task +import java.nio.file.Path +import kotlin.io.path.copyTo +import kotlin.io.path.exists +import kotlin.io.path.isDirectory +import kotlin.io.path.listDirectoryEntries + +/** + * Action for copying the snapshot directory to the work directory for Hytale server. + * + * @since 0.7 + * @author LeeGod + * @see io.fairyproject.gradle.runner.hytale.RunHytaleServerPlugin + */ +class CopyHytaleSnapshotAction( + private val snapshotDirectory: Path, + private val workDirectory: Path +) : Action { + + override fun execute(t: Task) { + if (!snapshotDirectory.exists()) + return + + // Copy the contents of the snapshot directory to the work directory + snapshotDirectory.listDirectoryEntries().forEach { + // Copy the file to the work directory, the file can be a directory + if (it.isDirectory()) { + it.toFile().copyRecursively(workDirectory.resolve(it.fileName).toFile(), true) + } else { + it.copyTo(workDirectory.resolve(it.fileName), true) + } + } + } + +} diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/PrepareHytaleBuildTask.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/PrepareHytaleBuildTask.kt new file mode 100644 index 00000000..2a860e38 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/PrepareHytaleBuildTask.kt @@ -0,0 +1,194 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.fairyproject.gradle.runner.hytale.task + +import io.fairyproject.gradle.runner.hytale.HytaleServerArtifact +import io.fairyproject.gradle.runner.hytale.RunHytaleServerExtension +import org.gradle.api.DefaultTask +import org.gradle.api.tasks.TaskAction +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import java.util.zip.ZipFile +import javax.inject.Inject +import kotlin.io.path.createDirectories +import kotlin.io.path.deleteIfExists +import kotlin.io.path.exists +import kotlin.io.path.writeText + +/** + * Task to download and extract Hytale server files using the Hytale Downloader CLI. + * + * @since 0.7 + * @author LeeGod + */ +open class PrepareHytaleBuildTask @Inject constructor( + private val downloaderDirectory: Path, + private val downloadsDirectory: Path, + private val workDirectory: Path, + private val artifact: HytaleServerArtifact, + private val extension: RunHytaleServerExtension +) : DefaultTask() { + + @TaskAction + fun prepareBuild() { + // If artifact already exists, skip (always downloads latest) + if (artifact.hasArtifact) { + println("Hytale server already prepared. Delete ${artifact.versionFilePath} to force re-download.") + return + } + + // Run the downloader + val downloaderPath = PrepareHytaleDownloaderTask.getDownloaderPath(downloaderDirectory, extension) + if (!downloaderPath.exists()) { + error("Hytale downloader not found at: $downloaderPath. Run prepareHytaleDownloader first.") + } + + downloadsDirectory.createDirectories() + + val downloadedZip = downloadsDirectory.resolve("hytale-server.zip") + runDownloader(downloaderPath, downloadedZip) + + // Extract the downloaded ZIP + extractServerFiles(downloadedZip.toFile()) + + // Get version from -print-version and write version file + val version = getVersion(downloaderPath) + artifact.versionFilePath.writeText(version) + + // Clean up downloaded ZIP + downloadedZip.deleteIfExists() + + println("Hytale server prepared successfully (version: $version).") + } + + private fun runDownloader(downloaderPath: Path, outputZip: Path) { + println("Running Hytale Downloader CLI...") + + val command = mutableListOf(downloaderPath.toAbsolutePath().toString()) + command.add("-download-path") + command.add(outputZip.toAbsolutePath().toString()) + command.add("-patchline") + command.add(extension.patchline.get()) + command.add("-skip-update-check") + + println("Executing: ${command.joinToString(" ")}") + + val processBuilder = ProcessBuilder(command) + .directory(downloaderDirectory.toFile()) + .redirectErrorStream(true) + + val process = processBuilder.start() + + // Forward output to console in real-time for OAuth authentication + val outputThread = Thread { + process.inputStream.bufferedReader().forEachLine { line -> + println(line) + } + } + outputThread.start() + + val exitCode = process.waitFor() + outputThread.join() + + if (exitCode != 0) { + error("Hytale Downloader failed with exit code: $exitCode") + } + } + + private fun getVersion(downloaderPath: Path): String { + val command = listOf( + downloaderPath.toAbsolutePath().toString(), + "-print-version", + "-patchline", + extension.patchline.get(), + "-skip-update-check" + ) + + val process = ProcessBuilder(command) + .directory(downloaderDirectory.toFile()) + .redirectErrorStream(true) + .start() + + val output = process.inputStream.bufferedReader().readText().trim() + val exitCode = process.waitFor() + + if (exitCode != 0) { + println("Warning: Could not get version info, using 'unknown'") + return "unknown" + } + + return output.ifBlank { "unknown" } + } + + private fun extractServerFiles(zipFile: File) { + println("Extracting server files from: ${zipFile.name}") + + workDirectory.createDirectories() + + // Preserve mods directory if it exists + val modsDir = workDirectory.resolve("mods") + val modsBackup = if (modsDir.exists()) { + val backup = Files.createTempDirectory("fairy-hytale-mods-backup") + modsDir.toFile().copyRecursively(backup.toFile(), overwrite = true) + backup + } else null + + ZipFile(zipFile).use { zip -> + zip.entries().asSequence().forEach { entry -> + // Skip Client directory - only extract Server and Assets + if (entry.name.startsWith("Client/")) { + return@forEach + } + + // Skip mods directory to preserve plugins + if (entry.name.startsWith("mods/") || entry.name == "mods") { + return@forEach + } + + val targetPath = workDirectory.resolve(entry.name) + + if (entry.isDirectory) { + targetPath.createDirectories() + } else { + targetPath.parent?.createDirectories() + zip.getInputStream(entry).use { input -> + Files.copy(input, targetPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING) + } + } + } + } + + // Restore mods directory + if (modsBackup != null) { + modsDir.createDirectories() + modsBackup.toFile().copyRecursively(modsDir.toFile(), overwrite = true) + modsBackup.toFile().deleteRecursively() + } + + println("Server files extracted to: $workDirectory") + } + +} diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/PrepareHytaleDownloaderTask.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/PrepareHytaleDownloaderTask.kt new file mode 100644 index 00000000..11d6c790 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/PrepareHytaleDownloaderTask.kt @@ -0,0 +1,161 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.fairyproject.gradle.runner.hytale.task + +import io.fairyproject.gradle.runner.hytale.RunHytaleServerExtension +import org.gradle.api.DefaultTask +import org.gradle.api.tasks.TaskAction +import java.net.URI +import java.nio.file.Files +import java.nio.file.Path +import java.util.zip.ZipFile +import javax.inject.Inject +import kotlin.io.path.createDirectories +import kotlin.io.path.deleteIfExists +import kotlin.io.path.exists + +/** + * Task to download the Hytale Downloader CLI. + * + * @since 0.7 + * @author LeeGod + */ +open class PrepareHytaleDownloaderTask @Inject constructor( + private val downloaderDirectory: Path, + private val extension: RunHytaleServerExtension +) : DefaultTask() { + + private val downloaderPath: Path + get() = downloaderDirectory.resolve(getDownloaderBinaryName()) + + init { + // If custom path is provided, or downloader already exists, skip + if (extension.downloaderPath.isPresent || downloaderPath.exists()) { + enabled = false + } + } + + @TaskAction + fun prepareDownloader() { + if (extension.downloaderPath.isPresent) { + println("Using custom downloader path: ${extension.downloaderPath.get()}") + return + } + + if (downloaderPath.exists()) { + println("Hytale downloader already exists at: $downloaderPath") + return + } + + val downloadUrl = extension.downloaderUrl.get() + println("Downloading Hytale Downloader CLI from: $downloadUrl") + + downloaderDirectory.createDirectories() + + // Download the ZIP file + val zipPath = downloaderDirectory.resolve("hytale-downloader.zip") + URI(downloadUrl).toURL().openStream().use { input -> + Files.copy(input, zipPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING) + } + + // Extract the appropriate binary + extractDownloader(zipPath) + + // Clean up ZIP file + zipPath.deleteIfExists() + + // Set executable permissions on Unix systems + if (!isWindows()) { + downloaderPath.toFile().setExecutable(true) + } + + println("Downloaded Hytale Downloader CLI to: $downloaderPath") + } + + private fun extractDownloader(zipPath: Path) { + val targetBinaryName = getDownloaderBinaryNameInZip() + + ZipFile(zipPath.toFile()).use { zip -> + val entry = zip.entries().asSequence().find { entry -> + entry.name.endsWith(targetBinaryName) || entry.name == targetBinaryName + } ?: error("Could not find $targetBinaryName in the downloaded ZIP. Available entries: ${ + zip.entries().asSequence().map { it.name }.toList() + }") + + zip.getInputStream(entry).use { input -> + Files.copy(input, downloaderPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING) + } + } + } + + private fun getDownloaderBinaryName(): String { + return if (isWindows()) "hytale-downloader.exe" else "hytale-downloader" + } + + private fun getDownloaderBinaryNameInZip(): String { + val os = getOsName() + val arch = getArchName() + val ext = if (isWindows()) ".exe" else "" + return "hytale-downloader-$os-$arch$ext" + } + + private fun getOsName(): String { + val osName = System.getProperty("os.name").lowercase() + return when { + osName.contains("win") -> "windows" + osName.contains("mac") || osName.contains("darwin") -> "macos" + osName.contains("linux") -> "linux" + else -> error("Unsupported operating system: $osName") + } + } + + private fun getArchName(): String { + val arch = System.getProperty("os.arch").lowercase() + return when { + arch.contains("amd64") || arch.contains("x86_64") -> "amd64" + arch.contains("aarch64") || arch.contains("arm64") -> "arm64" + else -> error("Unsupported architecture: $arch") + } + } + + private fun isWindows(): Boolean { + return System.getProperty("os.name").lowercase().contains("win") + } + + companion object { + fun getDownloaderPath(downloaderDirectory: Path, extension: RunHytaleServerExtension): Path { + if (extension.downloaderPath.isPresent) { + return Path.of(extension.downloaderPath.get()) + } + val binaryName = if (System.getProperty("os.name").lowercase().contains("win")) { + "hytale-downloader.exe" + } else { + "hytale-downloader" + } + return downloaderDirectory.resolve(binaryName) + } + } + +} diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/RunHytaleServerTask.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/RunHytaleServerTask.kt new file mode 100644 index 00000000..dea71ede --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/RunHytaleServerTask.kt @@ -0,0 +1,68 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.fairyproject.gradle.runner.hytale.task + +import io.fairyproject.gradle.runner.hytale.HytaleServerArtifact +import io.fairyproject.gradle.runner.hytale.RunHytaleServerExtension +import org.gradle.api.JavaVersion +import org.gradle.api.tasks.JavaExec +import org.gradle.jvm.toolchain.JavaLanguageVersion +import java.nio.file.Path +import javax.inject.Inject + +/** + * Task for running Hytale server. + * + * @since 0.7 + * @author LeeGod + * @see io.fairyproject.gradle.runner.hytale.RunHytaleServerPlugin + */ +open class RunHytaleServerTask @Inject constructor( + private val version: JavaVersion, + artifact: HytaleServerArtifact, + workDirectory: Path, + extension: RunHytaleServerExtension +) : JavaExec() { + + init { + classpath = project.files(artifact.serverJarPath) + workingDir = workDirectory.toFile() + standardInput = System.`in` + + // Configure Java toolchain + javaLauncher.set(javaToolchainService.launcherFor { + it.languageVersion.set(JavaLanguageVersion.of(javaVersion.majorVersion)) + }) + + // Hytale server requires specific arguments + args("--assets", artifact.assetsPath.toAbsolutePath().toString()) + args("--bind", extension.bindAddress.get()) + } + + override fun getJavaVersion(): JavaVersion { + return version + } + +} From 738f0bffb92f1c1c0e466dae6b2c13a956c635ef Mon Sep 17 00:00:00 2001 From: LeeGodSRC Date: Tue, 20 Jan 2026 17:45:45 +0800 Subject: [PATCH 02/20] feat: Added hytale test plugin --- test-hytale-plugin/build.gradle.kts | 29 +++++++++++ .../io/example/hytale/HytaleTestPlugin.java | 49 +++++++++++++++++++ .../hytale/component/HelloComponent.java | 43 ++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 test-hytale-plugin/build.gradle.kts create mode 100644 test-hytale-plugin/src/main/java/io/example/hytale/HytaleTestPlugin.java create mode 100644 test-hytale-plugin/src/main/java/io/example/hytale/component/HelloComponent.java diff --git a/test-hytale-plugin/build.gradle.kts b/test-hytale-plugin/build.gradle.kts new file mode 100644 index 00000000..966551dc --- /dev/null +++ b/test-hytale-plugin/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + `java` + id("io.fairyproject") + id("com.github.johnrengelman.shadow") version "7.1.2" +} + +repositories { + mavenCentral() + mavenLocal() +} + +fairy { + name.set("test-hytale-plugin") + mainPackage.set("io.example.hytale") + fairyPackage.set("io.fairyproject") +} + +dependencies { + implementation("io.fairyproject:hytale-bootstrap") + implementation("io.fairyproject:hytale-platform") +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +runHytaleServer { } \ No newline at end of file diff --git a/test-hytale-plugin/src/main/java/io/example/hytale/HytaleTestPlugin.java b/test-hytale-plugin/src/main/java/io/example/hytale/HytaleTestPlugin.java new file mode 100644 index 00000000..bfdc1412 --- /dev/null +++ b/test-hytale-plugin/src/main/java/io/example/hytale/HytaleTestPlugin.java @@ -0,0 +1,49 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.example.hytale; + +import io.fairyproject.plugin.Plugin; + +/** + * A simple test plugin for Hytale platform. + */ +public class HytaleTestPlugin extends Plugin { + + @Override + public void onInitial() { + System.out.println("[HytaleTestPlugin] onInitial called - Plugin is initializing!"); + } + + @Override + public void onPluginEnable() { + System.out.println("[HytaleTestPlugin] onPluginEnable called - Plugin is now enabled!"); + System.out.println("[HytaleTestPlugin] Hello from Hytale!"); + } + + @Override + public void onPluginDisable() { + System.out.println("[HytaleTestPlugin] onPluginDisable called - Plugin is being disabled!"); + } +} diff --git a/test-hytale-plugin/src/main/java/io/example/hytale/component/HelloComponent.java b/test-hytale-plugin/src/main/java/io/example/hytale/component/HelloComponent.java new file mode 100644 index 00000000..eaa1e733 --- /dev/null +++ b/test-hytale-plugin/src/main/java/io/example/hytale/component/HelloComponent.java @@ -0,0 +1,43 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.example.hytale.component; + +import io.fairyproject.container.InjectableComponent; + +/** + * A simple injectable component for testing Fairy's DI container on Hytale. + */ +@InjectableComponent +public class HelloComponent { + + public HelloComponent() { + System.out.println("[HelloComponent] Component created!"); + System.out.println("[HelloComponent] ClassLoader: " + this.getClass().getClassLoader().getClass().getName()); + } + + public String sayHello(String name) { + return "Hello, " + name + "! Welcome to Hytale with Fairy!"; + } +} From 586a191ad7d496879c42cd869a5503703719b99c Mon Sep 17 00:00:00 2001 From: LeeGodSRC Date: Tue, 20 Jan 2026 19:15:44 +0800 Subject: [PATCH 03/20] feat: add hytale-command module with player command support --- .../io.fairyproject.module.hytale.gradle.kts | 11 ++ framework/modules/hytale/build.gradle.kts | 3 + .../hytale/hytale-bom/build.gradle.kts | 14 ++ .../hytale/hytale-command/build.gradle.kts | 7 + .../hytale/command/HytaleCommandExecutor.java | 92 +++++++++++ .../hytale/command/HytaleCommandListener.java | 62 +++++++ .../hytale/command/HytaleCommandModule.java | 47 ++++++ .../command/HytalePlayerCommandExecutor.java | 125 ++++++++++++++ .../command/event/HytaleCommandContext.java | 55 +++++++ .../event/HytalePlayerCommandContext.java | 143 ++++++++++++++++ .../command/map/DefaultHytaleCommandMap.java | 132 +++++++++++++++ .../hytale/command/map/HytaleCommandMap.java | 36 +++++ .../presence/DefaultPresenceProvider.java | 66 ++++++++ .../presence/PlayerPresenceProvider.java | 68 ++++++++ framework/modules/hytale/settings.gradle.kts | 13 ++ framework/modules/settings.gradle.kts | 1 + framework/tests/hytale-tests/build.gradle.kts | 9 ++ .../tests/hytale/HytaleTestingHandle.java | 39 +++++ framework/tests/settings.gradle.kts | 1 + test-hytale-plugin/build.gradle.kts | 1 + .../example/hytale/command/TestCommand.java | 152 ++++++++++++++++++ 21 files changed, 1077 insertions(+) create mode 100644 build-logic/src/main/kotlin/io.fairyproject.module.hytale.gradle.kts create mode 100644 framework/modules/hytale/build.gradle.kts create mode 100644 framework/modules/hytale/hytale-bom/build.gradle.kts create mode 100644 framework/modules/hytale/hytale-command/build.gradle.kts create mode 100644 framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/HytaleCommandExecutor.java create mode 100644 framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/HytaleCommandListener.java create mode 100644 framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/HytaleCommandModule.java create mode 100644 framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/HytalePlayerCommandExecutor.java create mode 100644 framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/event/HytaleCommandContext.java create mode 100644 framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/event/HytalePlayerCommandContext.java create mode 100644 framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/map/DefaultHytaleCommandMap.java create mode 100644 framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/map/HytaleCommandMap.java create mode 100644 framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/presence/DefaultPresenceProvider.java create mode 100644 framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/presence/PlayerPresenceProvider.java create mode 100644 framework/modules/hytale/settings.gradle.kts create mode 100644 framework/tests/hytale-tests/build.gradle.kts create mode 100644 framework/tests/hytale-tests/src/main/java/io/fairyproject/tests/hytale/HytaleTestingHandle.java create mode 100644 test-hytale-plugin/src/main/java/io/example/hytale/command/TestCommand.java diff --git a/build-logic/src/main/kotlin/io.fairyproject.module.hytale.gradle.kts b/build-logic/src/main/kotlin/io.fairyproject.module.hytale.gradle.kts new file mode 100644 index 00000000..6ac63f6f --- /dev/null +++ b/build-logic/src/main/kotlin/io.fairyproject.module.hytale.gradle.kts @@ -0,0 +1,11 @@ +plugins { + id("io.fairyproject.module") +} + +dependencies { + compileOnly("dev.imanity.hytale:HytaleServer:2026.01.17-2") +} + +repositories { + maven ("https://repo.imanity.dev/imanity-libraries/") +} \ No newline at end of file diff --git a/framework/modules/hytale/build.gradle.kts b/framework/modules/hytale/build.gradle.kts new file mode 100644 index 00000000..3d670ad0 --- /dev/null +++ b/framework/modules/hytale/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + id("io.fairyproject.composite.subprojects") +} diff --git a/framework/modules/hytale/hytale-bom/build.gradle.kts b/framework/modules/hytale/hytale-bom/build.gradle.kts new file mode 100644 index 00000000..59cb3028 --- /dev/null +++ b/framework/modules/hytale/hytale-bom/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + id("io.fairyproject.bom") +} + +description = "Fairy Hytale Modules BOM (Bill of materials)" + +dependencies { + constraints { + rootProject.subprojects.forEach { + if (it != project) + api(project(it.path)) + } + } +} diff --git a/framework/modules/hytale/hytale-command/build.gradle.kts b/framework/modules/hytale/hytale-command/build.gradle.kts new file mode 100644 index 00000000..5da85615 --- /dev/null +++ b/framework/modules/hytale/hytale-command/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + id("io.fairyproject.module.hytale") +} + +dependencies { + api("io.fairyproject:core-command") +} diff --git a/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/HytaleCommandExecutor.java b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/HytaleCommandExecutor.java new file mode 100644 index 00000000..6b75edd7 --- /dev/null +++ b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/HytaleCommandExecutor.java @@ -0,0 +1,92 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.fairyproject.hytale.command; + +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.basecommands.CommandBase; +import io.fairyproject.command.BaseCommand; +import io.fairyproject.hytale.command.event.HytaleCommandContext; +import lombok.Getter; + +/** + * Hytale command executor that bridges Fairy's BaseCommand to Hytale's command system. + */ +@Getter +public class HytaleCommandExecutor extends CommandBase { + + private final BaseCommand fairyCommand; + + public HytaleCommandExecutor(BaseCommand fairyCommand) { + super(fairyCommand.getCommandNames()[0], fairyCommand.getDescription()); + this.fairyCommand = fairyCommand; + + // Add aliases + String[] commandNames = fairyCommand.getCommandNames(); + if (commandNames.length > 1) { + String[] aliases = new String[commandNames.length - 1]; + System.arraycopy(commandNames, 1, aliases, 0, aliases.length); + this.addAliases(aliases); + } + + // Allow extra arguments since Fairy handles its own argument parsing + this.setAllowsExtraArguments(true); + } + + @Override + protected void executeSync(CommandContext hytaleContext) { + // Extract the remaining arguments from the input + String inputString = hytaleContext.getInputString(); + String[] args = extractArgs(inputString); + + io.fairyproject.command.CommandContext fairyContext = new HytaleCommandContext(hytaleContext.sender(), args); + try { + this.fairyCommand.execute(fairyContext); + } catch (Throwable throwable) { + this.fairyCommand.onError(fairyContext, throwable); + hytaleContext.sendMessage(Message.raw("An error occurred while executing the command.").color("#FF5555")); + } + } + + /** + * Extract arguments from the input string. + * The input string contains the full command, so we need to skip the command name. + */ + private String[] extractArgs(String inputString) { + if (inputString == null || inputString.isEmpty()) { + return new String[0]; + } + + String[] parts = inputString.split(" "); + if (parts.length <= 1) { + return new String[0]; + } + + // Skip the command name and return the rest as arguments + String[] args = new String[parts.length - 1]; + System.arraycopy(parts, 1, args, 0, args.length); + return args; + } +} diff --git a/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/HytaleCommandListener.java b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/HytaleCommandListener.java new file mode 100644 index 00000000..01fca8a2 --- /dev/null +++ b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/HytaleCommandListener.java @@ -0,0 +1,62 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.fairyproject.hytale.command; + +import io.fairyproject.command.BaseCommand; +import io.fairyproject.command.CommandListener; +import io.fairyproject.command.CommandService; +import io.fairyproject.container.InjectableComponent; +import io.fairyproject.container.PostDestroy; +import io.fairyproject.container.PreInitialize; +import io.fairyproject.hytale.command.map.HytaleCommandMap; +import lombok.RequiredArgsConstructor; + +@InjectableComponent +@RequiredArgsConstructor +public class HytaleCommandListener implements CommandListener { + + private final CommandService commandService; + private final HytaleCommandMap hytaleCommandMap; + + @PreInitialize + public void init() { + commandService.addCommandListener(this); + } + + @PostDestroy + public void destroy() { + commandService.removeCommandListener(this); + } + + @Override + public void onCommandInitial(BaseCommand command, String[] aliases) { + hytaleCommandMap.register(command); + } + + @Override + public void onCommandRemoval(BaseCommand command) { + hytaleCommandMap.unregister(command); + } +} diff --git a/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/HytaleCommandModule.java b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/HytaleCommandModule.java new file mode 100644 index 00000000..7ba732ce --- /dev/null +++ b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/HytaleCommandModule.java @@ -0,0 +1,47 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.fairyproject.hytale.command; + +import io.fairyproject.command.CommandService; +import io.fairyproject.container.InjectableComponent; +import io.fairyproject.container.PreInitialize; +import io.fairyproject.hytale.command.presence.DefaultPresenceProvider; +import io.fairyproject.hytale.command.presence.PlayerPresenceProvider; +import lombok.RequiredArgsConstructor; + +@InjectableComponent +@RequiredArgsConstructor +public class HytaleCommandModule { + + private final CommandService commandService; + + @PreInitialize + public void onPreInitialize() { + // Register presence provider for general commands (HytaleCommandContext) + commandService.registerDefaultPresenceProvider(new DefaultPresenceProvider()); + // Register presence provider for player commands (HytalePlayerCommandContext) + commandService.registerDefaultPresenceProvider(new PlayerPresenceProvider()); + } +} diff --git a/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/HytalePlayerCommandExecutor.java b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/HytalePlayerCommandExecutor.java new file mode 100644 index 00000000..d125d8d4 --- /dev/null +++ b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/HytalePlayerCommandExecutor.java @@ -0,0 +1,125 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.fairyproject.hytale.command; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import io.fairyproject.command.BaseCommand; +import io.fairyproject.hytale.command.event.HytalePlayerCommandContext; +import lombok.Getter; + +/** + * Hytale player command executor that bridges Fairy's BaseCommand to Hytale's command system. + * + *

This executor extends {@link AbstractPlayerCommand} which handles multi-threading properly. + * The execute method is called on the correct thread for the player's world, ensuring thread safety + * when accessing player and world data.

+ * + *

The command execution flow:

+ *
    + *
  1. Player executes command (may be on any thread)
  2. + *
  3. AbstractPlayerCommand.executeAsync is called
  4. + *
  5. Hytale schedules execution on the correct world thread
  6. + *
  7. execute() is called with player context on the correct thread
  8. + *
  9. Fairy's BaseCommand.execute() is invoked with HytalePlayerCommandContext
  10. + *
+ */ +@Getter +public class HytalePlayerCommandExecutor extends AbstractPlayerCommand { + + private final BaseCommand fairyCommand; + + public HytalePlayerCommandExecutor(BaseCommand fairyCommand) { + super(fairyCommand.getCommandNames()[0], fairyCommand.getDescription()); + this.fairyCommand = fairyCommand; + + // Add aliases + String[] commandNames = fairyCommand.getCommandNames(); + if (commandNames.length > 1) { + String[] aliases = new String[commandNames.length - 1]; + System.arraycopy(commandNames, 1, aliases, 0, aliases.length); + this.addAliases(aliases); + } + + // Allow extra arguments since Fairy handles its own argument parsing + this.setAllowsExtraArguments(true); + } + + @Override + protected void execute( + CommandContext hytaleContext, + Store store, + Ref ref, + PlayerRef playerRef, + World world + ) { + // Extract the remaining arguments from the input + String inputString = hytaleContext.getInputString(); + String[] args = extractArgs(inputString); + + // Create player-specific context with all the player/world information + HytalePlayerCommandContext fairyContext = new HytalePlayerCommandContext( + hytaleContext.sender(), + args, + store, + ref, + playerRef, + world + ); + + try { + this.fairyCommand.execute(fairyContext); + } catch (Throwable throwable) { + this.fairyCommand.onError(fairyContext, throwable); + hytaleContext.sendMessage(Message.raw("An error occurred while executing the command.").color("#FF5555")); + } + } + + /** + * Extract arguments from the input string. + * The input string contains the full command, so we need to skip the command name. + */ + private String[] extractArgs(String inputString) { + if (inputString == null || inputString.isEmpty()) { + return new String[0]; + } + + String[] parts = inputString.split(" "); + if (parts.length <= 1) { + return new String[0]; + } + + // Skip the command name and return the rest as arguments + String[] args = new String[parts.length - 1]; + System.arraycopy(parts, 1, args, 0, args.length); + return args; + } +} diff --git a/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/event/HytaleCommandContext.java b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/event/HytaleCommandContext.java new file mode 100644 index 00000000..62e56657 --- /dev/null +++ b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/event/HytaleCommandContext.java @@ -0,0 +1,55 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.fairyproject.hytale.command.event; + +import com.hypixel.hytale.server.core.command.system.CommandSender; +import io.fairyproject.command.CommandContext; + +/** + * Hytale command context implementation. + */ +public class HytaleCommandContext extends CommandContext { + + private final CommandSender sender; + + public HytaleCommandContext(CommandSender sender, String[] args) { + super(args); + this.sender = sender; + } + + @Override + public String name() { + return sender.getDisplayName(); + } + + public CommandSender getSender() { + return this.sender; + } + + @Override + public boolean hasPermission(String permission) { + return this.sender.hasPermission(permission); + } +} diff --git a/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/event/HytalePlayerCommandContext.java b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/event/HytalePlayerCommandContext.java new file mode 100644 index 00000000..ad2898a2 --- /dev/null +++ b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/event/HytalePlayerCommandContext.java @@ -0,0 +1,143 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.fairyproject.hytale.command.event; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.command.system.CommandSender; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import java.util.UUID; + +/** + * Hytale player command context implementation. + * This context is used for commands executed by players and provides access to + * player-specific data like the player reference, world, and entity store. + * + *

Note: Hytale is multi-threaded, so command execution may occur on different threads. + * This context captures the player's state at the time of command execution.

+ */ +public class HytalePlayerCommandContext extends HytaleCommandContext { + + private final Store store; + private final Ref ref; + private final PlayerRef playerRef; + private final World world; + + public HytalePlayerCommandContext( + CommandSender sender, + String[] args, + Store store, + Ref ref, + PlayerRef playerRef, + World world + ) { + super(sender, args); + this.store = store; + this.ref = ref; + this.playerRef = playerRef; + this.world = world; + } + + @Override + public String name() { + return playerRef.getUsername(); + } + + /** + * Get the entity store for the player. + * + * @return the entity store + */ + public Store getStore() { + return store; + } + + /** + * Get the entity reference for the player. + * + * @return the entity reference + */ + public Ref getRef() { + return ref; + } + + /** + * Get the player reference. + * + * @return the player reference + */ + public PlayerRef getPlayerRef() { + return playerRef; + } + + /** + * Get the world the player is in. + * + * @return the world + */ + public World getWorld() { + return world; + } + + /** + * Get the player's UUID. + * + * @return the player's UUID + */ + public UUID getPlayerUuid() { + return playerRef.getUuid(); + } + + /** + * Get the player's username. + * + * @return the player's username + */ + public String getPlayerName() { + return playerRef.getUsername(); + } + + /** + * Check if the player reference is still valid. + * In a multi-threaded environment, the player may have disconnected. + * + * @return true if the player reference is still valid + */ + public boolean isPlayerValid() { + return playerRef.isValid() && ref.isValid(); + } + + /** + * Check if we are currently on the correct thread for this store. + * + * @return true if on the correct thread + */ + public boolean isInStoreThread() { + return store.isInThread(); + } +} diff --git a/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/map/DefaultHytaleCommandMap.java b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/map/DefaultHytaleCommandMap.java new file mode 100644 index 00000000..be6b2f84 --- /dev/null +++ b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/map/DefaultHytaleCommandMap.java @@ -0,0 +1,132 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.fairyproject.hytale.command.map; + +import com.hypixel.hytale.server.core.command.system.AbstractCommand; +import com.hypixel.hytale.server.core.command.system.CommandRegistration; +import com.hypixel.hytale.server.core.command.system.CommandRegistry; +import io.fairyproject.command.BaseCommand; +import io.fairyproject.command.annotation.Command; +import io.fairyproject.container.InjectableComponent; +import io.fairyproject.data.MetaKey; +import io.fairyproject.data.MetaStorage; +import io.fairyproject.hytale.FairyHytalePlatform; +import io.fairyproject.hytale.command.HytaleCommandExecutor; +import io.fairyproject.hytale.command.HytalePlayerCommandExecutor; +import io.fairyproject.hytale.command.event.HytalePlayerCommandContext; +import lombok.RequiredArgsConstructor; + +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; + +/** + * Default implementation of HytaleCommandMap that registers commands with Hytale's CommandRegistry. + * + *

This implementation supports two types of command executors:

+ *
    + *
  • {@link HytaleCommandExecutor} - For general commands that can be executed by any sender
  • + *
  • {@link HytalePlayerCommandExecutor} - For player-only commands that need thread-safe access to player/world data
  • + *
+ * + *

The executor type is automatically detected by checking if any command method uses + * {@link HytalePlayerCommandContext} as a parameter.

+ */ +@InjectableComponent +@RequiredArgsConstructor +public class DefaultHytaleCommandMap implements HytaleCommandMap { + + public static final MetaKey EXECUTOR_KEY = MetaKey.create("fairy:hytale-command-executor", AbstractCommand.class); + public static final MetaKey REGISTRATION_KEY = MetaKey.create("fairy:hytale-command-registration", CommandRegistration.class); + + @Override + public void register(BaseCommand command) { + if (this.isRegistered(command)) { + throw new IllegalArgumentException("Command already registered: " + command.getCommandNames()[0]); + } + + // Auto-detect if this is a player command by checking method parameters + boolean isPlayerCommand = hasPlayerCommandContext(command.getClass()); + + AbstractCommand commandExecutor; + if (isPlayerCommand) { + commandExecutor = new HytalePlayerCommandExecutor(command); + } else { + commandExecutor = new HytaleCommandExecutor(command); + } + + CommandRegistry commandRegistry = getCommandRegistry(); + CommandRegistration registration = commandRegistry.registerCommand(commandExecutor); + + command.getMetaStorage().put(EXECUTOR_KEY, commandExecutor); + command.getMetaStorage().put(REGISTRATION_KEY, registration); + } + + /** + * Check if any @Command annotated method in the class uses HytalePlayerCommandContext as a parameter. + */ + private boolean hasPlayerCommandContext(Class clazz) { + for (Method method : clazz.getDeclaredMethods()) { + if (!method.isAnnotationPresent(Command.class)) { + continue; + } + + for (Parameter parameter : method.getParameters()) { + if (HytalePlayerCommandContext.class.isAssignableFrom(parameter.getType())) { + return true; + } + } + } + + // Also check superclass methods + Class superclass = clazz.getSuperclass(); + if (superclass != null && superclass != Object.class) { + return hasPlayerCommandContext(superclass); + } + + return false; + } + + @Override + public void unregister(BaseCommand command) { + MetaStorage metaStorage = command.getMetaStorage(); + if (!this.isRegistered(command)) { + throw new IllegalArgumentException("Command not registered: " + command.getCommandNames()[0]); + } + + // Note: Hytale's CommandRegistry may not support unregistration + // Remove from meta storage to mark as unregistered + metaStorage.remove(EXECUTOR_KEY); + metaStorage.remove(REGISTRATION_KEY); + } + + @Override + public boolean isRegistered(BaseCommand command) { + return command.getMetaStorage().contains(EXECUTOR_KEY); + } + + private CommandRegistry getCommandRegistry() { + return FairyHytalePlatform.PLUGIN.getCommandRegistry(); + } +} diff --git a/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/map/HytaleCommandMap.java b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/map/HytaleCommandMap.java new file mode 100644 index 00000000..5e54ebb9 --- /dev/null +++ b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/map/HytaleCommandMap.java @@ -0,0 +1,36 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.fairyproject.hytale.command.map; + +import io.fairyproject.command.BaseCommand; + +public interface HytaleCommandMap { + + void register(BaseCommand command); + + void unregister(BaseCommand command); + + boolean isRegistered(BaseCommand command); +} diff --git a/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/presence/DefaultPresenceProvider.java b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/presence/DefaultPresenceProvider.java new file mode 100644 index 00000000..79d9f683 --- /dev/null +++ b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/presence/DefaultPresenceProvider.java @@ -0,0 +1,66 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.fairyproject.hytale.command.presence; + +import com.hypixel.hytale.server.core.Message; +import io.fairyproject.command.MessageType; +import io.fairyproject.command.PresenceProvider; +import io.fairyproject.hytale.command.event.HytaleCommandContext; + +/** + * Default presence provider for Hytale commands using Hytale's Message API. + */ +public class DefaultPresenceProvider implements PresenceProvider { + + private static final String COLOR_INFO = "#55FFFF"; // Aqua + private static final String COLOR_WARN = "#FFAA00"; // Gold + private static final String COLOR_ERROR = "#FF5555"; // Red + + @Override + public Class type() { + return HytaleCommandContext.class; + } + + @Override + public void sendMessage(HytaleCommandContext commandContext, MessageType messageType, String... messages) { + String color; + switch (messageType) { + case WARN: + color = COLOR_WARN; + break; + case ERROR: + color = COLOR_ERROR; + break; + default: + color = COLOR_INFO; + break; + } + + for (String message : messages) { + Message hytaleMessage = Message.raw(message).color(color); + commandContext.getSender().sendMessage(hytaleMessage); + } + } +} diff --git a/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/presence/PlayerPresenceProvider.java b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/presence/PlayerPresenceProvider.java new file mode 100644 index 00000000..15d17a45 --- /dev/null +++ b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/presence/PlayerPresenceProvider.java @@ -0,0 +1,68 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.fairyproject.hytale.command.presence; + +import com.hypixel.hytale.server.core.Message; +import io.fairyproject.command.MessageType; +import io.fairyproject.command.PresenceProvider; +import io.fairyproject.hytale.command.event.HytalePlayerCommandContext; + +/** + * Presence provider for Hytale player commands using Hytale's Message API. + * This provider is specifically for {@link HytalePlayerCommandContext} which provides + * access to player-specific data like World, PlayerRef, etc. + */ +public class PlayerPresenceProvider implements PresenceProvider { + + private static final String COLOR_INFO = "#55FFFF"; // Aqua + private static final String COLOR_WARN = "#FFAA00"; // Gold + private static final String COLOR_ERROR = "#FF5555"; // Red + + @Override + public Class type() { + return HytalePlayerCommandContext.class; + } + + @Override + public void sendMessage(HytalePlayerCommandContext commandContext, MessageType messageType, String... messages) { + String color; + switch (messageType) { + case WARN: + color = COLOR_WARN; + break; + case ERROR: + color = COLOR_ERROR; + break; + default: + color = COLOR_INFO; + break; + } + + for (String message : messages) { + Message hytaleMessage = Message.raw(message).color(color); + commandContext.getSender().sendMessage(hytaleMessage); + } + } +} diff --git a/framework/modules/hytale/settings.gradle.kts b/framework/modules/hytale/settings.gradle.kts new file mode 100644 index 00000000..9d9ae216 --- /dev/null +++ b/framework/modules/hytale/settings.gradle.kts @@ -0,0 +1,13 @@ +pluginManagement { + repositories { + gradlePluginPortal() + } +} + +includeBuild("../../../build-logic") +includeBuild("../../tests") +includeBuild("../../platforms") +includeBuild("..") + +include(":hytale-bom") +include(":hytale-command") diff --git a/framework/modules/settings.gradle.kts b/framework/modules/settings.gradle.kts index 8d5b210c..a1efc69a 100644 --- a/framework/modules/settings.gradle.kts +++ b/framework/modules/settings.gradle.kts @@ -7,5 +7,6 @@ pluginManagement { includeBuild("core") includeBuild("bukkit") includeBuild("mc") +includeBuild("hytale") include(":modules-bom") \ No newline at end of file diff --git a/framework/tests/hytale-tests/build.gradle.kts b/framework/tests/hytale-tests/build.gradle.kts new file mode 100644 index 00000000..7140047b --- /dev/null +++ b/framework/tests/hytale-tests/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + id("io.fairyproject.versioned") + id("io.fairyproject.publish") +} + +dependencies { + compileOnly("io.fairyproject:hytale-platform") + api(project(":core-tests")) +} diff --git a/framework/tests/hytale-tests/src/main/java/io/fairyproject/tests/hytale/HytaleTestingHandle.java b/framework/tests/hytale-tests/src/main/java/io/fairyproject/tests/hytale/HytaleTestingHandle.java new file mode 100644 index 00000000..37837d0d --- /dev/null +++ b/framework/tests/hytale-tests/src/main/java/io/fairyproject/tests/hytale/HytaleTestingHandle.java @@ -0,0 +1,39 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.fairyproject.tests.hytale; + +import io.fairyproject.tests.TestingHandle; + +/** + * This is the Hytale implementation for testing handle. + * You can use this to create custom server mock. + */ +public interface HytaleTestingHandle extends TestingHandle { + + @Override + default void onPreInitialization() { + // Hytale testing initialization + } +} diff --git a/framework/tests/settings.gradle.kts b/framework/tests/settings.gradle.kts index 3cc4b21c..1d9d7938 100644 --- a/framework/tests/settings.gradle.kts +++ b/framework/tests/settings.gradle.kts @@ -11,4 +11,5 @@ include(":core-tests") include(":app-tests") include(":mc-tests") include(":bukkit-tests") +include(":hytale-tests") include(":tests-bom") diff --git a/test-hytale-plugin/build.gradle.kts b/test-hytale-plugin/build.gradle.kts index 966551dc..a3966513 100644 --- a/test-hytale-plugin/build.gradle.kts +++ b/test-hytale-plugin/build.gradle.kts @@ -18,6 +18,7 @@ fairy { dependencies { implementation("io.fairyproject:hytale-bootstrap") implementation("io.fairyproject:hytale-platform") + implementation("io.fairyproject:hytale-command") } java { diff --git a/test-hytale-plugin/src/main/java/io/example/hytale/command/TestCommand.java b/test-hytale-plugin/src/main/java/io/example/hytale/command/TestCommand.java new file mode 100644 index 00000000..93b234d2 --- /dev/null +++ b/test-hytale-plugin/src/main/java/io/example/hytale/command/TestCommand.java @@ -0,0 +1,152 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.example.hytale.command; + +import io.example.hytale.component.HelloComponent; +import io.fairyproject.command.BaseCommand; +import io.fairyproject.command.MessageType; +import io.fairyproject.command.annotation.Arg; +import io.fairyproject.command.annotation.Command; +import io.fairyproject.container.Autowired; +import io.fairyproject.container.InjectableComponent; +import io.fairyproject.hytale.command.event.HytalePlayerCommandContext; + +/** + * Test command for demonstrating Fairy's command system on Hytale. + * + *

This command uses {@link HytalePlayerCommandContext} which means:

+ *
    + *
  • It can only be executed by players (not console)
  • + *
  • It runs on the correct thread for the player's world
  • + *
  • It has access to player-specific data like World, PlayerRef, etc.
  • + *
+ * + *

The executor type is automatically detected based on the context parameter type.

+ */ +@Command({"test", "t", "hytaletest"}) +@InjectableComponent +public class TestCommand extends BaseCommand { + + @Autowired + private HelloComponent helloComponent; + + @Override + public String getDescription() { + return "Test command for Hytale"; + } + + /** + * /test - Shows help message + */ + @Command("#") + public void noArgs(HytalePlayerCommandContext context) { + context.sendMessage(MessageType.INFO, "=== Hytale Test Plugin Commands ==="); + context.sendMessage(MessageType.INFO, "/test hello [name] - Greet someone"); + context.sendMessage(MessageType.INFO, "/test info - Show plugin info"); + context.sendMessage(MessageType.INFO, "/test echo - Echo a message"); + context.sendMessage(MessageType.INFO, "/test add - Add two numbers"); + context.sendMessage(MessageType.INFO, "/test player - Show player info"); + context.sendMessage(MessageType.INFO, "/test world - Show world info"); + } + + /** + * /test hello [name] - Greets the player or a specified name + */ + @Command("hello") + public void hello(HytalePlayerCommandContext context, @Arg(defaultValue = "World") String name) { + String greeting = helloComponent.sayHello(name); + context.sendMessage(MessageType.INFO, greeting); + } + + /** + * /test info - Shows plugin information + */ + @Command("info") + public void info(HytalePlayerCommandContext context) { + context.sendMessage(MessageType.INFO, "Plugin: Hytale Test Plugin"); + context.sendMessage(MessageType.INFO, "Framework: Fairy Project"); + context.sendMessage(MessageType.INFO, "Platform: Hytale"); + context.sendMessage(MessageType.INFO, "Sender: " + context.name()); + } + + /** + * /test echo - Echoes a message back to the sender + */ + @Command("echo") + public void echo(HytalePlayerCommandContext context, @Arg String message) { + context.sendMessage(MessageType.INFO, "Echo: " + message); + } + + /** + * /test add - Adds two numbers together + */ + @Command("add") + public void add(HytalePlayerCommandContext context, @Arg int a, @Arg int b) { + int result = a + b; + context.sendMessage(MessageType.INFO, a + " + " + b + " = " + result); + } + + /** + * /test warn - Test warning message + */ + @Command("warn") + public void warn(HytalePlayerCommandContext context) { + context.sendMessage(MessageType.WARN, "This is a warning message!"); + } + + /** + * /test error - Test error message + */ + @Command("error") + public void error(HytalePlayerCommandContext context) { + context.sendMessage(MessageType.ERROR, "This is an error message!"); + } + + /** + * /test player - Shows player-specific information + * Demonstrates access to HytalePlayerCommandContext data + */ + @Command("player") + public void player(HytalePlayerCommandContext context) { + context.sendMessage(MessageType.INFO, "=== Player Info ==="); + context.sendMessage(MessageType.INFO, "Username: " + context.getPlayerName()); + context.sendMessage(MessageType.INFO, "UUID: " + context.getPlayerUuid()); + context.sendMessage(MessageType.INFO, "Valid: " + context.isPlayerValid()); + context.sendMessage(MessageType.INFO, "In Store Thread: " + context.isInStoreThread()); + } + + /** + * /test world - Shows world information + * Demonstrates thread-safe access to world data + */ + @Command("world") + public void world(HytalePlayerCommandContext context) { + context.sendMessage(MessageType.INFO, "=== World Info ==="); + context.sendMessage(MessageType.INFO, "World Name: " + context.getWorld().getName()); + context.sendMessage(MessageType.INFO, "Store Index: " + context.getStore().getStoreIndex()); + context.sendMessage(MessageType.INFO, "Entity Count: " + context.getStore().getEntityCount()); + context.sendMessage(MessageType.INFO, "Thread Safe: " + context.isInStoreThread()); + } +} From c9e18835d724824e501d766b295b97a1e44edf07 Mon Sep 17 00:00:00 2001 From: LeeGodSRC Date: Tue, 20 Jan 2026 21:01:14 +0800 Subject: [PATCH 04/20] feat: redesign RunHytaleServer to support development mode with loose classes - Add GenerateHytaleManifestTask to generate manifest.json and fairy.json - Change RunHytaleServerTask to use mainClass with runtime classpath - Add devtools classpath support in RootNodeLoader for framework scanning - Fix LibraryHandlerPluginListener for Java 9+ non-URLClassLoader - Add extension options: allowOp, disableSentry, authMode - Add core-devtools dependency to test-hytale-plugin - Add UI examples (ExampleHud, ExamplePage) to test plugin --- .../event/HytalePlayerCommandContext.java | 5 + .../command/map/DefaultHytaleCommandMap.java | 6 +- .../container/RootNodeLoader.java | 30 ++ .../library/LibraryHandlerPluginListener.java | 8 +- .../runner/hytale/RunHytaleServerExtension.kt | 15 + .../runner/hytale/RunHytaleServerPlugin.kt | 30 +- .../hytale/task/GenerateHytaleManifestTask.kt | 326 ++++++++++++++++++ .../runner/hytale/task/RunHytaleServerTask.kt | 25 +- test-hytale-plugin/build.gradle.kts | 1 + .../example/hytale/command/TestCommand.java | 18 + .../io/example/hytale/huds/ExampleHud.java | 19 + .../io/example/hytale/pages/ExamplePage.java | 24 ++ .../src/main/resources/.gitignore | 2 + .../Common/UI/Custom/Hud/ExampleHud.ui | 21 ++ .../Common/UI/Custom/Pages/ExamplePage.ui | 34 ++ 15 files changed, 559 insertions(+), 5 deletions(-) create mode 100644 gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/GenerateHytaleManifestTask.kt create mode 100644 test-hytale-plugin/src/main/java/io/example/hytale/huds/ExampleHud.java create mode 100644 test-hytale-plugin/src/main/java/io/example/hytale/pages/ExamplePage.java create mode 100644 test-hytale-plugin/src/main/resources/.gitignore create mode 100644 test-hytale-plugin/src/main/resources/Common/UI/Custom/Hud/ExampleHud.ui create mode 100644 test-hytale-plugin/src/main/resources/Common/UI/Custom/Pages/ExamplePage.ui diff --git a/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/event/HytalePlayerCommandContext.java b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/event/HytalePlayerCommandContext.java index ad2898a2..fa28de1d 100644 --- a/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/event/HytalePlayerCommandContext.java +++ b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/event/HytalePlayerCommandContext.java @@ -27,6 +27,7 @@ import com.hypixel.hytale.component.Ref; import com.hypixel.hytale.component.Store; import com.hypixel.hytale.server.core.command.system.CommandSender; +import com.hypixel.hytale.server.core.entity.entities.Player; import com.hypixel.hytale.server.core.universe.PlayerRef; import com.hypixel.hytale.server.core.universe.world.World; import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; @@ -95,6 +96,10 @@ public PlayerRef getPlayerRef() { return playerRef; } + public Player getPlayerComponent() { + return store.getComponent(ref, Player.getComponentType()); + } + /** * Get the world the player is in. * diff --git a/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/map/DefaultHytaleCommandMap.java b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/map/DefaultHytaleCommandMap.java index be6b2f84..ca06cee4 100644 --- a/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/map/DefaultHytaleCommandMap.java +++ b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/map/DefaultHytaleCommandMap.java @@ -36,7 +36,6 @@ import io.fairyproject.hytale.command.HytaleCommandExecutor; import io.fairyproject.hytale.command.HytalePlayerCommandExecutor; import io.fairyproject.hytale.command.event.HytalePlayerCommandContext; -import lombok.RequiredArgsConstructor; import java.lang.reflect.Method; import java.lang.reflect.Parameter; @@ -54,12 +53,15 @@ * {@link HytalePlayerCommandContext} as a parameter.

*/ @InjectableComponent -@RequiredArgsConstructor public class DefaultHytaleCommandMap implements HytaleCommandMap { public static final MetaKey EXECUTOR_KEY = MetaKey.create("fairy:hytale-command-executor", AbstractCommand.class); public static final MetaKey REGISTRATION_KEY = MetaKey.create("fairy:hytale-command-registration", CommandRegistration.class); + public DefaultHytaleCommandMap() { + System.out.println("DefaultHytaleCommandMap initialized"); + } + @Override public void register(BaseCommand command) { if (this.isRegistered(command)) { diff --git a/framework/platforms/core-platform/src/main/java/io/fairyproject/container/RootNodeLoader.java b/framework/platforms/core-platform/src/main/java/io/fairyproject/container/RootNodeLoader.java index 3398cd85..57d2476c 100644 --- a/framework/platforms/core-platform/src/main/java/io/fairyproject/container/RootNodeLoader.java +++ b/framework/platforms/core-platform/src/main/java/io/fairyproject/container/RootNodeLoader.java @@ -34,6 +34,9 @@ import io.fairyproject.util.Stacktrace; import lombok.RequiredArgsConstructor; +import java.net.URL; +import java.nio.file.Paths; + import static io.fairyproject.Debug.log; @RequiredArgsConstructor @@ -62,6 +65,9 @@ private void runClassScanner() { if (!Debug.UNIT_TEST) { classScanner.getUrls().add(this.getClass().getProtectionDomain().getCodeSource().getLocation()); classScanner.getClassLoaders().add(ContainerContext.class.getClassLoader()); + + // Also add URLs from devtools classpath for development mode + addDevtoolsClasspathUrls(classScanner); } classScanner.scan(); @@ -71,6 +77,30 @@ private void runClassScanner() { } } + private void addDevtoolsClasspathUrls(ContainerNodeClassScanner classScanner) { + String classpathProperty = System.getProperty("io.fairyproject.devtools.classpath"); + if (classpathProperty == null || classpathProperty.isEmpty()) { + return; + } + + // Parse format: name1|path1,path2:name2|path3,path4 + String[] entries = classpathProperty.split(":"); + for (String entry : entries) { + try { + String[] parts = entry.split("\\|"); + if (parts.length < 2) continue; + + String paths = parts[1]; + for (String path : paths.split(",")) { + URL url = Paths.get(path).toUri().toURL(); + classScanner.getUrls().add(url); + } + } catch (Exception e) { + // Ignore parsing errors + } + } + } + private void addPreDefinedComponents() { ContainerObj obj = ContainerObj.create(this.context.getClass()); this.node.addObj(obj); diff --git a/framework/platforms/core-platform/src/main/java/io/fairyproject/library/LibraryHandlerPluginListener.java b/framework/platforms/core-platform/src/main/java/io/fairyproject/library/LibraryHandlerPluginListener.java index 9db40554..6f6ca4d6 100644 --- a/framework/platforms/core-platform/src/main/java/io/fairyproject/library/LibraryHandlerPluginListener.java +++ b/framework/platforms/core-platform/src/main/java/io/fairyproject/library/LibraryHandlerPluginListener.java @@ -52,7 +52,13 @@ public void onPluginPreLoaded(ClassLoader classLoader, PluginDescription descrip @Override public void onPluginInitial(Plugin plugin) { - final URLClassLoaderAccess classLoader = URLClassLoaderAccess.create((URLClassLoader) plugin.getPluginClassLoader()); + ClassLoader pluginClassLoader = plugin.getPluginClassLoader(); + if (!(pluginClassLoader instanceof URLClassLoader)) { + // In Java 9+, the system classloader is not a URLClassLoader + // Skip library injection for non-URLClassLoader classloaders + return; + } + final URLClassLoaderAccess classLoader = URLClassLoaderAccess.create((URLClassLoader) pluginClassLoader); this.libraryHandler.addClassLoader(plugin, classLoader); } diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerExtension.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerExtension.kt index 2d152e94..90073719 100644 --- a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerExtension.kt +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerExtension.kt @@ -80,4 +80,19 @@ open class RunHytaleServerExtension(objectFactory: ObjectFactory) { */ val downloaderPath: Property = objectFactory.property(String::class.java) + /** + * Allow OP commands (default true). + */ + val allowOp: Property = objectFactory.property(Boolean::class.java).convention(true) + + /** + * Disable Sentry error reporting (default true). + */ + val disableSentry: Property = objectFactory.property(Boolean::class.java).convention(true) + + /** + * Authentication mode: "authenticated", "unauthenticated", or "mixed" (default "authenticated"). + */ + val authMode: Property = objectFactory.property(String::class.java).convention("authenticated") + } diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerPlugin.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerPlugin.kt index d342ae82..4e37a349 100644 --- a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerPlugin.kt +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerPlugin.kt @@ -27,12 +27,14 @@ package io.fairyproject.gradle.runner.hytale import io.fairyproject.gradle.FairyGradlePlugin import io.fairyproject.gradle.runner.ClasspathRegistry import io.fairyproject.gradle.runner.hytale.action.CopyHytaleSnapshotAction +import io.fairyproject.gradle.runner.hytale.task.GenerateHytaleManifestTask import io.fairyproject.gradle.runner.hytale.task.PrepareHytaleBuildTask import io.fairyproject.gradle.runner.hytale.task.PrepareHytaleDownloaderTask import io.fairyproject.gradle.runner.hytale.task.RunHytaleServerTask import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.file.DuplicatesStrategy +import org.gradle.api.plugins.JavaPluginExtension import org.gradle.api.tasks.Copy import org.gradle.api.tasks.Delete import org.gradle.jvm.tasks.Jar @@ -91,6 +93,7 @@ open class RunHytaleServerPlugin : Plugin { configurePrepareHytaleBuild(downloaderDir, downloadsDir, workDir, artifact) configureCopyHytaleModJar(workDir) configureCleanHytaleServer(workDir) + configureGenerateHytaleManifest() configureRunHytaleServer(artifact, workDir, snapshotDir) } @@ -168,6 +171,31 @@ open class RunHytaleServerPlugin : Plugin { } } + private fun configureGenerateHytaleManifest() { + val javaExtension = project.extensions.getByType(JavaPluginExtension::class.java) + val mainSourceSet = javaExtension.sourceSets.getByName("main") + + project.tasks.register("generateHytaleManifest", GenerateHytaleManifestTask::class.java) { + it.group = group + it.description = "Generates Hytale manifest.json for development" + + // Input: compiled classes directory + it.classesDir.set(mainSourceSet.output.classesDirs.singleFile) + + // Input: runtime classpath (to find HytalePlugin from hytale-bootstrap) + it.runtimeClasspath.from(mainSourceSet.runtimeClasspath) + + // Output: src/main/ so manifest.json is at root of --mods path + it.outputDir.set(project.file("src/main/resources")) + + it.projectName.set(project.name) + it.projectVersion.set(project.version.toString()) + it.projectDescription.set(project.description ?: "") + + it.dependsOn("classes") + } + } + private fun configureRunHytaleServer( artifact: HytaleServerArtifact, workDir: Path, @@ -186,8 +214,8 @@ open class RunHytaleServerPlugin : Plugin { it.dependsOn("cleanHytaleServer") } it.doFirst(CopyHytaleSnapshotAction(snapshotDir, workDir)) - it.dependsOn("copyHytaleModJar") it.dependsOn("prepareHytaleBuild") + it.dependsOn("generateHytaleManifest") it.group = group it.description = "Runs the Hytale server with mods" diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/GenerateHytaleManifestTask.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/GenerateHytaleManifestTask.kt new file mode 100644 index 00000000..3ebed16d --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/GenerateHytaleManifestTask.kt @@ -0,0 +1,326 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.fairyproject.gradle.runner.hytale.task + +import com.google.gson.GsonBuilder +import io.fairyproject.gradle.constants.ClassConstants +import io.fairyproject.gradle.extension.FairyExtension +import io.fairyproject.gradle.extension.property.HytaleAuthor +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Classpath +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction +import org.objectweb.asm.ClassReader +import org.objectweb.asm.Opcodes +import org.objectweb.asm.tree.ClassNode +import java.io.File +import java.util.jar.JarFile + +/** + * Task for generating Hytale manifest.json into the build output directory. + * This allows running the server with compiled classes instead of a JAR. + * + * @since 0.7 + * @author LeeGod + */ +abstract class GenerateHytaleManifestTask : DefaultTask() { + + @get:InputDirectory + abstract val classesDir: DirectoryProperty + + @get:Classpath + abstract val runtimeClasspath: ConfigurableFileCollection + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @get:Input + abstract val projectName: Property + + @get:Input + abstract val projectVersion: Property + + @get:Input + abstract val projectDescription: Property + + private val gson = GsonBuilder().setPrettyPrinting().create() + + @TaskAction + fun generate() { + val extension = project.extensions.findByType(FairyExtension::class.java) + + // Find HytalePlugin class + val hytalePluginClass = findHytalePluginClass() ?: run { + logger.warn("[Fairy] No HytalePlugin class found, skipping manifest generation") + return + } + + // Find main class (with @FairyLaunch annotation) + val mainClass = findMainClass() ?: run { + logger.warn("[Fairy] No main class with @FairyLaunch found, skipping fairy.json generation") + return + } + + val outputDirectory = outputDir.get().asFile + outputDirectory.mkdirs() + + // Generate manifest.json + val hytaleProps = extension?.hytalePropertiesRaw() ?: emptyMap() + val manifest = buildManifest(hytalePluginClass, hytaleProps, extension) + val manifestFile = File(outputDirectory, "manifest.json") + manifestFile.writeText(gson.toJson(manifest)) + logger.lifecycle("[Fairy] Generated Hytale manifest.json at ${manifestFile.absolutePath}") + + // Generate fairy.json + val fairyJson = buildFairyJson(mainClass, extension) + val fairyFile = File(outputDirectory, "fairy.json") + fairyFile.writeText(gson.toJson(fairyJson)) + logger.lifecycle("[Fairy] Generated fairy.json at ${fairyFile.absolutePath}") + } + + private fun findMainClass(): String? { + val classesDirectory = classesDir.get().asFile + if (!classesDirectory.exists()) return null + + // First, find the Plugin/Application interface class from runtime classpath + val pluginInterfaceClass = findPluginInterfaceClass() + + classesDirectory.walkTopDown() + .filter { it.isFile && it.extension == "class" } + .forEach { classFile -> + val className = findMainClassInFile(classFile, pluginInterfaceClass) + if (className != null) return className + } + + return null + } + + private fun findPluginInterfaceClass(): String? { + // Look for Plugin or Application class with @FairyInternalIdentityMeta + runtimeClasspath.files + .filter { it.isFile && it.extension == "jar" } + .forEach { jarFile -> + try { + JarFile(jarFile).use { jar -> + for (entry in jar.entries()) { + if (!entry.name.endsWith(".class")) continue + val simpleName = entry.name.substringAfterLast("/").removeSuffix(".class") + if (simpleName != "Plugin" && simpleName != "Application") continue + + val bytes = jar.getInputStream(entry).readBytes() + val classReader = ClassReader(bytes) + val classNode = ClassNode() + classReader.accept(classNode, ClassReader.SKIP_CODE or ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES) + + val hasInternalMeta = classNode.visibleAnnotations?.any { + it.desc.contains(ClassConstants.INTERNAL_META) + } ?: false + + if (hasInternalMeta) { + return classNode.name + } + } + } + } catch (e: Exception) { + // Ignore + } + } + return null + } + + private fun findMainClassInFile(classFile: File, pluginInterfaceClass: String?): String? { + val bytes = classFile.readBytes() + val classReader = ClassReader(bytes) + val classNode = ClassNode() + + classReader.accept( + classNode, + ClassReader.SKIP_CODE or ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES + ) + + // Skip abstract classes and interfaces + if (classNode.access and Opcodes.ACC_ABSTRACT != 0) return null + if (classNode.access and Opcodes.ACC_INTERFACE != 0) return null + + // Check if this class has @FairyLaunch annotation + val hasFairyLaunch = classNode.visibleAnnotations?.any { + it.desc.contains(ClassConstants.FAIRY_LAUNCH) + } ?: false + + // Check if this class extends Plugin or Application + val extendsPluginInterface = pluginInterfaceClass != null && classNode.superName == pluginInterfaceClass + + if (!hasFairyLaunch && !extendsPluginInterface) return null + + return classNode.name.replace("/", ".") + } + + private fun buildFairyJson(mainClass: String, extension: FairyExtension?): Map { + val json = mutableMapOf() + json["name"] = extension?.name?.orNull ?: projectName.get() + json["mainClass"] = mainClass + extension?.mainPackage?.orNull?.let { json["shadedPackage"] = it } + extension?.fairyPackage?.orNull?.let { json["fairyPackage"] = it } + return json + } + + private fun findHytalePluginClass(): String? { + // First, scan project's compiled classes + val classesDirectory = classesDir.get().asFile + if (classesDirectory.exists()) { + classesDirectory.walkTopDown() + .filter { it.isFile && it.extension == "class" } + .forEach { classFile -> + val className = findHytalePluginInClassFile(classFile) + if (className != null) return className + } + } + + // Then, scan runtime classpath JARs (for HytalePlugin from hytale-bootstrap) + runtimeClasspath.files + .filter { it.isFile && it.extension == "jar" } + .forEach { jarFile -> + val className = findHytalePluginInJar(jarFile) + if (className != null) return className + } + + return null + } + + private fun findHytalePluginInJar(jarFile: File): String? { + try { + JarFile(jarFile).use { jar -> + for (entry in jar.entries()) { + if (!entry.name.endsWith(".class")) continue + if (!entry.name.endsWith("HytalePlugin.class")) continue + + val bytes = jar.getInputStream(entry).readBytes() + val className = findHytalePluginInBytes(bytes) + if (className != null) return className + } + } + } catch (e: Exception) { + // Ignore JAR read errors + } + return null + } + + private fun findHytalePluginInClassFile(classFile: File): String? { + return findHytalePluginInBytes(classFile.readBytes()) + } + + private fun findHytalePluginInBytes(bytes: ByteArray): String? { + val classReader = ClassReader(bytes) + val classNode = ClassNode() + + classReader.accept( + classNode, + ClassReader.SKIP_CODE or ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES + ) + + // Check if this class has @FairyInternalIdentityMeta annotation + val hasInternalMeta = classNode.visibleAnnotations?.any { + it.desc.contains(ClassConstants.INTERNAL_META) + } ?: false + + if (!hasInternalMeta) return null + + // Check if the class name ends with HytalePlugin + val simpleName = classNode.name.substringAfterLast("/") + if (simpleName != "HytalePlugin") return null + + // Skip abstract classes and interfaces + if (classNode.access and Opcodes.ACC_ABSTRACT != 0) return null + if (classNode.access and Opcodes.ACC_INTERFACE != 0) return null + + return classNode.name.replace("/", ".") + } + + private fun buildManifest( + hytalePluginClass: String, + hytaleProps: Map, + extension: FairyExtension? + ): Map { + val manifest = mutableMapOf() + + manifest["Main"] = hytalePluginClass + manifest["Group"] = (hytaleProps["Group"] as? String)?.takeIf { it.isNotEmpty() } ?: "io.fairyproject" + manifest["Name"] = extension?.name?.orNull ?: projectName.get() + manifest["Version"] = normalizeToSemver(projectVersion.get()) + manifest["Description"] = projectDescription.get() + + // Authors + val authors = hytaleProps["Authors"] + manifest["Authors"] = if (authors is List<*> && authors.isNotEmpty()) { + authors.map { author -> + when (author) { + is HytaleAuthor -> mapOf( + "Name" to author.name, + "Email" to author.email, + "Url" to author.url + ) + else -> mapOf("Name" to "", "Email" to "", "Url" to "") + } + } + } else { + emptyList>() + } + + manifest["Website"] = hytaleProps["Website"] as? String ?: "" + manifest["ServerVersion"] = hytaleProps["ServerVersion"] as? String ?: "" + manifest["Dependencies"] = hytaleProps["Dependencies"] as? Map<*, *> ?: emptyMap() + manifest["OptionalDependencies"] = hytaleProps["OptionalDependencies"] as? Map<*, *> ?: emptyMap() + manifest["LoadBefore"] = hytaleProps["LoadBefore"] as? Map<*, *> ?: emptyMap() + manifest["DisabledByDefault"] = hytaleProps["DisabledByDefault"] as? Boolean ?: false + manifest["IncludesAssetPack"] = hytaleProps["IncludesAssetPack"] as? Boolean ?: true + manifest["SubPlugins"] = hytaleProps["SubPlugins"] as? List<*> ?: emptyList() + + return manifest + } + + private fun normalizeToSemver(version: String): String { + if (version.isBlank() || version == "unspecified") { + return "0.0.1" + } + + val semverRegex = Regex("""^\d+\.\d+\.\d+(-[\w.]+)?(\+[\w.]+)?$""") + if (semverRegex.matches(version)) { + return version + } + + val numbers = Regex("""\d+""").findAll(version).map { it.value }.toList() + return when { + numbers.size >= 3 -> "${numbers[0]}.${numbers[1]}.${numbers[2]}" + numbers.size == 2 -> "${numbers[0]}.${numbers[1]}.0" + numbers.size == 1 -> "${numbers[0]}.0.0" + else -> "0.0.1" + } + } +} diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/RunHytaleServerTask.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/RunHytaleServerTask.kt index dea71ede..6519e1dc 100644 --- a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/RunHytaleServerTask.kt +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/RunHytaleServerTask.kt @@ -27,6 +27,7 @@ package io.fairyproject.gradle.runner.hytale.task import io.fairyproject.gradle.runner.hytale.HytaleServerArtifact import io.fairyproject.gradle.runner.hytale.RunHytaleServerExtension import org.gradle.api.JavaVersion +import org.gradle.api.plugins.JavaPluginExtension import org.gradle.api.tasks.JavaExec import org.gradle.jvm.toolchain.JavaLanguageVersion import java.nio.file.Path @@ -47,7 +48,18 @@ open class RunHytaleServerTask @Inject constructor( ) : JavaExec() { init { - classpath = project.files(artifact.serverJarPath) + // Set main class for Hytale server + mainClass.set("com.hypixel.hytale.Main") + + // Get project's source set + val mainSourceSet = project.extensions + .getByType(JavaPluginExtension::class.java) + .sourceSets + .getByName("main") + + // Classpath: HytaleServer.jar + project's runtime classpath (compiled classes + dependencies) + classpath = project.files(artifact.serverJarPath) + mainSourceSet.runtimeClasspath + workingDir = workDirectory.toFile() standardInput = System.`in` @@ -59,6 +71,17 @@ open class RunHytaleServerTask @Inject constructor( // Hytale server requires specific arguments args("--assets", artifact.assetsPath.toAbsolutePath().toString()) args("--bind", extension.bindAddress.get()) + // Point to src/main where manifest.json is generated in resources/ + args("--mods", project.file("src/main").absolutePath) + args("--auth-mode", extension.authMode.get()) + + if (extension.allowOp.get()) { + args("--allow-op") + } + + if (extension.disableSentry.get()) { + args("--disable-sentry") + } } override fun getJavaVersion(): JavaVersion { diff --git a/test-hytale-plugin/build.gradle.kts b/test-hytale-plugin/build.gradle.kts index a3966513..3c77c85f 100644 --- a/test-hytale-plugin/build.gradle.kts +++ b/test-hytale-plugin/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { implementation("io.fairyproject:hytale-bootstrap") implementation("io.fairyproject:hytale-platform") implementation("io.fairyproject:hytale-command") + implementation("io.fairyproject:core-devtools") } java { diff --git a/test-hytale-plugin/src/main/java/io/example/hytale/command/TestCommand.java b/test-hytale-plugin/src/main/java/io/example/hytale/command/TestCommand.java index 93b234d2..65e015c0 100644 --- a/test-hytale-plugin/src/main/java/io/example/hytale/command/TestCommand.java +++ b/test-hytale-plugin/src/main/java/io/example/hytale/command/TestCommand.java @@ -24,7 +24,13 @@ package io.example.hytale.command; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.server.core.entity.entities.Player; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; import io.example.hytale.component.HelloComponent; +import io.example.hytale.huds.ExampleHud; +import io.example.hytale.pages.ExamplePage; import io.fairyproject.command.BaseCommand; import io.fairyproject.command.MessageType; import io.fairyproject.command.annotation.Arg; @@ -52,6 +58,10 @@ public class TestCommand extends BaseCommand { @Autowired private HelloComponent helloComponent; + public TestCommand() { + System.out.println("[TestCommand] Command component created!"); + } + @Override public String getDescription() { return "Test command for Hytale"; @@ -137,6 +147,14 @@ public void player(HytalePlayerCommandContext context) { context.sendMessage(MessageType.INFO, "In Store Thread: " + context.isInStoreThread()); } + @Command("ui") + public void ui(HytalePlayerCommandContext context) { + Player playerComponent = context.getPlayerComponent(); + Ref ref = context.getRef(); + PlayerRef playerRef = context.getPlayerRef(); + playerComponent.getPageManager().openCustomPage(ref, context.getStore(), new ExamplePage(playerRef)); + } + /** * /test world - Shows world information * Demonstrates thread-safe access to world data diff --git a/test-hytale-plugin/src/main/java/io/example/hytale/huds/ExampleHud.java b/test-hytale-plugin/src/main/java/io/example/hytale/huds/ExampleHud.java new file mode 100644 index 00000000..cadc0d09 --- /dev/null +++ b/test-hytale-plugin/src/main/java/io/example/hytale/huds/ExampleHud.java @@ -0,0 +1,19 @@ +package io.example.hytale.huds; + +import com.hypixel.hytale.server.core.entity.entities.player.hud.CustomUIHud; +import com.hypixel.hytale.server.core.ui.builder.UICommandBuilder; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import org.checkerframework.checker.nullness.compatqual.NonNullDecl; + +public class ExampleHud extends CustomUIHud { + public ExampleHud(@NonNullDecl PlayerRef playerRef) { + super(playerRef); + } + + @Override + protected void build(@NonNullDecl UICommandBuilder builder) { + builder.append("Hud/ExampleHud.ui"); + + builder.set("#TitleLabel.Text", "Hello " + this.getPlayerRef().getUsername()); + } +} \ No newline at end of file diff --git a/test-hytale-plugin/src/main/java/io/example/hytale/pages/ExamplePage.java b/test-hytale-plugin/src/main/java/io/example/hytale/pages/ExamplePage.java new file mode 100644 index 00000000..c9cfdb1b --- /dev/null +++ b/test-hytale-plugin/src/main/java/io/example/hytale/pages/ExamplePage.java @@ -0,0 +1,24 @@ +package io.example.hytale.pages; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.protocol.packets.interface_.CustomPageLifetime; +import com.hypixel.hytale.server.core.entity.entities.player.pages.CustomUIPage; +import com.hypixel.hytale.server.core.ui.builder.UICommandBuilder; +import com.hypixel.hytale.server.core.ui.builder.UIEventBuilder; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import org.checkerframework.checker.nullness.compatqual.NonNullDecl; + +public class ExamplePage extends CustomUIPage { + public ExamplePage(@NonNullDecl PlayerRef playerRef) { + super(playerRef, CustomPageLifetime.CanDismiss); + } + + @Override + public void build(@NonNullDecl Ref ref, @NonNullDecl UICommandBuilder builder, @NonNullDecl UIEventBuilder uiEventBuilder, @NonNullDecl Store store) { + builder.append("Pages/ExamplePage.ui"); + + builder.set("#ItemSlot.ItemId", "Deco_Trophy_Harvest"); + } +} \ No newline at end of file diff --git a/test-hytale-plugin/src/main/resources/.gitignore b/test-hytale-plugin/src/main/resources/.gitignore new file mode 100644 index 00000000..97708284 --- /dev/null +++ b/test-hytale-plugin/src/main/resources/.gitignore @@ -0,0 +1,2 @@ +manifest.json +fairy.json \ No newline at end of file diff --git a/test-hytale-plugin/src/main/resources/Common/UI/Custom/Hud/ExampleHud.ui b/test-hytale-plugin/src/main/resources/Common/UI/Custom/Hud/ExampleHud.ui new file mode 100644 index 00000000..791f3d11 --- /dev/null +++ b/test-hytale-plugin/src/main/resources/Common/UI/Custom/Hud/ExampleHud.ui @@ -0,0 +1,21 @@ +Group { + LayoutMode: Right; + Anchor: (Top: 20, Right: 20, Height: 60); + + Group #Box { + Background: #000000(0.2); + Anchor: (Left: 0); + Padding: (Horizontal: 20, Vertical: 10); + LayoutMode: Left; + + Group { + Background: "../Common/WIPIcon.png"; + Anchor: (Width: 40, Height: 40, Right: 10); + } + + Label #TitleLabel { + Style: (FontSize: 32, Alignment: Center); + } + + } +} \ No newline at end of file diff --git a/test-hytale-plugin/src/main/resources/Common/UI/Custom/Pages/ExamplePage.ui b/test-hytale-plugin/src/main/resources/Common/UI/Custom/Pages/ExamplePage.ui new file mode 100644 index 00000000..4344b14b --- /dev/null +++ b/test-hytale-plugin/src/main/resources/Common/UI/Custom/Pages/ExamplePage.ui @@ -0,0 +1,34 @@ +$C = "../Common.ui"; + +$C.@PageOverlay {} + +$C.@Container { + Anchor: (Width: 600, Height: 700); + + #Title { + Group { + $C.@Title { + @Text = %example.customUI.example.title; + } + } + } + + #Content { + LayoutMode: Top; + Padding: (Horizontal: 10); + + ItemSlotButton { + Padding: (Full: 6); + LayoutMode: Left; + + ItemSlot #ItemSlot { + Anchor: (Full: 0, Height: 68, Width: 68); + ShowQualityBackground: true; + } + } + + + } +} + +$C.@BackButton {} \ No newline at end of file From ae74be7a622077480569e04c307e5e6410d2c27d Mon Sep 17 00:00:00 2001 From: LeeGodSRC Date: Wed, 21 Jan 2026 12:42:19 +0800 Subject: [PATCH 05/20] feat(hytale-command): support mixed context types allowing console to execute non-player commands --- .../hytale/command/HytaleCommandModule.java | 7 +- .../command/HytaleMixedCommandExecutor.java | 172 ++++++++++++++++++ .../command/map/DefaultHytaleCommandMap.java | 80 +++++--- .../presence/PlayerPresenceProvider.java | 22 ++- .../example/hytale/command/TestCommand.java | 24 +-- 5 files changed, 262 insertions(+), 43 deletions(-) create mode 100644 framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/HytaleMixedCommandExecutor.java diff --git a/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/HytaleCommandModule.java b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/HytaleCommandModule.java index 7ba732ce..9a8ab51e 100644 --- a/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/HytaleCommandModule.java +++ b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/HytaleCommandModule.java @@ -39,9 +39,12 @@ public class HytaleCommandModule { @PreInitialize public void onPreInitialize() { - // Register presence provider for general commands (HytaleCommandContext) + // Register presence providers for Hytale commands + // The command framework uses exact type matching, so we need to register + // separate providers for each context type: + // - DefaultPresenceProvider for HytaleCommandContext (any sender) + // - PlayerPresenceProvider for HytalePlayerCommandContext (player-only) commandService.registerDefaultPresenceProvider(new DefaultPresenceProvider()); - // Register presence provider for player commands (HytalePlayerCommandContext) commandService.registerDefaultPresenceProvider(new PlayerPresenceProvider()); } } diff --git a/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/HytaleMixedCommandExecutor.java b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/HytaleMixedCommandExecutor.java new file mode 100644 index 00000000..01bd5320 --- /dev/null +++ b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/HytaleMixedCommandExecutor.java @@ -0,0 +1,172 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.fairyproject.hytale.command; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractAsyncCommand; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import io.fairyproject.command.BaseCommand; +import io.fairyproject.hytale.command.event.HytaleCommandContext; +import io.fairyproject.hytale.command.event.HytalePlayerCommandContext; +import lombok.Getter; + +import java.util.concurrent.CompletableFuture; + +/** + * Hytale mixed command executor that supports both player and console senders. + * + *

This executor extends {@link AbstractAsyncCommand} which accepts any sender type, allowing + * both players and console to execute commands. When the sender is a player, it schedules + * execution on the correct world thread and creates a {@link HytalePlayerCommandContext} with + * full player data. When the sender is not a player (e.g., console), it creates a + * {@link HytaleCommandContext} and executes immediately.

+ * + *

This allows commands to have some sub-commands that require player context and others + * that work with any sender. The Fairy command framework handles context type validation + * at the method level:

+ *
    + *
  • Methods using {@link HytaleCommandContext} can be executed by any sender (player or console)
  • + *
  • Methods using {@link HytalePlayerCommandContext} can only be executed by players
  • + *
+ * + * @see HytaleCommandExecutor for commands where all methods use HytaleCommandContext + * @see HytalePlayerCommandExecutor for commands where all methods use HytalePlayerCommandContext + */ +@Getter +public class HytaleMixedCommandExecutor extends AbstractAsyncCommand { + + private static final Message MESSAGE_PLAYER_NOT_IN_WORLD = Message.raw("You must be in a world to execute this command.").color("#FF5555"); + + private final BaseCommand fairyCommand; + + public HytaleMixedCommandExecutor(BaseCommand fairyCommand) { + super(fairyCommand.getCommandNames()[0], fairyCommand.getDescription()); + this.fairyCommand = fairyCommand; + + // Add aliases + String[] commandNames = fairyCommand.getCommandNames(); + if (commandNames.length > 1) { + String[] aliases = new String[commandNames.length - 1]; + System.arraycopy(commandNames, 1, aliases, 0, aliases.length); + this.addAliases(aliases); + } + + // Allow extra arguments since Fairy handles its own argument parsing + this.setAllowsExtraArguments(true); + } + + @Override + protected CompletableFuture executeAsync(CommandContext hytaleContext) { + // Check if sender is a player + if (hytaleContext.isPlayer()) { + // Get player reference from context + Ref ref = hytaleContext.senderAsPlayerRef(); + + if (ref == null || !ref.isValid()) { + hytaleContext.sendMessage(MESSAGE_PLAYER_NOT_IN_WORLD); + return CompletableFuture.completedFuture(null); + } + + // Get Store and World (these don't require thread safety) + Store store = ref.getStore(); + EntityStore entityStore = (EntityStore) store.getExternalData(); + World world = entityStore.getWorld(); + + // Schedule execution on the correct world thread + return runAsync(hytaleContext, () -> executeForPlayer(hytaleContext, store, ref, world), world); + } else { + // Non-player sender (console, etc.) - execute immediately + executeForConsole(hytaleContext); + return CompletableFuture.completedFuture(null); + } + } + + /** + * Execute command for player on the correct world thread. + */ + private void executeForPlayer(CommandContext hytaleContext, Store store, Ref ref, World world) { + String[] args = extractArgs(hytaleContext.getInputString()); + + // Now we're on the correct thread, safe to call store.getComponent() + PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType()); + + HytalePlayerCommandContext fairyContext = new HytalePlayerCommandContext( + hytaleContext.sender(), + args, + store, + ref, + playerRef, + world + ); + + try { + this.fairyCommand.execute(fairyContext); + } catch (Throwable throwable) { + this.fairyCommand.onError(fairyContext, throwable); + hytaleContext.sendMessage(Message.raw("An error occurred while executing the command.").color("#FF5555")); + } + } + + /** + * Execute command for console/non-player sender. + */ + private void executeForConsole(CommandContext hytaleContext) { + String[] args = extractArgs(hytaleContext.getInputString()); + + HytaleCommandContext fairyContext = new HytaleCommandContext(hytaleContext.sender(), args); + + try { + this.fairyCommand.execute(fairyContext); + } catch (Throwable throwable) { + this.fairyCommand.onError(fairyContext, throwable); + hytaleContext.sendMessage(Message.raw("An error occurred while executing the command.").color("#FF5555")); + } + } + + /** + * Extract arguments from the input string. + * The input string contains the full command, so we need to skip the command name. + */ + private String[] extractArgs(String inputString) { + if (inputString == null || inputString.isEmpty()) { + return new String[0]; + } + + String[] parts = inputString.split(" "); + if (parts.length <= 1) { + return new String[0]; + } + + // Skip the command name and return the rest as arguments + String[] args = new String[parts.length - 1]; + System.arraycopy(parts, 1, args, 0, args.length); + return args; + } +} diff --git a/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/map/DefaultHytaleCommandMap.java b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/map/DefaultHytaleCommandMap.java index ca06cee4..ee536f26 100644 --- a/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/map/DefaultHytaleCommandMap.java +++ b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/map/DefaultHytaleCommandMap.java @@ -34,23 +34,29 @@ import io.fairyproject.data.MetaStorage; import io.fairyproject.hytale.FairyHytalePlatform; import io.fairyproject.hytale.command.HytaleCommandExecutor; +import io.fairyproject.hytale.command.HytaleMixedCommandExecutor; import io.fairyproject.hytale.command.HytalePlayerCommandExecutor; +import io.fairyproject.hytale.command.event.HytaleCommandContext; import io.fairyproject.hytale.command.event.HytalePlayerCommandContext; import java.lang.reflect.Method; -import java.lang.reflect.Parameter; /** * Default implementation of HytaleCommandMap that registers commands with Hytale's CommandRegistry. * - *

This implementation supports two types of command executors:

+ *

This implementation supports three types of command executors:

*
    - *
  • {@link HytaleCommandExecutor} - For general commands that can be executed by any sender
  • - *
  • {@link HytalePlayerCommandExecutor} - For player-only commands that need thread-safe access to player/world data
  • + *
  • {@link HytaleCommandExecutor} - For commands where all methods use HytaleCommandContext (any sender)
  • + *
  • {@link HytalePlayerCommandExecutor} - For commands where all methods use HytalePlayerCommandContext (player-only, thread-safe)
  • + *
  • {@link HytaleMixedCommandExecutor} - For commands with mixed context types (supports both player and console)
  • *
* - *

The executor type is automatically detected by checking if any command method uses - * {@link HytalePlayerCommandContext} as a parameter.

+ *

The executor type is automatically detected by analyzing the command method parameters:

+ *
    + *
  • If all methods use HytalePlayerCommandContext → HytalePlayerCommandExecutor
  • + *
  • If all methods use HytaleCommandContext → HytaleCommandExecutor
  • + *
  • If methods use both types → HytaleMixedCommandExecutor
  • + *
*/ @InjectableComponent public class DefaultHytaleCommandMap implements HytaleCommandMap { @@ -58,24 +64,25 @@ public class DefaultHytaleCommandMap implements HytaleCommandMap { public static final MetaKey EXECUTOR_KEY = MetaKey.create("fairy:hytale-command-executor", AbstractCommand.class); public static final MetaKey REGISTRATION_KEY = MetaKey.create("fairy:hytale-command-registration", CommandRegistration.class); - public DefaultHytaleCommandMap() { - System.out.println("DefaultHytaleCommandMap initialized"); - } - @Override public void register(BaseCommand command) { if (this.isRegistered(command)) { throw new IllegalArgumentException("Command already registered: " + command.getCommandNames()[0]); } - // Auto-detect if this is a player command by checking method parameters - boolean isPlayerCommand = hasPlayerCommandContext(command.getClass()); + // Analyze command methods to determine executor type + ContextTypeInfo contextInfo = analyzeContextTypes(command.getClass()); AbstractCommand commandExecutor; - if (isPlayerCommand) { + if (contextInfo.hasOnlyPlayerContext()) { + // All methods require player context - use player-only executor (thread-safe) commandExecutor = new HytalePlayerCommandExecutor(command); - } else { + } else if (contextInfo.hasOnlyGeneralContext()) { + // All methods use general context - use general executor (any sender) commandExecutor = new HytaleCommandExecutor(command); + } else { + // Mixed context types - use mixed executor (supports both player and console) + commandExecutor = new HytaleMixedCommandExecutor(command); } CommandRegistry commandRegistry = getCommandRegistry(); @@ -86,28 +93,57 @@ public void register(BaseCommand command) { } /** - * Check if any @Command annotated method in the class uses HytalePlayerCommandContext as a parameter. + * Analyzes the command class to determine what context types are used by @Command methods. */ - private boolean hasPlayerCommandContext(Class clazz) { + private ContextTypeInfo analyzeContextTypes(Class clazz) { + boolean hasPlayerContext = false; + boolean hasGeneralContext = false; + for (Method method : clazz.getDeclaredMethods()) { if (!method.isAnnotationPresent(Command.class)) { continue; } - for (Parameter parameter : method.getParameters()) { - if (HytalePlayerCommandContext.class.isAssignableFrom(parameter.getType())) { - return true; - } + Class[] paramTypes = method.getParameterTypes(); + if (paramTypes.length == 0) { + continue; + } + + Class firstParam = paramTypes[0]; + if (HytalePlayerCommandContext.class.isAssignableFrom(firstParam)) { + hasPlayerContext = true; + } else if (HytaleCommandContext.class.isAssignableFrom(firstParam)) { + hasGeneralContext = true; } } // Also check superclass methods Class superclass = clazz.getSuperclass(); if (superclass != null && superclass != Object.class) { - return hasPlayerCommandContext(superclass); + ContextTypeInfo superInfo = analyzeContextTypes(superclass); + hasPlayerContext = hasPlayerContext || superInfo.hasPlayerContext; + hasGeneralContext = hasGeneralContext || superInfo.hasGeneralContext; } - return false; + return new ContextTypeInfo(hasPlayerContext, hasGeneralContext); + } + + private static class ContextTypeInfo { + final boolean hasPlayerContext; + final boolean hasGeneralContext; + + ContextTypeInfo(boolean hasPlayerContext, boolean hasGeneralContext) { + this.hasPlayerContext = hasPlayerContext; + this.hasGeneralContext = hasGeneralContext; + } + + boolean hasOnlyPlayerContext() { + return hasPlayerContext && !hasGeneralContext; + } + + boolean hasOnlyGeneralContext() { + return hasGeneralContext && !hasPlayerContext; + } } @Override diff --git a/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/presence/PlayerPresenceProvider.java b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/presence/PlayerPresenceProvider.java index 15d17a45..a638ead4 100644 --- a/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/presence/PlayerPresenceProvider.java +++ b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/presence/PlayerPresenceProvider.java @@ -27,26 +27,34 @@ import com.hypixel.hytale.server.core.Message; import io.fairyproject.command.MessageType; import io.fairyproject.command.PresenceProvider; +import io.fairyproject.hytale.command.event.HytaleCommandContext; import io.fairyproject.hytale.command.event.HytalePlayerCommandContext; /** - * Presence provider for Hytale player commands using Hytale's Message API. - * This provider is specifically for {@link HytalePlayerCommandContext} which provides - * access to player-specific data like World, PlayerRef, etc. + * Presence provider for Hytale player commands. + * + *

This provider is registered for {@link HytalePlayerCommandContext} type but accepts + * the base {@link HytaleCommandContext} in sendMessage(). This is necessary because when + * console tries to execute a player-only command, the framework sends an error message + * using this provider, but the actual context is HytaleCommandContext (not player context).

*/ -public class PlayerPresenceProvider implements PresenceProvider { +public class PlayerPresenceProvider implements PresenceProvider { private static final String COLOR_INFO = "#55FFFF"; // Aqua private static final String COLOR_WARN = "#FFAA00"; // Gold private static final String COLOR_ERROR = "#FF5555"; // Red @Override - public Class type() { - return HytalePlayerCommandContext.class; + @SuppressWarnings("unchecked") + public Class type() { + // Return HytalePlayerCommandContext.class for registration purposes + // This ensures the framework registers this provider for player context methods + // The unchecked cast is safe because we accept HytaleCommandContext in sendMessage() + return (Class) (Class) HytalePlayerCommandContext.class; } @Override - public void sendMessage(HytalePlayerCommandContext commandContext, MessageType messageType, String... messages) { + public void sendMessage(HytaleCommandContext commandContext, MessageType messageType, String... messages) { String color; switch (messageType) { case WARN: diff --git a/test-hytale-plugin/src/main/java/io/example/hytale/command/TestCommand.java b/test-hytale-plugin/src/main/java/io/example/hytale/command/TestCommand.java index 65e015c0..05046784 100644 --- a/test-hytale-plugin/src/main/java/io/example/hytale/command/TestCommand.java +++ b/test-hytale-plugin/src/main/java/io/example/hytale/command/TestCommand.java @@ -37,19 +37,19 @@ import io.fairyproject.command.annotation.Command; import io.fairyproject.container.Autowired; import io.fairyproject.container.InjectableComponent; +import io.fairyproject.hytale.command.event.HytaleCommandContext; import io.fairyproject.hytale.command.event.HytalePlayerCommandContext; /** * Test command for demonstrating Fairy's command system on Hytale. * - *

This command uses {@link HytalePlayerCommandContext} which means:

+ *

This command demonstrates mixed context types:

*
    - *
  • It can only be executed by players (not console)
  • - *
  • It runs on the correct thread for the player's world
  • - *
  • It has access to player-specific data like World, PlayerRef, etc.
  • + *
  • Sub-commands using {@link HytaleCommandContext} can be executed by any sender (player or console)
  • + *
  • Sub-commands using {@link HytalePlayerCommandContext} can only be executed by players
  • *
* - *

The executor type is automatically detected based on the context parameter type.

+ *

The executor type is automatically detected based on the context parameter types used in the command methods.

*/ @Command({"test", "t", "hytaletest"}) @InjectableComponent @@ -71,7 +71,7 @@ public String getDescription() { * /test - Shows help message */ @Command("#") - public void noArgs(HytalePlayerCommandContext context) { + public void noArgs(HytaleCommandContext context) { context.sendMessage(MessageType.INFO, "=== Hytale Test Plugin Commands ==="); context.sendMessage(MessageType.INFO, "/test hello [name] - Greet someone"); context.sendMessage(MessageType.INFO, "/test info - Show plugin info"); @@ -85,7 +85,7 @@ public void noArgs(HytalePlayerCommandContext context) { * /test hello [name] - Greets the player or a specified name */ @Command("hello") - public void hello(HytalePlayerCommandContext context, @Arg(defaultValue = "World") String name) { + public void hello(HytaleCommandContext context, @Arg(defaultValue = "World") String name) { String greeting = helloComponent.sayHello(name); context.sendMessage(MessageType.INFO, greeting); } @@ -94,7 +94,7 @@ public void hello(HytalePlayerCommandContext context, @Arg(defaultValue = "World * /test info - Shows plugin information */ @Command("info") - public void info(HytalePlayerCommandContext context) { + public void info(HytaleCommandContext context) { context.sendMessage(MessageType.INFO, "Plugin: Hytale Test Plugin"); context.sendMessage(MessageType.INFO, "Framework: Fairy Project"); context.sendMessage(MessageType.INFO, "Platform: Hytale"); @@ -105,7 +105,7 @@ public void info(HytalePlayerCommandContext context) { * /test echo - Echoes a message back to the sender */ @Command("echo") - public void echo(HytalePlayerCommandContext context, @Arg String message) { + public void echo(HytaleCommandContext context, @Arg String message) { context.sendMessage(MessageType.INFO, "Echo: " + message); } @@ -113,7 +113,7 @@ public void echo(HytalePlayerCommandContext context, @Arg String message) { * /test add
- Adds two numbers together */ @Command("add") - public void add(HytalePlayerCommandContext context, @Arg int a, @Arg int b) { + public void add(HytaleCommandContext context, @Arg int a, @Arg int b) { int result = a + b; context.sendMessage(MessageType.INFO, a + " + " + b + " = " + result); } @@ -122,7 +122,7 @@ public void add(HytalePlayerCommandContext context, @Arg int a, @Arg int b) { * /test warn - Test warning message */ @Command("warn") - public void warn(HytalePlayerCommandContext context) { + public void warn(HytaleCommandContext context) { context.sendMessage(MessageType.WARN, "This is a warning message!"); } @@ -130,7 +130,7 @@ public void warn(HytalePlayerCommandContext context) { * /test error - Test error message */ @Command("error") - public void error(HytalePlayerCommandContext context) { + public void error(HytaleCommandContext context) { context.sendMessage(MessageType.ERROR, "This is an error message!"); } From 7577af842bcea44814bc723abfab1e08c7741588 Mon Sep 17 00:00:00 2001 From: LeeGodSRC Date: Wed, 21 Jan 2026 12:42:23 +0800 Subject: [PATCH 06/20] chore(modules-bom): add hytale-bom to modules BOM --- framework/modules/modules-bom/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/framework/modules/modules-bom/build.gradle.kts b/framework/modules/modules-bom/build.gradle.kts index 3ab26436..2eb74cda 100644 --- a/framework/modules/modules-bom/build.gradle.kts +++ b/framework/modules/modules-bom/build.gradle.kts @@ -7,5 +7,6 @@ description = "Fairy DevTools BOM (Bill of materials)" dependencies { api(platform("io.fairyproject:core-bom")) api(platform("io.fairyproject:bukkit-bom")) + api(platform("io.fairyproject:hytale-bom")) api(platform("io.fairyproject:mc-bom")) } \ No newline at end of file From 0e68f3a403c2a8d51e3327d10bdd133c9af40466 Mon Sep 17 00:00:00 2001 From: LeeGodSRC Date: Wed, 21 Jan 2026 12:42:27 +0800 Subject: [PATCH 07/20] feat(hytale-platform): add HytaleILogger integration --- .../hytale/FairyHytalePlatform.java | 6 ++ .../hytale/logger/HytaleILogger.java | 99 +++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/logger/HytaleILogger.java diff --git a/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/FairyHytalePlatform.java b/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/FairyHytalePlatform.java index cea17062..d423dd15 100644 --- a/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/FairyHytalePlatform.java +++ b/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/FairyHytalePlatform.java @@ -25,9 +25,12 @@ package io.fairyproject.hytale; import com.hypixel.hytale.server.core.plugin.PluginBase; +import io.fairyproject.Debug; import io.fairyproject.FairyPlatform; import io.fairyproject.PlatformType; +import io.fairyproject.hytale.logger.HytaleILogger; import io.fairyproject.hytale.plugin.HytalePluginHandler; +import io.fairyproject.log.Log; import io.fairyproject.plugin.PluginManager; import io.fairyproject.util.URLClassLoaderAccess; import io.fairyproject.util.terminable.Terminable; @@ -62,6 +65,9 @@ public FairyHytalePlatform(PluginBase plugin, Runnable shutdownCallback, File da } PluginManager.initialize(new HytalePluginHandler()); + if (!Debug.UNIT_TEST) { + Log.set(new HytaleILogger()); + } } @NotNull diff --git a/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/logger/HytaleILogger.java b/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/logger/HytaleILogger.java new file mode 100644 index 00000000..20606d54 --- /dev/null +++ b/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/logger/HytaleILogger.java @@ -0,0 +1,99 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.fairyproject.hytale.logger; + +import com.hypixel.hytale.logger.HytaleLogger; +import io.fairyproject.log.ILogger; + +import java.util.logging.Level; + +public class HytaleILogger implements ILogger { + + private final HytaleLogger logger; + + public HytaleILogger() { + this.logger = HytaleLogger.get("Fairy"); + } + + @Override + public void info(String message, Object... replace) { + logger.at(Level.INFO).log(String.format(message, replace)); + } + + @Override + public void debug(String message, Object... replace) { + logger.at(Level.FINE).log(String.format(message, replace)); + } + + @Override + public void warn(String message, Object... replace) { + logger.at(Level.WARNING).log(String.format(message, replace)); + } + + @Override + public void error(String message, Object... replace) { + logger.at(Level.SEVERE).log(String.format(message, replace)); + } + + @Override + public void info(String message, Throwable throwable, Object... replace) { + logger.at(Level.INFO).withCause(throwable).log(String.format(message, replace)); + } + + @Override + public void debug(String message, Throwable throwable, Object... replace) { + logger.at(Level.FINE).withCause(throwable).log(String.format(message, replace)); + } + + @Override + public void warn(String message, Throwable throwable, Object... replace) { + logger.at(Level.WARNING).withCause(throwable).log(String.format(message, replace)); + } + + @Override + public void error(String message, Throwable throwable, Object... replace) { + logger.at(Level.SEVERE).withCause(throwable).log(String.format(message, replace)); + } + + @Override + public void info(Throwable throwable) { + logger.at(Level.INFO).withCause(throwable).log(""); + } + + @Override + public void debug(Throwable throwable) { + logger.at(Level.FINE).withCause(throwable).log(""); + } + + @Override + public void warn(Throwable throwable) { + logger.at(Level.WARNING).withCause(throwable).log(""); + } + + @Override + public void error(Throwable throwable) { + logger.at(Level.SEVERE).withCause(throwable).log(""); + } +} From dc59e5daf491216af6bc393143007d88589651a1 Mon Sep 17 00:00:00 2001 From: LeeGodSRC Date: Wed, 21 Jan 2026 12:42:31 +0800 Subject: [PATCH 08/20] fix(gradle-plugin): add FairyExtension inputs to GenerateHytaleManifestTask for incremental builds --- .../runner/hytale/RunHytaleServerPlugin.kt | 9 ++++++ .../hytale/task/GenerateHytaleManifestTask.kt | 30 +++++++++++++------ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerPlugin.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerPlugin.kt index 4e37a349..1e0b77fd 100644 --- a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerPlugin.kt +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerPlugin.kt @@ -25,6 +25,7 @@ package io.fairyproject.gradle.runner.hytale import io.fairyproject.gradle.FairyGradlePlugin +import io.fairyproject.gradle.extension.FairyExtension import io.fairyproject.gradle.runner.ClasspathRegistry import io.fairyproject.gradle.runner.hytale.action.CopyHytaleSnapshotAction import io.fairyproject.gradle.runner.hytale.task.GenerateHytaleManifestTask @@ -174,6 +175,7 @@ open class RunHytaleServerPlugin : Plugin { private fun configureGenerateHytaleManifest() { val javaExtension = project.extensions.getByType(JavaPluginExtension::class.java) val mainSourceSet = javaExtension.sourceSets.getByName("main") + val fairyExtension = project.extensions.findByType(FairyExtension::class.java) project.tasks.register("generateHytaleManifest", GenerateHytaleManifestTask::class.java) { it.group = group @@ -192,6 +194,13 @@ open class RunHytaleServerPlugin : Plugin { it.projectVersion.set(project.version.toString()) it.projectDescription.set(project.description ?: "") + // FairyExtension inputs - track changes to these properties + fairyExtension?.let { ext -> + it.fairyName.set(ext.name) + it.fairyMainPackage.set(ext.mainPackage) + it.fairyFairyPackage.set(ext.fairyPackage) + } + it.dependsOn("classes") } } diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/GenerateHytaleManifestTask.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/GenerateHytaleManifestTask.kt index 3ebed16d..31443818 100644 --- a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/GenerateHytaleManifestTask.kt +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/GenerateHytaleManifestTask.kt @@ -70,6 +70,19 @@ abstract class GenerateHytaleManifestTask : DefaultTask() { @get:Input abstract val projectDescription: Property + // FairyExtension inputs - these affect the generated manifest + @get:Input + @get:org.gradle.api.tasks.Optional + abstract val fairyName: Property + + @get:Input + @get:org.gradle.api.tasks.Optional + abstract val fairyMainPackage: Property + + @get:Input + @get:org.gradle.api.tasks.Optional + abstract val fairyFairyPackage: Property + private val gson = GsonBuilder().setPrettyPrinting().create() @TaskAction @@ -93,13 +106,13 @@ abstract class GenerateHytaleManifestTask : DefaultTask() { // Generate manifest.json val hytaleProps = extension?.hytalePropertiesRaw() ?: emptyMap() - val manifest = buildManifest(hytalePluginClass, hytaleProps, extension) + val manifest = buildManifest(hytalePluginClass, hytaleProps) val manifestFile = File(outputDirectory, "manifest.json") manifestFile.writeText(gson.toJson(manifest)) logger.lifecycle("[Fairy] Generated Hytale manifest.json at ${manifestFile.absolutePath}") // Generate fairy.json - val fairyJson = buildFairyJson(mainClass, extension) + val fairyJson = buildFairyJson(mainClass) val fairyFile = File(outputDirectory, "fairy.json") fairyFile.writeText(gson.toJson(fairyJson)) logger.lifecycle("[Fairy] Generated fairy.json at ${fairyFile.absolutePath}") @@ -182,12 +195,12 @@ abstract class GenerateHytaleManifestTask : DefaultTask() { return classNode.name.replace("/", ".") } - private fun buildFairyJson(mainClass: String, extension: FairyExtension?): Map { + private fun buildFairyJson(mainClass: String): Map { val json = mutableMapOf() - json["name"] = extension?.name?.orNull ?: projectName.get() + json["name"] = fairyName.orNull ?: projectName.get() json["mainClass"] = mainClass - extension?.mainPackage?.orNull?.let { json["shadedPackage"] = it } - extension?.fairyPackage?.orNull?.let { json["fairyPackage"] = it } + fairyMainPackage.orNull?.let { json["shadedPackage"] = it } + fairyFairyPackage.orNull?.let { json["fairyPackage"] = it } return json } @@ -265,14 +278,13 @@ abstract class GenerateHytaleManifestTask : DefaultTask() { private fun buildManifest( hytalePluginClass: String, - hytaleProps: Map, - extension: FairyExtension? + hytaleProps: Map ): Map { val manifest = mutableMapOf() manifest["Main"] = hytalePluginClass manifest["Group"] = (hytaleProps["Group"] as? String)?.takeIf { it.isNotEmpty() } ?: "io.fairyproject" - manifest["Name"] = extension?.name?.orNull ?: projectName.get() + manifest["Name"] = fairyName.orNull ?: projectName.get() manifest["Version"] = normalizeToSemver(projectVersion.get()) manifest["Description"] = projectDescription.get() From e2fa7c57bd011a322bb69d53de00352ca0d76afb Mon Sep 17 00:00:00 2001 From: LeeGodSRC Date: Wed, 21 Jan 2026 12:42:34 +0800 Subject: [PATCH 09/20] chore: bump version to 0.8.5b1-hytale3-SNAPSHOT --- global.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/global.properties b/global.properties index a88e3bff..2a0c01c1 100644 --- a/global.properties +++ b/global.properties @@ -1 +1 @@ -version = 0.8.4b1-SNAPSHOT \ No newline at end of file +version = 0.8.5b1-hytale3-SNAPSHOT \ No newline at end of file From a5cef6563d0dae1db564d72f6d8c2f8cb33c4a5c Mon Sep 17 00:00:00 2001 From: LeeGodSRC Date: Wed, 21 Jan 2026 13:22:15 +0800 Subject: [PATCH 10/20] fix: address Codacy code quality issues in Hytale modules - FairyHytalePlatform: use synchronized setter for static PLUGIN field - GenerateHytaleManifestTask: catch IOException instead of generic Exception - GenerateHytaleManifestTask: extract CLASS_FILE_EXTENSION constant - GenerateHytaleManifestTask: reduce function nesting by extracting methods - PrepareHytaleBuildTask: use for loop instead of forEach with labeled return - PrepareHytaleDownloaderTask: extract OS_NAME_PROPERTY and OS_ARCH_PROPERTY constants --- .../hytale/FairyHytalePlatform.java | 14 ++-- .../hytale/task/GenerateHytaleManifestTask.kt | 81 ++++++++++--------- .../hytale/task/PrepareHytaleBuildTask.kt | 6 +- .../task/PrepareHytaleDownloaderTask.kt | 41 +++++----- 4 files changed, 79 insertions(+), 63 deletions(-) diff --git a/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/FairyHytalePlatform.java b/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/FairyHytalePlatform.java index d423dd15..78f0fe66 100644 --- a/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/FairyHytalePlatform.java +++ b/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/FairyHytalePlatform.java @@ -44,16 +44,16 @@ public class FairyHytalePlatform extends FairyPlatform implements TerminableConsumer { public static PluginBase PLUGIN; - private static Runnable shutdownCallback; private final URLClassLoaderAccess classLoader; private final File dataFolder; private final CompositeTerminable compositeTerminable; + private final Runnable shutdownCallback; public FairyHytalePlatform(PluginBase plugin, Runnable shutdownCallback, File dataFolder) { FairyPlatform.INSTANCE = this; - PLUGIN = plugin; - FairyHytalePlatform.shutdownCallback = shutdownCallback; + this.shutdownCallback = shutdownCallback; + setPlugin(plugin); this.dataFolder = dataFolder; this.compositeTerminable = CompositeTerminable.create(); @@ -94,11 +94,15 @@ public File getDataFolder() { @Override public void shutdown() { - if (shutdownCallback != null) { - shutdownCallback.run(); + if (this.shutdownCallback != null) { + this.shutdownCallback.run(); } } + private static synchronized void setPlugin(PluginBase plugin) { + PLUGIN = plugin; + } + @Override public boolean isRunning() { return true; diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/GenerateHytaleManifestTask.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/GenerateHytaleManifestTask.kt index 31443818..f0c494bf 100644 --- a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/GenerateHytaleManifestTask.kt +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/GenerateHytaleManifestTask.kt @@ -41,6 +41,7 @@ import org.objectweb.asm.ClassReader import org.objectweb.asm.Opcodes import org.objectweb.asm.tree.ClassNode import java.io.File +import java.io.IOException import java.util.jar.JarFile /** @@ -52,6 +53,10 @@ import java.util.jar.JarFile */ abstract class GenerateHytaleManifestTask : DefaultTask() { + companion object { + private const val CLASS_FILE_EXTENSION = ".class" + } + @get:InputDirectory abstract val classesDir: DirectoryProperty @@ -126,7 +131,7 @@ abstract class GenerateHytaleManifestTask : DefaultTask() { val pluginInterfaceClass = findPluginInterfaceClass() classesDirectory.walkTopDown() - .filter { it.isFile && it.extension == "class" } + .filter { it.isFile && it.name.endsWith(CLASS_FILE_EXTENSION) } .forEach { classFile -> val className = findMainClassInFile(classFile, pluginInterfaceClass) if (className != null) return className @@ -137,34 +142,38 @@ abstract class GenerateHytaleManifestTask : DefaultTask() { private fun findPluginInterfaceClass(): String? { // Look for Plugin or Application class with @FairyInternalIdentityMeta - runtimeClasspath.files - .filter { it.isFile && it.extension == "jar" } - .forEach { jarFile -> - try { - JarFile(jarFile).use { jar -> - for (entry in jar.entries()) { - if (!entry.name.endsWith(".class")) continue - val simpleName = entry.name.substringAfterLast("/").removeSuffix(".class") - if (simpleName != "Plugin" && simpleName != "Application") continue - - val bytes = jar.getInputStream(entry).readBytes() - val classReader = ClassReader(bytes) - val classNode = ClassNode() - classReader.accept(classNode, ClassReader.SKIP_CODE or ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES) - - val hasInternalMeta = classNode.visibleAnnotations?.any { - it.desc.contains(ClassConstants.INTERNAL_META) - } ?: false - - if (hasInternalMeta) { - return classNode.name - } - } + for (jarFile in runtimeClasspath.files.filter { it.isFile && it.extension == "jar" }) { + val result = findPluginInterfaceInJar(jarFile) + if (result != null) return result + } + return null + } + + private fun findPluginInterfaceInJar(jarFile: File): String? { + try { + JarFile(jarFile).use { jar -> + for (entry in jar.entries()) { + if (!entry.name.endsWith(CLASS_FILE_EXTENSION)) continue + val simpleName = entry.name.substringAfterLast("/").removeSuffix(CLASS_FILE_EXTENSION) + if (simpleName != "Plugin" && simpleName != "Application") continue + + val bytes = jar.getInputStream(entry).readBytes() + val classReader = ClassReader(bytes) + val classNode = ClassNode() + classReader.accept(classNode, ClassReader.SKIP_CODE or ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES) + + val hasInternalMeta = classNode.visibleAnnotations?.any { + it.desc.contains(ClassConstants.INTERNAL_META) + } ?: false + + if (hasInternalMeta) { + return classNode.name } - } catch (e: Exception) { - // Ignore } } + } catch (e: IOException) { + logger.debug("Could not read JAR file: ${jarFile.name}", e) + } return null } @@ -209,7 +218,7 @@ abstract class GenerateHytaleManifestTask : DefaultTask() { val classesDirectory = classesDir.get().asFile if (classesDirectory.exists()) { classesDirectory.walkTopDown() - .filter { it.isFile && it.extension == "class" } + .filter { it.isFile && it.name.endsWith(CLASS_FILE_EXTENSION) } .forEach { classFile -> val className = findHytalePluginInClassFile(classFile) if (className != null) return className @@ -217,12 +226,10 @@ abstract class GenerateHytaleManifestTask : DefaultTask() { } // Then, scan runtime classpath JARs (for HytalePlugin from hytale-bootstrap) - runtimeClasspath.files - .filter { it.isFile && it.extension == "jar" } - .forEach { jarFile -> - val className = findHytalePluginInJar(jarFile) - if (className != null) return className - } + for (jarFile in runtimeClasspath.files.filter { it.isFile && it.extension == "jar" }) { + val className = findHytalePluginInJar(jarFile) + if (className != null) return className + } return null } @@ -231,16 +238,16 @@ abstract class GenerateHytaleManifestTask : DefaultTask() { try { JarFile(jarFile).use { jar -> for (entry in jar.entries()) { - if (!entry.name.endsWith(".class")) continue - if (!entry.name.endsWith("HytalePlugin.class")) continue + if (!entry.name.endsWith(CLASS_FILE_EXTENSION)) continue + if (!entry.name.endsWith("HytalePlugin${CLASS_FILE_EXTENSION}")) continue val bytes = jar.getInputStream(entry).readBytes() val className = findHytalePluginInBytes(bytes) if (className != null) return className } } - } catch (e: Exception) { - // Ignore JAR read errors + } catch (e: IOException) { + logger.debug("Could not read JAR file: ${jarFile.name}", e) } return null } diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/PrepareHytaleBuildTask.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/PrepareHytaleBuildTask.kt index 2a860e38..77ecca80 100644 --- a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/PrepareHytaleBuildTask.kt +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/PrepareHytaleBuildTask.kt @@ -157,15 +157,15 @@ open class PrepareHytaleBuildTask @Inject constructor( } else null ZipFile(zipFile).use { zip -> - zip.entries().asSequence().forEach { entry -> + for (entry in zip.entries().asSequence()) { // Skip Client directory - only extract Server and Assets if (entry.name.startsWith("Client/")) { - return@forEach + continue } // Skip mods directory to preserve plugins if (entry.name.startsWith("mods/") || entry.name == "mods") { - return@forEach + continue } val targetPath = workDirectory.resolve(entry.name) diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/PrepareHytaleDownloaderTask.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/PrepareHytaleDownloaderTask.kt index 11d6c790..d2875459 100644 --- a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/PrepareHytaleDownloaderTask.kt +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/PrepareHytaleDownloaderTask.kt @@ -47,6 +47,26 @@ open class PrepareHytaleDownloaderTask @Inject constructor( private val extension: RunHytaleServerExtension ) : DefaultTask() { + companion object { + private const val OS_NAME_PROPERTY = "os.name" + private const val OS_ARCH_PROPERTY = "os.arch" + + /** + * Gets the path to the Hytale downloader executable. + */ + fun getDownloaderPath(downloaderDirectory: Path, extension: RunHytaleServerExtension): Path { + if (extension.downloaderPath.isPresent) { + return Path.of(extension.downloaderPath.get()) + } + val binaryName = if (System.getProperty(OS_NAME_PROPERTY).lowercase().contains("win")) { + "hytale-downloader.exe" + } else { + "hytale-downloader" + } + return downloaderDirectory.resolve(binaryName) + } + } + private val downloaderPath: Path get() = downloaderDirectory.resolve(getDownloaderBinaryName()) @@ -122,7 +142,7 @@ open class PrepareHytaleDownloaderTask @Inject constructor( } private fun getOsName(): String { - val osName = System.getProperty("os.name").lowercase() + val osName = System.getProperty(OS_NAME_PROPERTY).lowercase() return when { osName.contains("win") -> "windows" osName.contains("mac") || osName.contains("darwin") -> "macos" @@ -132,7 +152,7 @@ open class PrepareHytaleDownloaderTask @Inject constructor( } private fun getArchName(): String { - val arch = System.getProperty("os.arch").lowercase() + val arch = System.getProperty(OS_ARCH_PROPERTY).lowercase() return when { arch.contains("amd64") || arch.contains("x86_64") -> "amd64" arch.contains("aarch64") || arch.contains("arm64") -> "arm64" @@ -141,21 +161,6 @@ open class PrepareHytaleDownloaderTask @Inject constructor( } private fun isWindows(): Boolean { - return System.getProperty("os.name").lowercase().contains("win") + return System.getProperty(OS_NAME_PROPERTY).lowercase().contains("win") } - - companion object { - fun getDownloaderPath(downloaderDirectory: Path, extension: RunHytaleServerExtension): Path { - if (extension.downloaderPath.isPresent) { - return Path.of(extension.downloaderPath.get()) - } - val binaryName = if (System.getProperty("os.name").lowercase().contains("win")) { - "hytale-downloader.exe" - } else { - "hytale-downloader" - } - return downloaderDirectory.resolve(binaryName) - } - } - } From 561a6abcf4e67e57a653f455816131ff6ab4d068 Mon Sep 17 00:00:00 2001 From: LeeGodSRC Date: Wed, 21 Jan 2026 13:32:21 +0800 Subject: [PATCH 11/20] fix: address additional Codacy code quality issues - GenerateHytaleManifestTask: add KDoc to generate() and companion object - GenerateHytaleManifestTask: reduce nesting depth using sequence operations - GenerateHytaleManifestTask: merge findHytalePluginInClassFile into caller - PrepareHytaleDownloaderTask: add KDoc to prepareDownloader() and companion - PrepareHytaleBuildTask: add KDoc to prepareBuild() - PrepareHytaleBuildTask: extract helper methods to reduce nesting depth - FairyResourceHytaleMeta: rename normalizeToSemver to convertToValidSemver --- .../resource/impl/FairyResourceHytaleMeta.kt | 11 +-- .../hytale/task/GenerateHytaleManifestTask.kt | 98 ++++++++++--------- .../hytale/task/PrepareHytaleBuildTask.kt | 73 +++++++------- .../task/PrepareHytaleDownloaderTask.kt | 4 + 4 files changed, 96 insertions(+), 90 deletions(-) diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/resource/impl/FairyResourceHytaleMeta.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/resource/impl/FairyResourceHytaleMeta.kt index 5cec9183..aadb6c92 100644 --- a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/resource/impl/FairyResourceHytaleMeta.kt +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/resource/impl/FairyResourceHytaleMeta.kt @@ -34,7 +34,7 @@ class FairyResourceHytaleMeta : FairyResource { manifest["Name"] = context.pluginName // Set Version from context - must be valid semver format - manifest["Version"] = normalizeToSemver(context.projectVersion) + manifest["Version"] = convertToValidSemver(context.projectVersion) // Set Description from context manifest["Description"] = context.projectDescription @@ -84,23 +84,16 @@ class FairyResourceHytaleMeta : FairyResource { return resourceOf("manifest.json", json.encodeToByteArray()) } - /** - * Normalize version string to valid semver format. - * Hytale requires versions in semver format (e.g., "1.0.0"). - */ - private fun normalizeToSemver(version: String): String { - // If version is unspecified or empty, return default + private fun convertToValidSemver(version: String): String { if (version.isBlank() || version == "unspecified") { return "0.0.1" } - // Simple semver regex pattern: major.minor.patch with optional pre-release/build val semverRegex = Regex("""^\d+\.\d+\.\d+(-[\w.]+)?(\+[\w.]+)?$""") if (semverRegex.matches(version)) { return version } - // Try to extract numbers from version string val numbers = Regex("""\d+""").findAll(version).map { it.value }.toList() return when { numbers.size >= 3 -> "${numbers[0]}.${numbers[1]}.${numbers[2]}" diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/GenerateHytaleManifestTask.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/GenerateHytaleManifestTask.kt index f0c494bf..54cb81aa 100644 --- a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/GenerateHytaleManifestTask.kt +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/GenerateHytaleManifestTask.kt @@ -53,6 +53,7 @@ import java.util.jar.JarFile */ abstract class GenerateHytaleManifestTask : DefaultTask() { + /** Constants used for class file scanning. */ companion object { private const val CLASS_FILE_EXTENSION = ".class" } @@ -90,6 +91,9 @@ abstract class GenerateHytaleManifestTask : DefaultTask() { private val gson = GsonBuilder().setPrettyPrinting().create() + /** + * Generates the Hytale manifest.json and fairy.json files. + */ @TaskAction fun generate() { val extension = project.extensions.findByType(FairyExtension::class.java) @@ -150,31 +154,35 @@ abstract class GenerateHytaleManifestTask : DefaultTask() { } private fun findPluginInterfaceInJar(jarFile: File): String? { - try { - JarFile(jarFile).use { jar -> - for (entry in jar.entries()) { - if (!entry.name.endsWith(CLASS_FILE_EXTENSION)) continue - val simpleName = entry.name.substringAfterLast("/").removeSuffix(CLASS_FILE_EXTENSION) - if (simpleName != "Plugin" && simpleName != "Application") continue - - val bytes = jar.getInputStream(entry).readBytes() - val classReader = ClassReader(bytes) - val classNode = ClassNode() - classReader.accept(classNode, ClassReader.SKIP_CODE or ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES) - - val hasInternalMeta = classNode.visibleAnnotations?.any { - it.desc.contains(ClassConstants.INTERNAL_META) - } ?: false - - if (hasInternalMeta) { - return classNode.name - } - } - } + val jar = try { + JarFile(jarFile) } catch (e: IOException) { logger.debug("Could not read JAR file: ${jarFile.name}", e) + return null } - return null + + return jar.use { j -> + j.entries().asSequence() + .filter { it.name.endsWith(CLASS_FILE_EXTENSION) } + .filter { + val simpleName = it.name.substringAfterLast("/").removeSuffix(CLASS_FILE_EXTENSION) + simpleName == "Plugin" || simpleName == "Application" + } + .mapNotNull { entry -> checkForInternalMeta(j, entry) } + .firstOrNull() + } + } + + private fun checkForInternalMeta(jar: JarFile, entry: java.util.zip.ZipEntry): String? { + val bytes = jar.getInputStream(entry).readBytes() + val classNode = ClassNode() + ClassReader(bytes).accept(classNode, ClassReader.SKIP_CODE or ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES) + + val hasInternalMeta = classNode.visibleAnnotations?.any { + it.desc.contains(ClassConstants.INTERNAL_META) + } ?: false + + return if (hasInternalMeta) classNode.name else null } private fun findMainClassInFile(classFile: File, pluginInterfaceClass: String?): String? { @@ -217,43 +225,37 @@ abstract class GenerateHytaleManifestTask : DefaultTask() { // First, scan project's compiled classes val classesDirectory = classesDir.get().asFile if (classesDirectory.exists()) { - classesDirectory.walkTopDown() + val result = classesDirectory.walkTopDown() .filter { it.isFile && it.name.endsWith(CLASS_FILE_EXTENSION) } - .forEach { classFile -> - val className = findHytalePluginInClassFile(classFile) - if (className != null) return className - } + .mapNotNull { findHytalePluginInBytes(it.readBytes()) } + .firstOrNull() + if (result != null) return result } // Then, scan runtime classpath JARs (for HytalePlugin from hytale-bootstrap) - for (jarFile in runtimeClasspath.files.filter { it.isFile && it.extension == "jar" }) { - val className = findHytalePluginInJar(jarFile) - if (className != null) return className - } - - return null + return runtimeClasspath.files + .filter { it.isFile && it.extension == "jar" } + .mapNotNull { findHytalePluginInJar(it) } + .firstOrNull() } private fun findHytalePluginInJar(jarFile: File): String? { - try { - JarFile(jarFile).use { jar -> - for (entry in jar.entries()) { - if (!entry.name.endsWith(CLASS_FILE_EXTENSION)) continue - if (!entry.name.endsWith("HytalePlugin${CLASS_FILE_EXTENSION}")) continue - - val bytes = jar.getInputStream(entry).readBytes() - val className = findHytalePluginInBytes(bytes) - if (className != null) return className - } - } + val jar = try { + JarFile(jarFile) } catch (e: IOException) { logger.debug("Could not read JAR file: ${jarFile.name}", e) + return null } - return null - } - private fun findHytalePluginInClassFile(classFile: File): String? { - return findHytalePluginInBytes(classFile.readBytes()) + return jar.use { j -> + j.entries().asSequence() + .filter { it.name.endsWith("HytalePlugin${CLASS_FILE_EXTENSION}") } + .mapNotNull { entry -> + val bytes = j.getInputStream(entry).readBytes() + findHytalePluginInBytes(bytes) + } + .firstOrNull() + } } private fun findHytalePluginInBytes(bytes: ByteArray): String? { diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/PrepareHytaleBuildTask.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/PrepareHytaleBuildTask.kt index 77ecca80..9618ec90 100644 --- a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/PrepareHytaleBuildTask.kt +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/PrepareHytaleBuildTask.kt @@ -52,6 +52,9 @@ open class PrepareHytaleBuildTask @Inject constructor( private val extension: RunHytaleServerExtension ) : DefaultTask() { + /** + * Downloads and extracts Hytale server files using the Hytale Downloader CLI. + */ @TaskAction fun prepareBuild() { // If artifact already exists, skip (always downloads latest) @@ -150,45 +153,49 @@ open class PrepareHytaleBuildTask @Inject constructor( // Preserve mods directory if it exists val modsDir = workDirectory.resolve("mods") - val modsBackup = if (modsDir.exists()) { - val backup = Files.createTempDirectory("fairy-hytale-mods-backup") - modsDir.toFile().copyRecursively(backup.toFile(), overwrite = true) - backup - } else null + val modsBackup = backupModsDirectory(modsDir) ZipFile(zipFile).use { zip -> - for (entry in zip.entries().asSequence()) { - // Skip Client directory - only extract Server and Assets - if (entry.name.startsWith("Client/")) { - continue - } - - // Skip mods directory to preserve plugins - if (entry.name.startsWith("mods/") || entry.name == "mods") { - continue - } - - val targetPath = workDirectory.resolve(entry.name) - - if (entry.isDirectory) { - targetPath.createDirectories() - } else { - targetPath.parent?.createDirectories() - zip.getInputStream(entry).use { input -> - Files.copy(input, targetPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING) - } - } - } + zip.entries().asSequence() + .filter { shouldExtractEntry(it.name) } + .forEach { entry -> extractZipEntry(zip, entry) } } - // Restore mods directory - if (modsBackup != null) { - modsDir.createDirectories() - modsBackup.toFile().copyRecursively(modsDir.toFile(), overwrite = true) - modsBackup.toFile().deleteRecursively() - } + restoreModsDirectory(modsBackup, modsDir) println("Server files extracted to: $workDirectory") } + private fun backupModsDirectory(modsDir: Path): Path? { + if (!modsDir.exists()) return null + val backup = Files.createTempDirectory("fairy-hytale-mods-backup") + modsDir.toFile().copyRecursively(backup.toFile(), overwrite = true) + return backup + } + + private fun shouldExtractEntry(entryName: String): Boolean { + if (entryName.startsWith("Client/")) return false + if (entryName.startsWith("mods/") || entryName == "mods") return false + return true + } + + private fun extractZipEntry(zip: ZipFile, entry: java.util.zip.ZipEntry) { + val targetPath = workDirectory.resolve(entry.name) + if (entry.isDirectory) { + targetPath.createDirectories() + } else { + targetPath.parent?.createDirectories() + zip.getInputStream(entry).use { input -> + Files.copy(input, targetPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING) + } + } + } + + private fun restoreModsDirectory(modsBackup: Path?, modsDir: Path) { + if (modsBackup == null) return + modsDir.createDirectories() + modsBackup.toFile().copyRecursively(modsDir.toFile(), overwrite = true) + modsBackup.toFile().deleteRecursively() + } + } diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/PrepareHytaleDownloaderTask.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/PrepareHytaleDownloaderTask.kt index d2875459..0c798ca6 100644 --- a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/PrepareHytaleDownloaderTask.kt +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/PrepareHytaleDownloaderTask.kt @@ -47,6 +47,7 @@ open class PrepareHytaleDownloaderTask @Inject constructor( private val extension: RunHytaleServerExtension ) : DefaultTask() { + /** OS property constants and utility functions. */ companion object { private const val OS_NAME_PROPERTY = "os.name" private const val OS_ARCH_PROPERTY = "os.arch" @@ -77,6 +78,9 @@ open class PrepareHytaleDownloaderTask @Inject constructor( } } + /** + * Downloads and prepares the Hytale Downloader CLI if not already present. + */ @TaskAction fun prepareDownloader() { if (extension.downloaderPath.isPresent) { From babb5486da1854e248eced87d44423d619d836df Mon Sep 17 00:00:00 2001 From: LeeGodSRC Date: Wed, 21 Jan 2026 13:37:55 +0800 Subject: [PATCH 12/20] fix: extract PLUGIN_NAME constant and reduce function count - RunHytaleServerPlugin: extract 'runHytaleServer' to PLUGIN_NAME constant - GenerateHytaleManifestTask: inline checkForInternalMeta to reduce function count to 11 --- .../runner/hytale/RunHytaleServerPlugin.kt | 21 ++++++++++-------- .../hytale/task/GenerateHytaleManifestTask.kt | 22 ++++++++----------- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerPlugin.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerPlugin.kt index 1e0b77fd..481fa9ff 100644 --- a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerPlugin.kt +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerPlugin.kt @@ -50,13 +50,16 @@ import java.nio.file.Path */ open class RunHytaleServerPlugin : Plugin { - private val group = "runHytaleServer" + companion object { + private const val PLUGIN_NAME = "runHytaleServer" + } + private lateinit var project: Project private lateinit var extension: RunHytaleServerExtension override fun apply(project: Project) { this.project = project - extension = project.extensions.create("runHytaleServer", RunHytaleServerExtension::class.java) + extension = project.extensions.create(PLUGIN_NAME, RunHytaleServerExtension::class.java) project.afterEvaluate { configureProject(extension, project) @@ -105,7 +108,7 @@ open class RunHytaleServerPlugin : Plugin { downloaderDir, extension ).configure { - it.group = group + it.group = PLUGIN_NAME it.description = "Downloads the Hytale Downloader CLI" } } @@ -125,7 +128,7 @@ open class RunHytaleServerPlugin : Plugin { artifact, extension ).configure { - it.group = group + it.group = PLUGIN_NAME it.description = "Downloads and extracts Hytale server files" it.dependsOn("prepareHytaleDownloader") } @@ -142,7 +145,7 @@ open class RunHytaleServerPlugin : Plugin { it.into(workDir.resolve("mods")) it.duplicatesStrategy = DuplicatesStrategy.INCLUDE - it.group = group + it.group = PLUGIN_NAME it.description = "Copies mod JARs to the Hytale server mods directory" } } @@ -167,7 +170,7 @@ open class RunHytaleServerPlugin : Plugin { // Don't delete mods directory on clean file.name != "mods" && file.name != ".fairy-hytale-version" } ?: emptyList()) - it.group = group + it.group = PLUGIN_NAME it.description = "Cleans the Hytale server work directory" } } @@ -178,7 +181,7 @@ open class RunHytaleServerPlugin : Plugin { val fairyExtension = project.extensions.findByType(FairyExtension::class.java) project.tasks.register("generateHytaleManifest", GenerateHytaleManifestTask::class.java) { - it.group = group + it.group = PLUGIN_NAME it.description = "Generates Hytale manifest.json for development" // Input: compiled classes directory @@ -212,7 +215,7 @@ open class RunHytaleServerPlugin : Plugin { ) { project.afterEvaluate { project.tasks.register( - "runHytaleServer", + PLUGIN_NAME, RunHytaleServerTask::class.java, extension.javaVersion.get(), artifact, @@ -226,7 +229,7 @@ open class RunHytaleServerPlugin : Plugin { it.dependsOn("prepareHytaleBuild") it.dependsOn("generateHytaleManifest") - it.group = group + it.group = PLUGIN_NAME it.description = "Runs the Hytale server with mods" it.jvmArgs = extension.args.get() diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/GenerateHytaleManifestTask.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/GenerateHytaleManifestTask.kt index 54cb81aa..33a76d23 100644 --- a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/GenerateHytaleManifestTask.kt +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/GenerateHytaleManifestTask.kt @@ -168,23 +168,19 @@ abstract class GenerateHytaleManifestTask : DefaultTask() { val simpleName = it.name.substringAfterLast("/").removeSuffix(CLASS_FILE_EXTENSION) simpleName == "Plugin" || simpleName == "Application" } - .mapNotNull { entry -> checkForInternalMeta(j, entry) } + .mapNotNull { entry -> + val bytes = j.getInputStream(entry).readBytes() + val classNode = ClassNode() + ClassReader(bytes).accept(classNode, ClassReader.SKIP_CODE or ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES) + val hasInternalMeta = classNode.visibleAnnotations?.any { + it.desc.contains(ClassConstants.INTERNAL_META) + } ?: false + if (hasInternalMeta) classNode.name else null + } .firstOrNull() } } - private fun checkForInternalMeta(jar: JarFile, entry: java.util.zip.ZipEntry): String? { - val bytes = jar.getInputStream(entry).readBytes() - val classNode = ClassNode() - ClassReader(bytes).accept(classNode, ClassReader.SKIP_CODE or ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES) - - val hasInternalMeta = classNode.visibleAnnotations?.any { - it.desc.contains(ClassConstants.INTERNAL_META) - } ?: false - - return if (hasInternalMeta) classNode.name else null - } - private fun findMainClassInFile(classFile: File, pluginInterfaceClass: String?): String? { val bytes = classFile.readBytes() val classReader = ClassReader(bytes) From 2d9bd91bb1e28138cb842cbcf7d49f986ea0cc9a Mon Sep 17 00:00:00 2001 From: LeeGodSRC Date: Wed, 21 Jan 2026 13:42:46 +0800 Subject: [PATCH 13/20] fix: add KDoc to companion object and reduce function count to 10 - RunHytaleServerPlugin: add KDoc to companion object - GenerateHytaleManifestTask: inline findPluginInterfaceClass into findMainClass --- .../runner/hytale/RunHytaleServerPlugin.kt | 1 + .../hytale/task/GenerateHytaleManifestTask.kt | 26 ++++++------------- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerPlugin.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerPlugin.kt index 481fa9ff..ea28f7da 100644 --- a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerPlugin.kt +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerPlugin.kt @@ -50,6 +50,7 @@ import java.nio.file.Path */ open class RunHytaleServerPlugin : Plugin { + /** Plugin constants. */ companion object { private const val PLUGIN_NAME = "runHytaleServer" } diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/GenerateHytaleManifestTask.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/GenerateHytaleManifestTask.kt index 33a76d23..e1b4d210 100644 --- a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/GenerateHytaleManifestTask.kt +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/GenerateHytaleManifestTask.kt @@ -131,26 +131,16 @@ abstract class GenerateHytaleManifestTask : DefaultTask() { val classesDirectory = classesDir.get().asFile if (!classesDirectory.exists()) return null - // First, find the Plugin/Application interface class from runtime classpath - val pluginInterfaceClass = findPluginInterfaceClass() + // Find the Plugin/Application interface class from runtime classpath + val pluginInterfaceClass = runtimeClasspath.files + .filter { it.isFile && it.extension == "jar" } + .mapNotNull { findPluginInterfaceInJar(it) } + .firstOrNull() - classesDirectory.walkTopDown() + return classesDirectory.walkTopDown() .filter { it.isFile && it.name.endsWith(CLASS_FILE_EXTENSION) } - .forEach { classFile -> - val className = findMainClassInFile(classFile, pluginInterfaceClass) - if (className != null) return className - } - - return null - } - - private fun findPluginInterfaceClass(): String? { - // Look for Plugin or Application class with @FairyInternalIdentityMeta - for (jarFile in runtimeClasspath.files.filter { it.isFile && it.extension == "jar" }) { - val result = findPluginInterfaceInJar(jarFile) - if (result != null) return result - } - return null + .mapNotNull { findMainClassInFile(it, pluginInterfaceClass) } + .firstOrNull() } private fun findPluginInterfaceInJar(jarFile: File): String? { From a11d3b9f0da20350a698fe36d1a75c39eba5e95f Mon Sep 17 00:00:00 2001 From: LeeGodSRC Date: Mon, 26 Jan 2026 14:44:01 +0800 Subject: [PATCH 14/20] feat(hytale-platform): add RegisterAsEntitySystem and RegisterAsChunkSystem annotations for automatic system registration --- .../hytale/FairyHytalePlatform.java | 32 +++++++++++++++ .../hytale/entity/RegisterAsChunkSystem.java | 39 +++++++++++++++++++ .../hytale/entity/RegisterAsEntitySystem.java | 39 +++++++++++++++++++ .../hytale/network/PacketListener.java | 10 +++++ .../hytale/network/PacketListenerManager.java | 10 +++++ .../example/hytale/system/ExampleSystem.java | 4 ++ 6 files changed, 134 insertions(+) create mode 100644 framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/entity/RegisterAsChunkSystem.java create mode 100644 framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/entity/RegisterAsEntitySystem.java create mode 100644 framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/network/PacketListener.java create mode 100644 framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/network/PacketListenerManager.java create mode 100644 test-hytale-plugin/src/main/java/io/example/hytale/system/ExampleSystem.java diff --git a/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/FairyHytalePlatform.java b/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/FairyHytalePlatform.java index 78f0fe66..b0e8c238 100644 --- a/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/FairyHytalePlatform.java +++ b/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/FairyHytalePlatform.java @@ -24,10 +24,17 @@ package io.fairyproject.hytale; +import com.hypixel.hytale.component.system.ISystem; import com.hypixel.hytale.server.core.plugin.PluginBase; +import com.hypixel.hytale.server.core.universe.world.storage.ChunkStore; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; import io.fairyproject.Debug; import io.fairyproject.FairyPlatform; import io.fairyproject.PlatformType; +import io.fairyproject.container.PreInitialize; +import io.fairyproject.container.collection.ContainerObjCollector; +import io.fairyproject.hytale.entity.RegisterAsChunkSystem; +import io.fairyproject.hytale.entity.RegisterAsEntitySystem; import io.fairyproject.hytale.logger.HytaleILogger; import io.fairyproject.hytale.plugin.HytalePluginHandler; import io.fairyproject.log.Log; @@ -70,6 +77,31 @@ public FairyHytalePlatform(PluginBase plugin, Runnable shutdownCallback, File da } } + @SuppressWarnings("unchecked") + @PreInitialize + public void onPreInitialize() { + this.getContainerContext().objectCollectorRegistry().add(ContainerObjCollector.create() + .withFilter(ContainerObjCollector.inherits(ISystem.class)) + .withAddHandler(ContainerObjCollector.warpInstance(ISystem.class, system -> { + if (system.getClass().isAnnotationPresent(RegisterAsEntitySystem.class)) { + EntityStore.REGISTRY.registerSystem((ISystem) system); + } else if (system.getClass().isAnnotationPresent(RegisterAsChunkSystem.class)) { + ChunkStore.REGISTRY.registerSystem((ISystem) system); + } + })) + .withRemoveHandler(ContainerObjCollector.warpInstance(ISystem.class, system -> { + if (system.getClass().isAnnotationPresent(RegisterAsEntitySystem.class)) { + EntityStore.REGISTRY.unregisterSystem( + (Class>) system.getClass() + ); + } else if (system.getClass().isAnnotationPresent(RegisterAsChunkSystem.class)) { + ChunkStore.REGISTRY.unregisterSystem( + (Class>) system.getClass() + ); + } + }))); + } + @NotNull @Override public T bind(@NotNull T terminable) { diff --git a/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/entity/RegisterAsChunkSystem.java b/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/entity/RegisterAsChunkSystem.java new file mode 100644 index 00000000..52e6ab43 --- /dev/null +++ b/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/entity/RegisterAsChunkSystem.java @@ -0,0 +1,39 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.fairyproject.hytale.entity; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a class to be automatically registered as a chunk system + * using {@code ChunkStore.REGISTRY.registerSystem}. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface RegisterAsChunkSystem { +} diff --git a/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/entity/RegisterAsEntitySystem.java b/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/entity/RegisterAsEntitySystem.java new file mode 100644 index 00000000..632b5480 --- /dev/null +++ b/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/entity/RegisterAsEntitySystem.java @@ -0,0 +1,39 @@ +/* + * MIT License + * + * Copyright (c) 2022 Fairy Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.fairyproject.hytale.entity; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a class to be automatically registered as an entity system + * using {@code EntityStore.REGISTRY.registerSystem}. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface RegisterAsEntitySystem { +} diff --git a/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/network/PacketListener.java b/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/network/PacketListener.java new file mode 100644 index 00000000..96419162 --- /dev/null +++ b/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/network/PacketListener.java @@ -0,0 +1,10 @@ +package io.fairyproject.hytale.network; + +import com.hypixel.hytale.server.core.universe.PlayerRef; +import org.jetbrains.annotations.NotNull; + +public interface PacketListener { + + void handle(@NotNull PlayerRef playerRef, @NotNull T packet); + +} diff --git a/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/network/PacketListenerManager.java b/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/network/PacketListenerManager.java new file mode 100644 index 00000000..f5dd8d72 --- /dev/null +++ b/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/network/PacketListenerManager.java @@ -0,0 +1,10 @@ +package io.fairyproject.hytale.network; + +import io.fairyproject.container.InjectableComponent; + +@InjectableComponent +public class PacketListenerManager { + + + +} diff --git a/test-hytale-plugin/src/main/java/io/example/hytale/system/ExampleSystem.java b/test-hytale-plugin/src/main/java/io/example/hytale/system/ExampleSystem.java new file mode 100644 index 00000000..160e67f7 --- /dev/null +++ b/test-hytale-plugin/src/main/java/io/example/hytale/system/ExampleSystem.java @@ -0,0 +1,4 @@ +package io.example.hytale.system; + +public class ExampleSystem { +} From 29f8506489b2fdd65698a95f8ac67d5d7f4b16ee Mon Sep 17 00:00:00 2001 From: LeeGodSRC Date: Mon, 26 Jan 2026 14:44:14 +0800 Subject: [PATCH 15/20] refactor(gradle-plugin): use entries instead of values() and extract className variable --- .../io/fairyproject/gradle/resource/FairyResourceAction.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/resource/FairyResourceAction.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/resource/FairyResourceAction.kt index 71be3dae..e65ae8c8 100644 --- a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/resource/FairyResourceAction.kt +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/resource/FairyResourceAction.kt @@ -150,8 +150,9 @@ abstract class FairyResourceAction @Inject constructor() : Action { } private fun pushClassToMapping(classInfo: ClassInfo, classMapper: MutableMap) { - ClassType.values().forEach { classType -> - if (!classInfo.name.contains("module-info") && classType.names.contains(classInfo.name.substringAfterLast("/"))) { + ClassType.entries.forEach { classType -> + val className = classInfo.name.substringAfterLast("/") + if (!classInfo.name.contains("module-info") && classType.names.contains(className)) { // Duplicated class types if (classMapper.contains(classType)) throw IllegalStateException("a project are not suppose to have 2 or more classes that are $classType") From 91b9d3e796eb2a46838916b8014e97fdc66a5e97 Mon Sep 17 00:00:00 2001 From: LeeGodSRC Date: Mon, 26 Jan 2026 14:44:22 +0800 Subject: [PATCH 16/20] chore: bump version to 0.8.5b1-hytale5-SNAPSHOT --- global.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/global.properties b/global.properties index 2a0c01c1..3142465c 100644 --- a/global.properties +++ b/global.properties @@ -1 +1 @@ -version = 0.8.5b1-hytale3-SNAPSHOT \ No newline at end of file +version = 0.8.5b1-hytale5-SNAPSHOT \ No newline at end of file From ea2d843a0a850fcaa4341c23b1486c4b50f76025 Mon Sep 17 00:00:00 2001 From: LeeGodSRC Date: Mon, 26 Jan 2026 14:44:39 +0800 Subject: [PATCH 17/20] feat(test-hytale-plugin): implement ExampleSystem using RegisterAsEntitySystem annotation --- .../example/hytale/system/ExampleSystem.java | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/test-hytale-plugin/src/main/java/io/example/hytale/system/ExampleSystem.java b/test-hytale-plugin/src/main/java/io/example/hytale/system/ExampleSystem.java index 160e67f7..98eacc08 100644 --- a/test-hytale-plugin/src/main/java/io/example/hytale/system/ExampleSystem.java +++ b/test-hytale-plugin/src/main/java/io/example/hytale/system/ExampleSystem.java @@ -1,4 +1,29 @@ package io.example.hytale.system; -public class ExampleSystem { +import com.hypixel.hytale.component.ArchetypeChunk; +import com.hypixel.hytale.component.CommandBuffer; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.component.query.Query; +import com.hypixel.hytale.component.system.tick.EntityTickingSystem; +import com.hypixel.hytale.server.core.entity.entities.Player; +import io.fairyproject.container.InjectableComponent; +import io.fairyproject.hytale.entity.RegisterAsEntitySystem; +import org.jline.utils.Log; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +@InjectableComponent +@RegisterAsEntitySystem +public class ExampleSystem extends EntityTickingSystem { + @Override + public void tick(float v, int i, @Nonnull ArchetypeChunk archetypeChunk, @Nonnull Store store, @Nonnull CommandBuffer commandBuffer) { + Log.info("Ticking players in ExampleSystem..."); + } + + @Nullable + @Override + public Query getQuery() { + return Query.any(); + } } From d450e143c0b280d39681796a301e9b5c3268fc59 Mon Sep 17 00:00:00 2001 From: LeeGodSRC Date: Mon, 26 Jan 2026 15:11:19 +0800 Subject: [PATCH 18/20] feat: add fairy.json support to gradle plugin resource action - Add fairy.json as recognized configuration file in jar entries - Update version to 0.8.5b1-hytale6-SNAPSHOT --- global.properties | 2 +- .../io/fairyproject/gradle/resource/FairyResourceAction.kt | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/global.properties b/global.properties index 3142465c..e005cf4b 100644 --- a/global.properties +++ b/global.properties @@ -1 +1 @@ -version = 0.8.5b1-hytale5-SNAPSHOT \ No newline at end of file +version = 0.8.5b1-hytale6-SNAPSHOT \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/resource/FairyResourceAction.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/resource/FairyResourceAction.kt index e65ae8c8..65a68735 100644 --- a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/resource/FairyResourceAction.kt +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/resource/FairyResourceAction.kt @@ -165,6 +165,7 @@ abstract class FairyResourceAction @Inject constructor() : Action { if (jarEntry.name.equals("module.json")) return true if (jarEntry.name.equals("plugin.yml")) return true if (jarEntry.name.equals("manifest.json")) return true + if (jarEntry.name.equals("fairy.json")) return true return false } From b88a4dfc406dedfc03b4cbead7c2ca2e81e9f3c5 Mon Sep 17 00:00:00 2001 From: LeeGodSRC Date: Sun, 1 Feb 2026 11:24:13 +0800 Subject: [PATCH 19/20] chore: update dependencies to latest versions in build.gradle.kts --- build-logic/build.gradle.kts | 10 +++++----- .../library/relocate/RelocationHandlerImpl.java | 4 ++-- gradle-plugin/build.gradle.kts | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 98505ff0..d66c7d24 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -11,7 +11,7 @@ dependencies { compileOnly(gradleApi()) compileOnly("org.codehaus.groovy:groovy-all:3.0.9") - implementation("org.yaml:snakeyaml:1.29") + implementation("org.yaml:snakeyaml:2.0") compileOnly("org.projectlombok:lombok:1.18.32") annotationProcessor("org.projectlombok:lombok:1.18.32") @@ -19,8 +19,8 @@ dependencies { annotationProcessor("org.jetbrains:annotations:23.0.0") implementation("com.google.code.gson:gson:2.8.9") - implementation("com.google.guava:guava:32.0.0-android") - implementation("org.apache.commons:commons-lang3:3.12.0") - implementation("org.ow2.asm:asm:9.7") - implementation("org.ow2.asm:asm-commons:9.7") + implementation("com.google.guava:guava:32.0.1-android") + implementation("org.apache.commons:commons-lang3:3.18.0") + implementation("org.ow2.asm:asm:9.9.1") + implementation("org.ow2.asm:asm-commons:9.9.1") } \ No newline at end of file diff --git a/framework/platforms/core-platform/src/main/java/io/fairyproject/library/relocate/RelocationHandlerImpl.java b/framework/platforms/core-platform/src/main/java/io/fairyproject/library/relocate/RelocationHandlerImpl.java index 78d683c3..3cc988f0 100644 --- a/framework/platforms/core-platform/src/main/java/io/fairyproject/library/relocate/RelocationHandlerImpl.java +++ b/framework/platforms/core-platform/src/main/java/io/fairyproject/library/relocate/RelocationHandlerImpl.java @@ -44,13 +44,13 @@ public class RelocationHandlerImpl implements RelocationHandler { Library.builder() .groupId("org.ow2.asm") .artifactId("asm") - .version("9.7") + .version("9.9.1") .build(), // asm-commons Library.builder() .groupId("org.ow2.asm") .artifactId("asm-commons") - .version("9.7") + .version("9.9.1") .build(), // jar-relocator Library.builder() diff --git a/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts index 669cae57..27822f3a 100644 --- a/gradle-plugin/build.gradle.kts +++ b/gradle-plugin/build.gradle.kts @@ -38,13 +38,13 @@ repositories { } dependencies { - implementation("io.spring.gradle:dependency-management-plugin:1.1.0") + implementation("io.spring.gradle:dependency-management-plugin:1.1.7") implementation(kotlin("stdlib-jdk8")) implementation("org.json:json:20231013") implementation("org.apache.maven:maven-plugin-api:3.8.5") implementation("org.jetbrains.kotlin:kotlin-gradle-plugin-api:1.7.22") - implementation("org.ow2.asm:asm:9.7") - implementation("org.ow2.asm:asm-commons:9.7") + implementation("org.ow2.asm:asm:9.9.1") + implementation("org.ow2.asm:asm-commons:9.9.1") implementation("com.google.code.gson:gson:2.10") implementation("io.github.toolfactory:narcissus:1.0.7") implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.14.0") From 105a6c7660052093e5bdcdaf34f8b31c1ba39c5a Mon Sep 17 00:00:00 2001 From: LeeGodSRC Date: Thu, 5 Feb 2026 23:37:31 +0800 Subject: [PATCH 20/20] feat: update project to use Java 25 and upgrade Lombok to version 1.18.40 --- build-logic/build.gradle.kts | 4 ++-- .../gradle/ProjectTransformPlugin.java | 2 +- .../kotlin/io.fairyproject.bootstrap.gradle.kts | 5 ----- .../main/kotlin/io.fairyproject.common.gradle.kts | 8 ++++---- .../io.fairyproject.module.hytale.gradle.kts | 13 ++++++++++++- .../bootstraps/hytale-bootstrap/build.gradle.kts | 11 +++++++++++ .../bukkit/bukkit-xseries/build.gradle.kts | 2 +- .../platforms/hytale-platform/build.gradle.kts | 12 +++++++++++- framework/tests/hytale-tests/build.gradle.kts | 11 +++++++++++ global.properties | 2 +- gradle-plugin/build.gradle.kts | 15 +++++++++------ .../io/fairyproject/gradle/FairyGradlePlugin.kt | 2 +- .../fairyproject/gradle/runner/RunServerTask.kt | 2 +- .../runner/hytale/task/RunHytaleServerTask.kt | 2 +- .../gradle/runner/task/PrepareSpigotTask.kt | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- test-hytale-plugin/build.gradle.kts | 7 ++++++- 17 files changed, 74 insertions(+), 28 deletions(-) diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index d66c7d24..13be76b2 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -13,8 +13,8 @@ dependencies { implementation("org.yaml:snakeyaml:2.0") - compileOnly("org.projectlombok:lombok:1.18.32") - annotationProcessor("org.projectlombok:lombok:1.18.32") + compileOnly("org.projectlombok:lombok:1.18.40") + annotationProcessor("org.projectlombok:lombok:1.18.40") compileOnly("org.jetbrains:annotations:23.0.0") annotationProcessor("org.jetbrains:annotations:23.0.0") diff --git a/build-logic/src/main/java/io/fairyproject/gradle/ProjectTransformPlugin.java b/build-logic/src/main/java/io/fairyproject/gradle/ProjectTransformPlugin.java index e6b5aef0..a69b669d 100644 --- a/build-logic/src/main/java/io/fairyproject/gradle/ProjectTransformPlugin.java +++ b/build-logic/src/main/java/io/fairyproject/gradle/ProjectTransformPlugin.java @@ -19,7 +19,7 @@ public void apply(Project project) { } private void configurePlugin(Project project, String language) { - sourceSets.all(sourceSet -> { + sourceSets.configureEach(sourceSet -> { project.getTasks().named(sourceSet.getCompileTaskName(language), AbstractCompile.class, compile -> { ModuleCompilerAction action = project.getObjects().newInstance(ModuleCompilerAction.class); compile.doLast("extraCompile", action); diff --git a/build-logic/src/main/kotlin/io.fairyproject.bootstrap.gradle.kts b/build-logic/src/main/kotlin/io.fairyproject.bootstrap.gradle.kts index c4cd556a..ead0ba95 100644 --- a/build-logic/src/main/kotlin/io.fairyproject.bootstrap.gradle.kts +++ b/build-logic/src/main/kotlin/io.fairyproject.bootstrap.gradle.kts @@ -4,11 +4,6 @@ plugins { id("io.fairyproject.publish") } -configurations { - compileOnly { - isCanBeResolved = true - } -} //sourceSets { // test.get().compileClasspath += configurations.compileOnly.get() diff --git a/build-logic/src/main/kotlin/io.fairyproject.common.gradle.kts b/build-logic/src/main/kotlin/io.fairyproject.common.gradle.kts index 2b3a84eb..4bbfeda5 100644 --- a/build-logic/src/main/kotlin/io.fairyproject.common.gradle.kts +++ b/build-logic/src/main/kotlin/io.fairyproject.common.gradle.kts @@ -70,16 +70,16 @@ repositories { dependencies { compileOnly("org.jetbrains:annotations:24.1.0") - compileOnly("org.projectlombok:lombok:1.18.32") - annotationProcessor("org.projectlombok:lombok:1.18.32") + compileOnly("org.projectlombok:lombok:1.18.40") + annotationProcessor("org.projectlombok:lombok:1.18.40") testCompileOnly("org.jetbrains:annotations:24.1.0") testImplementation("org.mockito:mockito-core:4.2.0") testImplementation("org.junit.jupiter:junit-jupiter-api:5.8.2") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.8.2") - testCompileOnly("org.projectlombok:lombok:1.18.32") - testAnnotationProcessor("org.projectlombok:lombok:1.18.32") + testCompileOnly("org.projectlombok:lombok:1.18.40") + testAnnotationProcessor("org.projectlombok:lombok:1.18.40") } java { diff --git a/build-logic/src/main/kotlin/io.fairyproject.module.hytale.gradle.kts b/build-logic/src/main/kotlin/io.fairyproject.module.hytale.gradle.kts index 6ac63f6f..96664679 100644 --- a/build-logic/src/main/kotlin/io.fairyproject.module.hytale.gradle.kts +++ b/build-logic/src/main/kotlin/io.fairyproject.module.hytale.gradle.kts @@ -8,4 +8,15 @@ dependencies { repositories { maven ("https://repo.imanity.dev/imanity-libraries/") -} \ No newline at end of file +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(25)) + } +} + +tasks.withType(JavaCompile::class.java).configureEach { + options.encoding = "UTF-8" + options.release = 25 +} diff --git a/framework/bootstraps/hytale-bootstrap/build.gradle.kts b/framework/bootstraps/hytale-bootstrap/build.gradle.kts index 1c189acb..d2232d94 100644 --- a/framework/bootstraps/hytale-bootstrap/build.gradle.kts +++ b/framework/bootstraps/hytale-bootstrap/build.gradle.kts @@ -5,4 +5,15 @@ plugins { dependencies { api(project(":core-bootstrap")) compileOnly("io.fairyproject:hytale-platform") +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(25)) + } +} + +tasks.withType(JavaCompile::class.java).configureEach { + options.encoding = "UTF-8" + options.release = 25 } \ No newline at end of file diff --git a/framework/modules/bukkit/bukkit-xseries/build.gradle.kts b/framework/modules/bukkit/bukkit-xseries/build.gradle.kts index 31d6f662..3dbe2106 100644 --- a/framework/modules/bukkit/bukkit-xseries/build.gradle.kts +++ b/framework/modules/bukkit/bukkit-xseries/build.gradle.kts @@ -12,7 +12,7 @@ dependencies { } tasks { - withType(JavaCompile::class.java) { + withType(JavaCompile::class.java).configureEach { options.encoding = "UTF-8" sourceCompatibility = "8" targetCompatibility = "8" diff --git a/framework/platforms/hytale-platform/build.gradle.kts b/framework/platforms/hytale-platform/build.gradle.kts index 3df2d311..226f0189 100644 --- a/framework/platforms/hytale-platform/build.gradle.kts +++ b/framework/platforms/hytale-platform/build.gradle.kts @@ -2,8 +2,18 @@ plugins { id("io.fairyproject.platform") } - dependencies { api(project(":core-platform")) compileOnlyApi("dev.imanity.hytale:HytaleServer:2026.01.17-2") } + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(25)) + } +} + +tasks.withType(JavaCompile::class.java).configureEach { + options.encoding = "UTF-8" + options.release = 25 +} diff --git a/framework/tests/hytale-tests/build.gradle.kts b/framework/tests/hytale-tests/build.gradle.kts index 7140047b..d335017f 100644 --- a/framework/tests/hytale-tests/build.gradle.kts +++ b/framework/tests/hytale-tests/build.gradle.kts @@ -7,3 +7,14 @@ dependencies { compileOnly("io.fairyproject:hytale-platform") api(project(":core-tests")) } + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(25)) + } +} + +tasks.withType(JavaCompile::class.java).configureEach { + options.encoding = "UTF-8" + options.release = 25 +} diff --git a/global.properties b/global.properties index e005cf4b..f3e5801f 100644 --- a/global.properties +++ b/global.properties @@ -1 +1 @@ -version = 0.8.5b1-hytale6-SNAPSHOT \ No newline at end of file +version = 0.8.5b1-hytale8-SNAPSHOT \ No newline at end of file diff --git a/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts index 27822f3a..7869702f 100644 --- a/gradle-plugin/build.gradle.kts +++ b/gradle-plugin/build.gradle.kts @@ -1,9 +1,10 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import java.util.* plugins { - kotlin("jvm") version "1.9.10" - id("com.gradle.plugin-publish") version "1.0.0" + kotlin("jvm") version "2.1.21" + id("com.gradle.plugin-publish") version "1.3.1" id("io.fairyproject.common") id("io.fairyproject.publish") `java-gradle-plugin` @@ -42,7 +43,7 @@ dependencies { implementation(kotlin("stdlib-jdk8")) implementation("org.json:json:20231013") implementation("org.apache.maven:maven-plugin-api:3.8.5") - implementation("org.jetbrains.kotlin:kotlin-gradle-plugin-api:1.7.22") + implementation("org.jetbrains.kotlin:kotlin-gradle-plugin-api:2.1.21") implementation("org.ow2.asm:asm:9.9.1") implementation("org.ow2.asm:asm-commons:9.9.1") implementation("com.google.code.gson:gson:2.10") @@ -61,12 +62,14 @@ gradlePlugin { } } -tasks.withType { +tasks.withType().configureEach { manifest { attributes["Implementation-Version"] = project.version } } -tasks.withType { - kotlinOptions.jvmTarget = "1.8" +tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_1_8) + } } \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/FairyGradlePlugin.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/FairyGradlePlugin.kt index 4b26211e..1579ef5c 100644 --- a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/FairyGradlePlugin.kt +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/FairyGradlePlugin.kt @@ -44,7 +44,7 @@ class FairyGradlePlugin : Plugin { } private fun configurePlugin(project: Project, language: String) { - sourceSets.all { sourceSet -> + sourceSets.configureEach { sourceSet -> project.tasks.named(sourceSet.getCompileTaskName(language)) { val action = project.objects.newInstance(FairyCompilerAction::class.java) it.doLast("fairyCompile", action) diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/RunServerTask.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/RunServerTask.kt index b280e57b..995349e0 100644 --- a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/RunServerTask.kt +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/RunServerTask.kt @@ -37,7 +37,7 @@ import javax.inject.Inject * @author LeeGod * @see RunServerPlugin */ -open class RunServerTask @Inject constructor(private val version: JavaVersion, artifact: ServerJarArtifact, workDirectory: Path): JavaExec() { +abstract class RunServerTask @Inject constructor(private val version: JavaVersion, artifact: ServerJarArtifact, workDirectory: Path): JavaExec() { init { classpath = project.files(artifact.artifactPath) diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/RunHytaleServerTask.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/RunHytaleServerTask.kt index 6519e1dc..551b776e 100644 --- a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/RunHytaleServerTask.kt +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/RunHytaleServerTask.kt @@ -40,7 +40,7 @@ import javax.inject.Inject * @author LeeGod * @see io.fairyproject.gradle.runner.hytale.RunHytaleServerPlugin */ -open class RunHytaleServerTask @Inject constructor( +abstract class RunHytaleServerTask @Inject constructor( private val version: JavaVersion, artifact: HytaleServerArtifact, workDirectory: Path, diff --git a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/task/PrepareSpigotTask.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/task/PrepareSpigotTask.kt index b5af7e0d..34d48309 100644 --- a/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/task/PrepareSpigotTask.kt +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/task/PrepareSpigotTask.kt @@ -40,7 +40,7 @@ import kotlin.io.path.absolutePathString * @author LeeGod * @see io.fairyproject.gradle.runner.RunServerPlugin */ -open class PrepareSpigotTask @Inject constructor( +abstract class PrepareSpigotTask @Inject constructor( buildToolDirectory: Path, artifact: ServerJarArtifact, private val extension: RunServerExtension): JavaExec() { diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 5c82cb03..2f2958b9 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/test-hytale-plugin/build.gradle.kts b/test-hytale-plugin/build.gradle.kts index 3c77c85f..5e3556c5 100644 --- a/test-hytale-plugin/build.gradle.kts +++ b/test-hytale-plugin/build.gradle.kts @@ -24,8 +24,13 @@ dependencies { java { toolchain { - languageVersion.set(JavaLanguageVersion.of(21)) + languageVersion.set(JavaLanguageVersion.of(25)) } } +tasks.withType(JavaCompile::class.java).configureEach { + options.encoding = "UTF-8" + options.release = 25 +} + runHytaleServer { } \ No newline at end of file