diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 98505ff0..13be76b2 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -11,16 +11,16 @@ 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") + 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") 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/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 new file mode 100644 index 00000000..96664679 --- /dev/null +++ b/build-logic/src/main/kotlin/io.fairyproject.module.hytale.gradle.kts @@ -0,0 +1,22 @@ +plugins { + id("io.fairyproject.module") +} + +dependencies { + compileOnly("dev.imanity.hytale:HytaleServer:2026.01.17-2") +} + +repositories { + maven ("https://repo.imanity.dev/imanity-libraries/") +} + +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/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..d2232d94 --- /dev/null +++ b/framework/bootstraps/hytale-bootstrap/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id("io.fairyproject.bootstrap") +} + +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/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/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/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..9a8ab51e --- /dev/null +++ b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/HytaleCommandModule.java @@ -0,0 +1,50 @@ +/* + * 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 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()); + 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:

+ * + * + * @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/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..fa28de1d --- /dev/null +++ b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/event/HytalePlayerCommandContext.java @@ -0,0 +1,148 @@ +/* + * 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.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; + +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; + } + + public Player getPlayerComponent() { + return store.getComponent(ref, Player.getComponentType()); + } + + /** + * 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..ee536f26 --- /dev/null +++ b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/map/DefaultHytaleCommandMap.java @@ -0,0 +1,170 @@ +/* + * 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.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; + +/** + * Default implementation of HytaleCommandMap that registers commands with Hytale's CommandRegistry. + * + *

This implementation supports three types of command executors:

+ *
    + *
  • {@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 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 { + + 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]); + } + + // Analyze command methods to determine executor type + ContextTypeInfo contextInfo = analyzeContextTypes(command.getClass()); + + AbstractCommand commandExecutor; + if (contextInfo.hasOnlyPlayerContext()) { + // All methods require player context - use player-only executor (thread-safe) + commandExecutor = new HytalePlayerCommandExecutor(command); + } 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(); + CommandRegistration registration = commandRegistry.registerCommand(commandExecutor); + + command.getMetaStorage().put(EXECUTOR_KEY, commandExecutor); + command.getMetaStorage().put(REGISTRATION_KEY, registration); + } + + /** + * Analyzes the command class to determine what context types are used by @Command methods. + */ + private ContextTypeInfo analyzeContextTypes(Class clazz) { + boolean hasPlayerContext = false; + boolean hasGeneralContext = false; + + for (Method method : clazz.getDeclaredMethods()) { + if (!method.isAnnotationPresent(Command.class)) { + continue; + } + + 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) { + ContextTypeInfo superInfo = analyzeContextTypes(superclass); + hasPlayerContext = hasPlayerContext || superInfo.hasPlayerContext; + hasGeneralContext = hasGeneralContext || superInfo.hasGeneralContext; + } + + 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 + 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..a638ead4 --- /dev/null +++ b/framework/modules/hytale/hytale-command/src/main/java/io/fairyproject/hytale/command/presence/PlayerPresenceProvider.java @@ -0,0 +1,76 @@ +/* + * 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; +import io.fairyproject.hytale.command.event.HytalePlayerCommandContext; + +/** + * 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 { + + 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 + @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(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/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/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 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/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/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/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/framework/platforms/hytale-platform/build.gradle.kts b/framework/platforms/hytale-platform/build.gradle.kts new file mode 100644 index 00000000..226f0189 --- /dev/null +++ b/framework/platforms/hytale-platform/build.gradle.kts @@ -0,0 +1,19 @@ +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/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..b0e8c238 --- /dev/null +++ b/framework/platforms/hytale-platform/src/main/java/io/fairyproject/hytale/FairyHytalePlatform.java @@ -0,0 +1,147 @@ +/* + * 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.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; +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 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; + this.shutdownCallback = shutdownCallback; + setPlugin(plugin); + + 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()); + if (!Debug.UNIT_TEST) { + Log.set(new HytaleILogger()); + } + } + + @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) { + 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 (this.shutdownCallback != null) { + this.shutdownCallback.run(); + } + } + + private static synchronized void setPlugin(PluginBase plugin) { + PLUGIN = plugin; + } + + @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/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/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(""); + } +} 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/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/framework/tests/hytale-tests/build.gradle.kts b/framework/tests/hytale-tests/build.gradle.kts new file mode 100644 index 00000000..d335017f --- /dev/null +++ b/framework/tests/hytale-tests/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + id("io.fairyproject.versioned") + id("io.fairyproject.publish") +} + +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/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/global.properties b/global.properties index a88e3bff..f3e5801f 100644 --- a/global.properties +++ b/global.properties @@ -1 +1 @@ -version = 0.8.4b1-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 669cae57..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` @@ -38,13 +39,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.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") implementation("io.github.toolfactory:narcissus:1.0.7") implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.14.0") @@ -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 1026e843..1579ef5c 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") } @@ -42,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/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..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 @@ -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)) @@ -143,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") @@ -156,6 +164,8 @@ 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 + if (jarEntry.name.equals("fairy.json")) return true return false } @@ -176,7 +186,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..aadb6c92 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/resource/impl/FairyResourceHytaleMeta.kt @@ -0,0 +1,106 @@ +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"] = convertToValidSemver(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()) + } + + private fun convertToValidSemver(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/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/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..90073719 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerExtension.kt @@ -0,0 +1,98 @@ +/* + * 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) + + /** + * 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 new file mode 100644 index 00000000..ea28f7da --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/RunHytaleServerPlugin.kt @@ -0,0 +1,249 @@ +/* + * 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.extension.FairyExtension +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 +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 { + + /** Plugin constants. */ + 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(PLUGIN_NAME, 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) + configureGenerateHytaleManifest() + configureRunHytaleServer(artifact, workDir, snapshotDir) + } + + private fun configurePrepareHytaleDownloader(downloaderDir: Path) { + project.tasks.register( + "prepareHytaleDownloader", + PrepareHytaleDownloaderTask::class.java, + downloaderDir, + extension + ).configure { + it.group = PLUGIN_NAME + 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 = PLUGIN_NAME + 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 = PLUGIN_NAME + 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 = PLUGIN_NAME + it.description = "Cleans the Hytale server work directory" + } + } + + 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 = PLUGIN_NAME + 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 ?: "") + + // 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") + } + } + + private fun configureRunHytaleServer( + artifact: HytaleServerArtifact, + workDir: Path, + snapshotDir: Path + ) { + project.afterEvaluate { + project.tasks.register( + PLUGIN_NAME, + RunHytaleServerTask::class.java, + extension.javaVersion.get(), + artifact, + workDir, + extension + ).configure { + if (extension.cleanup.get()) { + it.dependsOn("cleanHytaleServer") + } + it.doFirst(CopyHytaleSnapshotAction(snapshotDir, workDir)) + it.dependsOn("prepareHytaleBuild") + it.dependsOn("generateHytaleManifest") + + it.group = PLUGIN_NAME + 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/GenerateHytaleManifestTask.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/GenerateHytaleManifestTask.kt new file mode 100644 index 00000000..e1b4d210 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/GenerateHytaleManifestTask.kt @@ -0,0 +1,333 @@ +/* + * 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.io.IOException +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() { + + /** Constants used for class file scanning. */ + companion object { + private const val CLASS_FILE_EXTENSION = ".class" + } + + @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 + + // 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() + + /** + * Generates the Hytale manifest.json and fairy.json files. + */ + @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) + 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) + 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 + + // Find the Plugin/Application interface class from runtime classpath + val pluginInterfaceClass = runtimeClasspath.files + .filter { it.isFile && it.extension == "jar" } + .mapNotNull { findPluginInterfaceInJar(it) } + .firstOrNull() + + return classesDirectory.walkTopDown() + .filter { it.isFile && it.name.endsWith(CLASS_FILE_EXTENSION) } + .mapNotNull { findMainClassInFile(it, pluginInterfaceClass) } + .firstOrNull() + } + + private fun findPluginInterfaceInJar(jarFile: File): String? { + val jar = try { + JarFile(jarFile) + } catch (e: IOException) { + logger.debug("Could not read JAR file: ${jarFile.name}", e) + 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 -> + 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 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): Map { + val json = mutableMapOf() + json["name"] = fairyName.orNull ?: projectName.get() + json["mainClass"] = mainClass + fairyMainPackage.orNull?.let { json["shadedPackage"] = it } + fairyFairyPackage.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()) { + val result = classesDirectory.walkTopDown() + .filter { it.isFile && it.name.endsWith(CLASS_FILE_EXTENSION) } + .mapNotNull { findHytalePluginInBytes(it.readBytes()) } + .firstOrNull() + if (result != null) return result + } + + // Then, scan runtime classpath JARs (for HytalePlugin from hytale-bootstrap) + return runtimeClasspath.files + .filter { it.isFile && it.extension == "jar" } + .mapNotNull { findHytalePluginInJar(it) } + .firstOrNull() + } + + private fun findHytalePluginInJar(jarFile: File): String? { + val jar = try { + JarFile(jarFile) + } catch (e: IOException) { + logger.debug("Could not read JAR file: ${jarFile.name}", e) + return null + } + + 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? { + 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 + ): Map { + val manifest = mutableMapOf() + + manifest["Main"] = hytalePluginClass + manifest["Group"] = (hytaleProps["Group"] as? String)?.takeIf { it.isNotEmpty() } ?: "io.fairyproject" + manifest["Name"] = fairyName.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/PrepareHytaleBuildTask.kt b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/PrepareHytaleBuildTask.kt new file mode 100644 index 00000000..9618ec90 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/PrepareHytaleBuildTask.kt @@ -0,0 +1,201 @@ +/* + * 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() { + + /** + * Downloads and extracts Hytale server files using the Hytale Downloader CLI. + */ + @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 = backupModsDirectory(modsDir) + + ZipFile(zipFile).use { zip -> + zip.entries().asSequence() + .filter { shouldExtractEntry(it.name) } + .forEach { entry -> extractZipEntry(zip, entry) } + } + + 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 new file mode 100644 index 00000000..0c798ca6 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/PrepareHytaleDownloaderTask.kt @@ -0,0 +1,170 @@ +/* + * 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() { + + /** OS property constants and utility functions. */ + 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()) + + init { + // If custom path is provided, or downloader already exists, skip + if (extension.downloaderPath.isPresent || downloaderPath.exists()) { + enabled = false + } + } + + /** + * Downloads and prepares the Hytale Downloader CLI if not already present. + */ + @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_PROPERTY).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_PROPERTY).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_PROPERTY).lowercase().contains("win") + } +} 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..551b776e --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/fairyproject/gradle/runner/hytale/task/RunHytaleServerTask.kt @@ -0,0 +1,91 @@ +/* + * 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.plugins.JavaPluginExtension +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 + */ +abstract class RunHytaleServerTask @Inject constructor( + private val version: JavaVersion, + artifact: HytaleServerArtifact, + workDirectory: Path, + extension: RunHytaleServerExtension +) : JavaExec() { + + init { + // 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` + + // 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()) + // 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 { + return version + } + +} 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 new file mode 100644 index 00000000..5e3556c5 --- /dev/null +++ b/test-hytale-plugin/build.gradle.kts @@ -0,0 +1,36 @@ +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") + implementation("io.fairyproject:hytale-command") + implementation("io.fairyproject:core-devtools") +} + +java { + toolchain { + 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 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/command/TestCommand.java b/test-hytale-plugin/src/main/java/io/example/hytale/command/TestCommand.java new file mode 100644 index 00000000..05046784 --- /dev/null +++ b/test-hytale-plugin/src/main/java/io/example/hytale/command/TestCommand.java @@ -0,0 +1,170 @@ +/* + * 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 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; +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 demonstrates mixed context types:

+ *
    + *
  • 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 types used in the command methods.

+ */ +@Command({"test", "t", "hytaletest"}) +@InjectableComponent +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"; + } + + /** + * /test - Shows help message + */ + @Command("#") + 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"); + 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(HytaleCommandContext 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(HytaleCommandContext 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(HytaleCommandContext context, @Arg String message) { + context.sendMessage(MessageType.INFO, "Echo: " + message); + } + + /** + * /test add - Adds two numbers together + */ + @Command("add") + public void add(HytaleCommandContext 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(HytaleCommandContext context) { + context.sendMessage(MessageType.WARN, "This is a warning message!"); + } + + /** + * /test error - Test error message + */ + @Command("error") + public void error(HytaleCommandContext 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()); + } + + @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 + */ + @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()); + } +} 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!"; + } +} 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/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..98eacc08 --- /dev/null +++ b/test-hytale-plugin/src/main/java/io/example/hytale/system/ExampleSystem.java @@ -0,0 +1,29 @@ +package io.example.hytale.system; + +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(); + } +} 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