diff --git a/api/src/main/java/io/github/eat_ram/fuream/api/FureamWorldConfig.java b/api/src/main/java/io/github/eat_ram/fuream/api/FureamWorldConfig.java index c68fa69..59df5eb 100644 --- a/api/src/main/java/io/github/eat_ram/fuream/api/FureamWorldConfig.java +++ b/api/src/main/java/io/github/eat_ram/fuream/api/FureamWorldConfig.java @@ -120,6 +120,19 @@ void setGuiNextPageItemId( // i18n + default @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> getGuiTitle() { + EnumMap titles = new EnumMap<>(FurnaceType.class); + titles.put(FurnaceType.FURNACE, "Furnace"); + titles.put(FurnaceType.SMOKER, "Smoker"); + titles.put(FurnaceType.BLAST_FURNACE, "Blast Furnace"); + return titles; + } + + default void setGuiTitle( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + } + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> getGuiBorderItemTitle(); diff --git a/gradlew b/gradlew old mode 100755 new mode 100644 diff --git a/settings.gradle.kts b/settings.gradle.kts index f8eff80..0cf5a37 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -36,4 +36,5 @@ include("api") include("1_19_4f") include("af2023f") include("1_20f") -include("1_20_1f") \ No newline at end of file +include("1_20_1f") +include("spigot") diff --git a/spigot/README.md b/spigot/README.md new file mode 100644 index 0000000..353f075 --- /dev/null +++ b/spigot/README.md @@ -0,0 +1,113 @@ +# Fuream Spigot/Purpur 移植版 + +这是 Fabric 1.20.1 服务端模组 Fuream 的 Bukkit 移植。插件提供可扩展的熔炉输入、燃料和输出槽,累积经验,以及冲突配方选择,并保留 Fabric 使用的根级 `FureamData` NBT 结构。 + +## 版本与 Jar + +| 服务端版本 | 使用文件 | `api-version` | 最低 Java | +| --- | --- | --- | --- | +| 1.7-1.12.x | `fuream-legacy-1.7-1.12.jar` | 无 | Java 8 | +| 1.13-1.20.4 | `fuream-flattening-1.13-1.20.4.jar` | `1.13` | Java 8 或服务端要求版本 | +| 1.20.5-26.2 | `fuream-components-1.20.5-26.2.jar` | `1.20.5` | 服务端要求版本 | + +保证范围是 Spigot 和 Purpur。三个 Jar 使用同一套配置和 `FureamData` 格式,世界升级时不需要手动转换。插件主体编译为 Java 8 字节码;高版本服务器仍须使用该版本服务端要求的 Java。 + +插件没有 NBTAPI 或其他外置插件依赖。Byte Buddy、Java 8 JRE 自附加所需的 JNA,以及原生 NBT 桥均已打入 Jar。 + +## 行为 + +- 每种炉子可分别配置输入、燃料和输出槽数量,超过一页时可翻页。 +- 每位玩家有独立的 GUI 会话和页码;只有炉子内的物品数据共享。 +- 支持拖拽、双击、数字键、丢弃键、Shift 点击、输出游标和燃料区空桶操作。 +- 1.13+ 使用服务端配方注册顺序选取首个匹配配方;冲突列表按稳定配方 ID 排序并记住选择。 +- 1.7-1.12 只使用原版唯一熔炉配方,不显示配方选择功能;旧存档内的 override 数据会保留但忽略。 +- 输出可合并并分散到多个槽。湿海绵只有在燃料区另有空槽时才把桶转换为水桶。 +- 自定义烧炼会触发服务器版本支持的 Bukkit 熔炉事件,并接受取消、燃烧时间、烧炼时间和产物修改。 +- 漏斗及漏斗矿车通过原生三槽投影和 `InventoryMoveItemEvent` 交互;底部先抽桶/水桶,再抽输出。 +- 比较器只观察 Fabric 的输入、燃料、输出三槽遮罩视图,而不是全部扩展槽。 +- 炉子锁、八格使用距离、区块状态和方块类型会在 GUI 会话期间持续检查。 + +## 原生 NBT + +数据直接存于炉子方块实体根 NBT,不使用 PDC: + +```text +Furnace BlockEntity root +|- FureamData +| |- Inputs +| |- Fuels +| |- Outputs +| |- RecipeOverridingInput +| |- OverriddenRecipes +| |- RunningRecipe +| `- Xp +|- BurnTime +|- CookTime +`- CookTimeTotal +``` + +物品编码由运行版本适配:1.12 及以前保留数字 ID/耐久值,1.13-1.20.4 使用扁平化物品 NBT,1.20.5+ 使用数据组件格式。`FureamData` 的键名和层级保持不变,可用于 Fabric 1.20.1 -> Spigot/Purpur -> Fabric 的往返迁移。 + +插件在 `onLoad` 阶段为当前 NMS 炉子类匹配加载/保存方法并安装根 NBT 钩子。如果钩子安装失败,插件会在接管任何炉子前拒绝启用,以免静默丢失扩展槽物品。旧版本曾写入 `PublicBukkitValues` 的 Fuream 数据会在首次加载时迁移到根级标签,其他插件的 PDC 不受影响。 + +在切回 Fabric 前,应让 Fuream 插件保持启用并正常停服。不要先移除插件再用 Bukkit 服务端加载和保存世界,否则原版方块实体可能丢弃未知标签。 + +## 启用、禁用与配置 + +普通世界的配置文件是 `/fuream.json`。槽位和 GUI 外观/i18n 字段接受 Fabric 配置格式;写回配置时会完整保存全部已知字段,并保留第三方未知 JSON 字段。 + +下界和末地按以下顺序查找配置: + +1. 当前维度的 `fuream.json` +2. 当前维度的 `serverconfig/fuream.json` 或 `serverconfig/fuream.json5` +3. `server.properties` 的 `level-name` 所指主世界中的相同路径 + +维度世界没有找到配置时只使用内存默认值,不会自动生成文件。普通世界没有配置时会在自己的世界目录创建默认文件,因此独立普通世界继续保持独立配置。 + +没有 `FureamData` 且未启用的炉子不会被迁移或清空。已有 `FureamData` 但当前禁用的炉子进入被动模式:扩展槽仍保存在 NBT,首个有效输入、燃料和输出投影到原版三槽运行。重新启用时会原子收回投影,不复制物品。配置重载、区块卸载和停服都会关闭失效 GUI 并同步数据。 + +管理命令: + +```text +/fuream reload +/fuream status +``` + +`status` 会显示当前版本适配器、根 NBT 钩子状态、每个世界启用的炉子类型及实际配置来源。 + +## 构建 + +在仓库根目录执行: + +```powershell +.\gradlew.bat :spigot:test :spigot:assemble +``` + +产物位于 `spigot/build/libs/`: + +```text +fuream-legacy-1.7-1.12.jar +fuream-flattening-1.13-1.20.4.jar +fuream-components-1.20.5-26.2.jar +``` + +## 验证状态 + +纯核心测试覆盖多槽插入/合并、跨槽产出、湿海绵、比较器遮罩、经验与配置完整往返等行为。使用 `Z:\fuream-servers` 中的 Jar 作为只读模板,在一次性本地目录实测了: + +| 服务端 | 结果 | +| --- | --- | +| Spigot 1.14.4 | 插件加载、根 NBT 钩子、真实熔炼与持久化通过 | +| Purpur 1.15.2、1.16.5 | 纯 Java 8 JRE 下原生自附加、根 NBT 钩子和插件启用通过 | +| Purpur 1.17.1、1.18.2、1.19.4 | 根 NBT 钩子和插件启用通过 | +| Purpur 1.20.1 | 熔炼、启停无损投影、多槽输出、比较器通过 | +| Purpur 1.20.4 | 根 NBT 钩子、插件启用和关闭通过 | +| Purpur 1.20.6 | 数据组件物品读写、熔炼与持久化通过 | +| Purpur 1.21.1 | 根 NBT 钩子、插件启用和关闭通过 | +| Purpur 1.21.4 | 数据组件物品读写、熔炼与持久化通过 | + +本次可用模板中没有 1.7.10、1.8.8、1.12.2、1.13.2 和 26.2 服务端 Jar,因此这些边界版本只完成了编译期隔离、1.8.8 API 依赖扫描和版本能力测试,发布前仍应补做对应服务端启动及手工 GUI 验收。双玩家独立分页、距离、锁和快捷键也仍需在目标服务器上做人工验收。 + +## 已知保留问题 + +破坏熔炉时仍会掉落“配方设置输入槽”的物品。这是 Fabric 基准版本本身的已知问题,为保持基准行为,本移植不修复这一项。 diff --git a/spigot/build.gradle.kts b/spigot/build.gradle.kts new file mode 100644 index 0000000..9fd076d --- /dev/null +++ b/spigot/build.gradle.kts @@ -0,0 +1,187 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar +import java.nio.charset.StandardCharsets +import java.util.zip.ZipFile + +plugins { + `java` + id("com.github.johnrengelman.shadow") version "8.1.1" +} + +group = "io.github.eat-ram.fuream-spigot" +version = "0.0.1" +evaluationDependsOn(":api") +project(":api") { + repositories { + mavenCentral() + } +} +base { + archivesName = "fuream" +} + +repositories { + mavenCentral() + maven("https://hub.spigotmc.org/nexus/content/repositories/snapshots/") { + name = "spigotmc-repo" + } + maven("https://oss.sonatype.org/content/groups/public/") { + name = "sonatype" + } + maven("https://repo.codemc.io/repository/maven-public/") { + name = "CodeMC" + } +} + +dependencies { + implementation("net.bytebuddy:byte-buddy:1.17.7") + implementation("net.bytebuddy:byte-buddy-agent:1.17.7") + implementation("net.java.dev.jna:jna:5.17.0") + implementation("net.java.dev.jna:jna-platform:5.17.0") + implementation("com.google.code.gson:gson:2.10.1") + compileOnly("org.spigotmc:spigot-api:1.20.1-R0.1-SNAPSHOT") + compileOnly("org.jetbrains:annotations:26.0.2") + compileOnly(project(":api")) { + isTransitive = false + } + + testImplementation("org.spigotmc:spigot-api:1.20.1-R0.1-SNAPSHOT") + testImplementation("org.junit.jupiter:junit-jupiter:5.10.2") + testImplementation(project(":api")) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.test { + useJUnitPlatform() +} + +tasks.processResources { + filesMatching("plugin.yml") { + expand("version" to project.version) + } +} + +val targetJavaVersion = 8 +java { + val javaVersion = JavaVersion.toVersion(targetJavaVersion) + sourceCompatibility = javaVersion + targetCompatibility = javaVersion + if (JavaVersion.current() < javaVersion) { + toolchain.languageVersion = JavaLanguageVersion.of(targetJavaVersion) + } +} + +tasks.withType().configureEach { + options.encoding = "UTF-8" + options.compilerArgs.addAll(listOf("-Xlint:deprecation", "-Xlint:unchecked")) + + if (targetJavaVersion >= 10 || JavaVersion.current().isJava10Compatible) { + options.release.set(targetJavaVersion) + } +} + +tasks.jar { + enabled = false +} +tasks.shadowJar { + enabled = false +} + +fun registerVariant(taskName: String, fileName: String, resourceDir: String) = + tasks.register(taskName) { + group = "build" + description = "Builds $fileName" + dependsOn(tasks.classes, project(":api").tasks.jar) + archiveFileName.set(fileName) + archiveClassifier.set("") + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + configurations = listOf(project.configurations.runtimeClasspath.get()) + from(sourceSets.main.get().output) { + exclude("plugin.yml") + } + from(resourceDir) { + filesMatching("plugin.yml") { + expand("version" to project.version) + } + } + exclude( + "META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA", "module-info.class", + "META-INF/versions/**" + ) + from(rootProject.file("LICENSE")) { into("META-INF") } + from(zipTree(project(":api").tasks.jar.get().archiveFile)) { include("**/*.class") } + mergeServiceFiles() + relocate("net.bytebuddy", "io.github.eat_ram.fuream.libs.bytebuddy") + relocate("com.sun.jna", "io.github.eat_ram.fuream.libs.jna") + relocate("com.google.gson", "io.github.eat_ram.fuream.libs.gson") + isPreserveFileTimestamps = false + isReproducibleFileOrder = true + } + +val legacyJar = registerVariant( + "legacyJar", "fuream-legacy-1.7-1.12.jar", "src/variants/legacy" +) +val flatteningJar = registerVariant( + "flatteningJar", "fuream-flattening-1.13-1.20.4.jar", "src/variants/flattening" +) +val componentsJar = registerVariant( + "componentsJar", "fuream-components-1.20.5-26.2.jar", "src/variants/components" +) + +tasks.assemble { + dependsOn(legacyJar, flatteningJar, componentsJar) +} + +val verifyVariantJars = tasks.register("verifyVariantJars") { + group = "verification" + description = "Checks variant metadata, Java 8 bytecode, shading, and guarded API references" + dependsOn(legacyJar, flatteningJar, componentsJar) + doLast { + val variants = listOf( + legacyJar.get().archiveFile.get().asFile to null, + flatteningJar.get().archiveFile.get().asFile to "1.13", + componentsJar.get().archiveFile.get().asFile to "1.20.5" + ) + val forbiddenEntries = listOf("net/bytebuddy/", "com/google/gson/", "com/sun/jna/") + for ((jarFile, apiVersion) in variants) { + ZipFile(jarFile).use { jar -> + val pluginEntry = jar.getEntry("plugin.yml") + ?: error("${jarFile.name} has no plugin.yml") + val pluginYml = jar.getInputStream(pluginEntry).bufferedReader(StandardCharsets.UTF_8).use { it.readText() } + check(pluginYml.contains("version: $version")) { + "${jarFile.name} does not contain project version $version" + } + if (apiVersion == null) { + check(!pluginYml.contains("api-version:")) { + "Legacy plugin.yml must not declare api-version" + } + } else { + check(pluginYml.contains("api-version: '$apiVersion'")) { + "${jarFile.name} has the wrong api-version" + } + } + + val entries = jar.entries().asSequence().toList() + check(entries.none { entry -> forbiddenEntries.any(entry.name::startsWith) }) { + "${jarFile.name} contains an unrelocated runtime dependency" + } + for (entry in entries) { + if (!entry.name.endsWith(".class")) continue + val bytes = jar.getInputStream(entry).use { it.readBytes() } + val major = ((bytes[6].toInt() and 0xff) shl 8) or (bytes[7].toInt() and 0xff) + check(major <= 52) { "${entry.name} in ${jarFile.name} is not Java 8 bytecode" } + if (jarFile == variants[0].first && + entry.name != "io/github/eat_ram/fuream/hook/BlockExplodeListener.class") { + val constants = String(bytes, StandardCharsets.ISO_8859_1) + check(!constants.contains("org/bukkit/event/block/BlockExplodeEvent")) { + "${entry.name} directly links BlockExplodeEvent in the legacy variant" + } + } + } + } + } + } +} + +tasks.check { + dependsOn(verifyVariantJars) +} diff --git a/spigot/gradle/wrapper/gradle-wrapper.properties b/spigot/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..4e7fa0b --- /dev/null +++ b/spigot/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.7-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/FureamMain.java b/spigot/src/main/java/io/github/eat_ram/fuream/FureamMain.java new file mode 100644 index 0000000..db7b7e4 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/FureamMain.java @@ -0,0 +1,334 @@ +package io.github.eat_ram.fuream; + +import java.io.File; +import java.io.FileInputStream; +import java.util.Locale; +import java.util.HashMap; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.WeakHashMap; +import java.util.logging.Level; + +import io.github.eat_ram.fuream.api.FureamWorldConfig; +import io.github.eat_ram.fuream.api.FurnaceType; +import io.github.eat_ram.fuream.hook.BlockBreakListener; +import io.github.eat_ram.fuream.hook.BlockExplodeListener; +import io.github.eat_ram.fuream.hook.ChunkListener; +import io.github.eat_ram.fuream.hook.ComparatorListener; +import io.github.eat_ram.fuream.hook.FurnaceManager; +import io.github.eat_ram.fuream.hook.FurnaceOpenListener; +import io.github.eat_ram.fuream.hook.GuiListener; +import io.github.eat_ram.fuream.hook.HopperListener; +import io.github.eat_ram.fuream.hook.VanillaFurnaceGuardListener; +import io.github.eat_ram.fuream.hook.RecipeIndexListener; +import io.github.eat_ram.fuream.compat.RecipeCompat; +import io.github.eat_ram.fuream.compat.FurnaceEventCompat; +import io.github.eat_ram.fuream.compat.ServerVersion; +import io.github.eat_ram.fuream.compat.VersionAdapters; +import io.github.eat_ram.fuream.nbt.FurnaceNbtInstrumentation; +import io.github.eat_ram.fuream.nbt.FurnaceRootNbtBridge; +import io.github.eat_ram.fuream.nbt.NbtSelfTest; +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockState; +import org.bukkit.block.Furnace; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabExecutor; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.world.WorldLoadEvent; +import org.bukkit.event.world.WorldSaveEvent; +import org.bukkit.event.world.WorldUnloadEvent; +import org.bukkit.plugin.java.JavaPlugin; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class FureamMain extends JavaPlugin implements Listener, TabExecutor { + public static final String DATA_KEY = "FureamData"; + private static final Map<@NotNull World, @NotNull FureamWorldConfig> WORLD_CONFIGS = new WeakHashMap<>(); + private static final Map<@NotNull World, @NotNull File> WORLD_CONFIG_SOURCES = new WeakHashMap<>(); + private static final Map WORLD_CONFIG_ERRORS = new HashMap<>(); + private static FureamMain INSTANCE; + private boolean rootNbtReady; + + public static FureamMain getInstance() { + return INSTANCE; + } + + @Override + public void onLoad() { + try { + FurnaceNbtInstrumentation.install(this); + this.rootNbtReady = true; + } catch (Throwable e) { + this.getLogger().severe( + "Unable to install furnace root-NBT hooks; refusing to fall back to PDC: " + e + ); + this.getLogger().log(Level.SEVERE, "Root-NBT hook installation failed", e); + } + } + + @Override + public void onEnable() { + INSTANCE = this; + + if (!this.rootNbtReady) { + this.getServer().getPluginManager().disablePlugin(this); + return; + } + try { + NbtSelfTest.run(); + } catch (Throwable failure) { + this.rootNbtReady = false; + this.getLogger().log(Level.SEVERE, "Native NBT self-test failed; no furnaces were adopted", failure); + this.getServer().getPluginManager().disablePlugin(this); + return; + } + + // Register listeners + this.getServer().getPluginManager().registerEvents(this, this); + this.getServer().getPluginManager().registerEvents(new FurnaceOpenListener(), this); + this.getServer().getPluginManager().registerEvents(new GuiListener(), this); + this.getServer().getPluginManager().registerEvents(new HopperListener(), this); + this.getServer().getPluginManager().registerEvents(new VanillaFurnaceGuardListener(), this); + this.getServer().getPluginManager().registerEvents(new RecipeIndexListener(), this); + this.getServer().getPluginManager().registerEvents(new ComparatorListener(), this); + this.getServer().getPluginManager().registerEvents(new BlockBreakListener(), this); + try { + Class.forName("org.bukkit.event.block.BlockExplodeEvent", false, this.getClassLoader()); + this.getServer().getPluginManager().registerEvents(new BlockExplodeListener(), this); + } catch (ClassNotFoundException ignored) { + // BlockExplodeEvent was added after the 1.7 API. + } + this.getServer().getPluginManager().registerEvents(new ChunkListener(), this); + + FurnaceEventCompat.initialize(); + RecipeCompat.rebuild(); + + // Load configs and scan loaded chunks for existing furnaces + for (World world : Bukkit.getWorlds()) { + loadWorldConfig(world); + for (Chunk chunk : world.getLoadedChunks()) { + for (BlockState tile : chunk.getTileEntities()) { + if (tile instanceof Furnace) { + FurnaceManager.getOrCreateContext(tile.getBlock()); + } + } + } + } + + // Schedule tick loop + Bukkit.getScheduler().runTaskTimer(this, FurnaceManager::tickAll, 1L, 1L); + + // Register command + if (this.getCommand("fuream") != null) { + this.getCommand("fuream").setExecutor(this); + this.getCommand("fuream").setTabCompleter(this); + } + + this.getLogger().info("Fuream (Spigot) enabled successfully!"); + } + + @Override + public void onDisable() { + FurnaceManager.shutdown(); + this.getLogger().info("Fuream (Spigot) disabled successfully!"); + } + + @EventHandler + public void onWorldLoad(WorldLoadEvent event) { + loadWorldConfig(event.getWorld()); + } + + @EventHandler + public void onWorldSave(WorldSaveEvent event) { + FurnaceManager.flushWorld(event.getWorld()); + } + + @EventHandler(priority = org.bukkit.event.EventPriority.MONITOR, ignoreCancelled = true) + public void onWorldUnload(WorldUnloadEvent event) { + FurnaceManager.unloadWorld(event.getWorld()); + WORLD_CONFIGS.remove(event.getWorld()); + WORLD_CONFIG_SOURCES.remove(event.getWorld()); + WORLD_CONFIG_ERRORS.remove(event.getWorld().getUID()); + } + + public static void loadWorldConfig(@NotNull World world) { + FureamWorldConfigImpl config = new FureamWorldConfigImpl(); + File targetFile = findExistingConfig(world.getWorldFolder()); + boolean dimension = world.getEnvironment() == World.Environment.NETHER || + world.getEnvironment() == World.Environment.THE_END; + File mainWorldFolder = getMainWorldFolder(); + if (targetFile == null && dimension) { + targetFile = findExistingConfig(mainWorldFolder); + } + if (targetFile == null && !dimension) { + targetFile = new File(world.getWorldFolder(), "fuream.json"); + try { + FureamWorldConfigImpl.writeWorldConfig(config, targetFile); + } catch (Exception e) { + recordConfigFailure(world, targetFile, e); + return; + } + } + + if (targetFile != null && targetFile.exists()) { + try { + FureamWorldConfigImpl.readWorldConfig(config, targetFile); + } catch (Exception e) { + recordConfigFailure(world, targetFile, e); + return; + } + } + WORLD_CONFIGS.put(world, config); + WORLD_CONFIG_ERRORS.remove(world.getUID()); + if (targetFile == null) { + WORLD_CONFIG_SOURCES.remove(world); + } else { + WORLD_CONFIG_SOURCES.put(world, targetFile.getAbsoluteFile()); + } + } + + private static void recordConfigFailure(World world, File source, Exception failure) { + String message = "Failed to load " + source + " for world " + world.getName() + ": " + failure.getMessage(); + WORLD_CONFIG_ERRORS.put(world.getUID(), message); + Bukkit.getLogger().warning(message + "; retaining the last-known-good configuration"); + } + + private static @Nullable File findExistingConfig(@NotNull File worldFolder) { + File direct = new File(worldFolder, "fuream.json"); + if (direct.isFile()) return direct; + File serverConfig = new File(new File(worldFolder, "serverconfig"), "fuream.json"); + if (serverConfig.isFile()) return serverConfig; + File json5 = new File(new File(worldFolder, "serverconfig"), "fuream.json5"); + return json5.isFile() ? json5 : null; + } + + private static @NotNull File getMainWorldFolder() { + File container = Bukkit.getWorldContainer(); + Properties properties = new Properties(); + File propertiesFile = new File("server.properties"); + try (FileInputStream input = new FileInputStream(propertiesFile)) { + properties.load(input); + } catch (Exception ignored) { + } + String levelName = properties.getProperty("level-name", "world").trim(); + if (levelName.isEmpty()) levelName = "world"; + return new File(container, levelName); + } + + public static @Nullable FureamWorldConfig getWorldConfig(@Nullable World world) { + if (world == null) return null; + FureamWorldConfig config = WORLD_CONFIGS.get(world); + if (config == null) { + loadWorldConfig(world); + config = WORLD_CONFIGS.get(world); + } + return config; + } + + @Contract(value = "null -> null", pure = true) + public static @Nullable FurnaceType getFurnaceType(@Nullable Object obj) { + if (obj instanceof FurnaceType) { + return (FurnaceType) obj; + } + if (obj instanceof String) { + try { + return FurnaceType.valueOf(((String) obj).toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ignored) {} + return null; + } + if (obj instanceof Block) { + Material mat = ((Block) obj).getType(); + String materialName = mat.name(); + if ("FURNACE".equals(materialName) || "BURNING_FURNACE".equals(materialName)) { + return FurnaceType.FURNACE; + } + if ("SMOKER".equals(materialName)) return FurnaceType.SMOKER; + if ("BLAST_FURNACE".equals(materialName)) return FurnaceType.BLAST_FURNACE; + return null; + } + if (obj instanceof BlockState) { + return getFurnaceType(((BlockState) obj).getBlock()); + } + return null; + } + + @Override + public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { + if (args.length > 0 && "reload".equalsIgnoreCase(args[0])) { + if (!sender.hasPermission("fuream.reload")) { + sender.sendMessage("§cYou do not have permission to use this command."); + return true; + } + for (World w : Bukkit.getWorlds()) { + loadWorldConfig(w); + } + RecipeCompat.rebuild(); + FurnaceManager.reconcileConfiguration(); + boolean failed = false; + for (World world : Bukkit.getWorlds()) { + String error = WORLD_CONFIG_ERRORS.get(world.getUID()); + if (error != null) { + failed = true; + sender.sendMessage("§c[Fuream] " + error); + } + } + sender.sendMessage(failed + ? "§e[Fuream] Reload completed with errors; previous configs remain active." + : "§a[Fuream] World configurations reloaded!"); + return true; + } + + if (args.length > 0 && "status".equalsIgnoreCase(args[0])) { + if (!sender.hasPermission("fuream.status")) { + sender.sendMessage("§cYou do not have permission to use this command."); + return true; + } + sender.sendMessage("§e=== Fuream Status ==="); + sender.sendMessage("§6Version adapter: §f" + VersionAdapters.current().id() + + " §7(" + ServerVersion.CURRENT + ")"); + sender.sendMessage("§6Root NBT hook: §f" + (this.rootNbtReady ? "ready" : "failed")); + long nbtFailures = FurnaceRootNbtBridge.failureCount(); + sender.sendMessage("§6Root NBT runtime failures: §f" + nbtFailures); + if (nbtFailures > 0) sender.sendMessage("§6Last NBT failure: §f" + FurnaceRootNbtBridge.lastFailure()); + sender.sendMessage("§6Indexed cooking recipes: §f" + RecipeCompat.indexedRecipeCount()); + for (World w : Bukkit.getWorlds()) { + FureamWorldConfig cfg = getWorldConfig(w); + if (cfg != null) { + File source = WORLD_CONFIG_SOURCES.get(w); + sender.sendMessage("§6World: §f" + w.getName() + " §7(Enabled: " + + cfg.getEnabledFurnaceTypes() + ", Source: " + + (source == null ? "in-memory defaults" : source.getPath()) + ")"); + } + String configError = WORLD_CONFIG_ERRORS.get(w.getUID()); + if (configError != null) sender.sendMessage("§cConfig error: §f" + configError); + } + return true; + } + + sender.sendMessage("§6/fuream reload §7- Reload all world configs"); + sender.sendMessage("§6/fuream status §7- View status of all worlds"); + return true; + } + + @Override + public List onTabComplete( + @NotNull CommandSender sender, @NotNull Command command, + @NotNull String alias, @NotNull String[] args + ) { + if (args.length != 1) return java.util.Collections.emptyList(); + String prefix = args[0].toLowerCase(Locale.ROOT); + List result = new ArrayList<>(); + if (sender.hasPermission("fuream.reload") && "reload".startsWith(prefix)) result.add("reload"); + if (sender.hasPermission("fuream.status") && "status".startsWith(prefix)) result.add("status"); + return result; + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/FureamWorldConfigImpl.java b/spigot/src/main/java/io/github/eat_ram/fuream/FureamWorldConfigImpl.java new file mode 100644 index 0000000..2571418 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/FureamWorldConfigImpl.java @@ -0,0 +1,948 @@ +package io.github.eat_ram.fuream; + +import java.io.FileNotFoundException; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.Map; + +import com.google.gson.JsonElement; +import com.google.gson.JsonIOException; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonSyntaxException; +import io.github.eat_ram.fuream.api.FureamWorldConfig; +import io.github.eat_ram.fuream.api.FurnaceType; +import java.io.File; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; + +public class FureamWorldConfigImpl implements FureamWorldConfig { + public static void writeWorldConfig( + @NotNull FureamWorldConfig config, @NotNull File configFile + ) throws java.io.IOException { + if (configFile.getParentFile() != null) { + configFile.getParentFile().mkdirs(); + } + JsonObject root = readExistingObject(configFile); + + putMap(root, "input_slot_count", config.getInputSlotCount()); + putMap(root, "fuel_slot_count", config.getFuelSlotCount()); + putMap(root, "output_slot_count", config.getOutputSlotCount()); + + com.google.gson.JsonArray enabledTypes = new com.google.gson.JsonArray(); + for (FurnaceType type : config.getEnabledFurnaceTypes()) { + enabledTypes.add(type.name()); + } + root.add("enabled_furnace_types", enabledTypes); + + com.google.gson.JsonArray preventsHopper = new com.google.gson.JsonArray(); + for (FurnaceType type : config.getPreventsHopperInsertNonSmeltable()) { + preventsHopper.add(type.name()); + } + root.add("prevents_hopper_insert_non_smeltable", preventsHopper); + + putMap(root, "gui_border_item_id", config.getGuiBorderItemId()); + putMap(root, "gui_fuel_left_item_id", config.getGuiFuelLeftItemId()); + putMap(root, "gui_fuel_used_item_id", config.getGuiFuelUsedItemId()); + putMap(root, "gui_progress_done_item_id", config.getGuiProgressDoneItemId()); + putMap(root, "gui_progress_remaining_item_id", config.getGuiProgressRemainingItemId()); + putMap(root, "gui_prev_recipe_item_id", config.getGuiPrevRecipeItemId()); + putMap(root, "gui_next_recipe_item_id", config.getGuiNextRecipeItemId()); + putMap(root, "gui_xp_indicator_item_id", config.getGuiXpIndicatorItemId()); + putMap(root, "gui_next_functional_area_item_id", config.getGuiNextFunctionalAreaItemId()); + putMap(root, "gui_prev_page_item_id", config.getGuiPrevPageItemId()); + putMap(root, "gui_next_page_item_id", config.getGuiNextPageItemId()); + + putMap(root, "gui_title", config.getGuiTitle()); + putMap(root, "gui_border_item_title", config.getGuiBorderItemTitle()); + putMap(root, "gui_fuel_item_title", config.getGuiFuelItemTitle()); + putMap(root, "gui_fuel_item_tooltip", config.getGuiFuelItemTooltip()); + putMap(root, "gui_progress_item_title", config.getGuiProgressItemTitle()); + putMap(root, "gui_progress_item_tooltip", config.getGuiProgressItemTooltip()); + putMap(root, "gui_prev_recipe_item_title", config.getGuiPrevRecipeItemTitle()); + putMap(root, "gui_prev_recipe_empty_item_tooltip", config.getGuiPrevRecipeEmptyItemTooltip()); + putMap(root, "gui_prev_recipe_arbitrary_item_tooltip", config.getGuiPrevRecipeArbitraryItemTooltip()); + putMap(root, "gui_prev_recipe_item_tooltip", config.getGuiPrevRecipeItemTooltip()); + putMap(root, "gui_next_recipe_item_title", config.getGuiNextRecipeItemTitle()); + putMap(root, "gui_next_recipe_empty_item_tooltip", config.getGuiNextRecipeEmptyItemTooltip()); + putMap(root, "gui_next_recipe_arbitrary_item_tooltip", config.getGuiNextRecipeArbitraryItemTooltip()); + putMap(root, "gui_next_recipe_item_tooltip", config.getGuiNextRecipeItemTooltip()); + putMap(root, "gui_xp_indicator_item_title", config.getGuiXpIndicatorItemTitle()); + putMap(root, "gui_xp_indicator_gainable_item_title", config.getGuiXpIndicatorGainableItemTitle()); + putMap(root, "gui_xp_indicator_item_tooltip", config.getGuiXpIndicatorItemTooltip()); + putMap(root, "gui_next_functional_area_item_title", config.getGuiNextFunctionalAreaItemTitle()); + putMap(root, "gui_prev_page_item_title", config.getGuiPrevPageItemTitle()); + putMap(root, "gui_prev_page_item_tooltip", config.getGuiPrevPageItemTooltip()); + putMap(root, "gui_next_page_item_title", config.getGuiNextPageItemTitle()); + putMap(root, "gui_next_page_item_tooltip", config.getGuiNextPageItemTooltip()); + + com.google.gson.Gson gson = new com.google.gson.GsonBuilder().setPrettyPrinting().create(); + Path target = configFile.toPath().toAbsolutePath(); + Path parent = target.getParent(); + if (parent == null) throw new java.io.IOException("Config has no parent directory: " + target); + Path temporary = Files.createTempFile(parent, configFile.getName(), ".tmp"); + try { + try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter( + new FileOutputStream(temporary.toFile()), StandardCharsets.UTF_8 + ))) { + gson.toJson(root, writer); + } + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(temporary); + } + } + + private static JsonObject readExistingObject(File file) throws java.io.IOException { + if (!file.isFile()) return new JsonObject(); + try (BufferedReader reader = utf8Reader(file)) { + JsonElement element = new JsonParser().parse(reader); + if (!element.isJsonObject()) { + throw new JsonSyntaxException("Config root must be a JSON object: " + file); + } + return element.getAsJsonObject(); + } + } + + private static BufferedReader utf8Reader(File file) throws FileNotFoundException { + return new BufferedReader(new InputStreamReader( + new FileInputStream(file), StandardCharsets.UTF_8 + )); + } + + private static void putMap(JsonObject root, String key, Map values) { + JsonObject object = new JsonObject(); + for (Map.Entry entry : values.entrySet()) { + Object value = entry.getValue(); + if (value instanceof Number) object.addProperty(entry.getKey().name(), (Number) value); + else if (value instanceof Boolean) object.addProperty(entry.getKey().name(), (Boolean) value); + else object.addProperty(entry.getKey().name(), String.valueOf(value)); + } + root.add(key, object); + } + + private static void readStringMap( + JsonObject root, String key, EnumMap values + ) { + if (!root.has(key)) return; + JsonElement element = root.get(key); + if (element.isJsonPrimitive()) { + String value = element.getAsString(); + for (FurnaceType type : FurnaceType.values()) values.put(type, value); + return; + } + if (!element.isJsonObject()) return; + for (Map.Entry entry : element.getAsJsonObject().entrySet()) { + if (!entry.getValue().isJsonPrimitive()) continue; + try { + values.put(FurnaceType.valueOf(entry.getKey()), entry.getValue().getAsString()); + } catch (IllegalArgumentException ignored) { + } + } + } + + public static void readWorldConfig( + @NotNull FureamWorldConfig config, @NotNull File configFile + ) throws FileNotFoundException, JsonIOException, JsonSyntaxException { + final JsonElement json; + try (BufferedReader reader = utf8Reader(configFile)) { + json = new JsonParser().parse(reader); + } catch (FileNotFoundException e) { + throw e; + } catch (java.io.IOException e) { + throw new JsonIOException(e); + } + if (!json.isJsonObject()) throw new JsonSyntaxException("Config root must be a JSON object"); + JsonObject root = json.getAsJsonObject(); + + readIntMap(root, "input_slot_count", config.getInputSlotCount()); + readIntMap(root, "fuel_slot_count", config.getFuelSlotCount()); + readIntMap(root, "output_slot_count", config.getOutputSlotCount()); + readTypeSet(root, "enabled_furnace_types", config.getEnabledFurnaceTypes()); + readTypeSet(root, "prevents_hopper_insert_non_smeltable", config.getPreventsHopperInsertNonSmeltable()); + + readStringMaps(root, + field("gui_border_item_id", config.getGuiBorderItemId()), + field("gui_fuel_left_item_id", config.getGuiFuelLeftItemId()), + field("gui_fuel_used_item_id", config.getGuiFuelUsedItemId()), + field("gui_progress_done_item_id", config.getGuiProgressDoneItemId()), + field("gui_progress_remaining_item_id", config.getGuiProgressRemainingItemId()), + field("gui_prev_recipe_item_id", config.getGuiPrevRecipeItemId()), + field("gui_next_recipe_item_id", config.getGuiNextRecipeItemId()), + field("gui_xp_indicator_item_id", config.getGuiXpIndicatorItemId()), + field("gui_next_functional_area_item_id", config.getGuiNextFunctionalAreaItemId()), + field("gui_prev_page_item_id", config.getGuiPrevPageItemId()), + field("gui_next_page_item_id", config.getGuiNextPageItemId()), + field("gui_title", config.getGuiTitle()), + field("gui_border_item_title", config.getGuiBorderItemTitle()), + field("gui_fuel_item_title", config.getGuiFuelItemTitle()), + field("gui_fuel_item_tooltip", config.getGuiFuelItemTooltip()), + field("gui_progress_item_title", config.getGuiProgressItemTitle()), + field("gui_progress_item_tooltip", config.getGuiProgressItemTooltip()), + field("gui_prev_recipe_item_title", config.getGuiPrevRecipeItemTitle()), + field("gui_prev_recipe_empty_item_tooltip", config.getGuiPrevRecipeEmptyItemTooltip()), + field("gui_prev_recipe_arbitrary_item_tooltip", config.getGuiPrevRecipeArbitraryItemTooltip()), + field("gui_prev_recipe_item_tooltip", config.getGuiPrevRecipeItemTooltip()), + field("gui_next_recipe_item_title", config.getGuiNextRecipeItemTitle()), + field("gui_next_recipe_empty_item_tooltip", config.getGuiNextRecipeEmptyItemTooltip()), + field("gui_next_recipe_arbitrary_item_tooltip", config.getGuiNextRecipeArbitraryItemTooltip()), + field("gui_next_recipe_item_tooltip", config.getGuiNextRecipeItemTooltip()), + field("gui_xp_indicator_item_title", config.getGuiXpIndicatorItemTitle()), + field("gui_xp_indicator_gainable_item_title", config.getGuiXpIndicatorGainableItemTitle()), + field("gui_xp_indicator_item_tooltip", config.getGuiXpIndicatorItemTooltip()), + field("gui_next_functional_area_item_title", config.getGuiNextFunctionalAreaItemTitle()), + field("gui_prev_page_item_title", config.getGuiPrevPageItemTitle()), + field("gui_prev_page_item_tooltip", config.getGuiPrevPageItemTooltip()), + field("gui_next_page_item_title", config.getGuiNextPageItemTitle()), + field("gui_next_page_item_tooltip", config.getGuiNextPageItemTooltip()) + ); + } + + private static void readIntMap( + JsonObject root, String key, EnumMap target + ) { + if (!root.has(key)) return; + JsonElement element = root.get(key); + if (!element.isJsonObject()) throw new JsonSyntaxException(key + " must be an object"); + for (Map.Entry entry : element.getAsJsonObject().entrySet()) { + try { + FurnaceType type = FurnaceType.valueOf(entry.getKey()); + int count = entry.getValue().getAsInt(); + if (count < 1 || count > 576) { + throw new JsonSyntaxException(key + '.' + entry.getKey() + " must be in 1..576"); + } + target.put(type, count); + } catch (IllegalArgumentException ignored) { + if (isFurnaceType(entry.getKey())) throw ignored; + } + } + } + + private static void readTypeSet(JsonObject root, String key, EnumSet target) { + if (!root.has(key)) return; + JsonElement element = root.get(key); + if (!element.isJsonArray()) throw new JsonSyntaxException(key + " must be an array"); + target.clear(); + for (JsonElement value : element.getAsJsonArray()) { + if (!value.isJsonPrimitive()) continue; + try { + target.add(FurnaceType.valueOf(value.getAsString())); + } catch (IllegalArgumentException ignored) { + } + } + } + + private static boolean isFurnaceType(String value) { + for (FurnaceType type : FurnaceType.values()) if (type.name().equals(value)) return true; + return false; + } + + private static StringMapField field(String key, EnumMap target) { + return new StringMapField(key, target); + } + + private static void readStringMaps(JsonObject root, StringMapField... fields) { + for (StringMapField field : fields) readStringMap(root, field.key, field.target); + } + + private static final class StringMapField { + private final String key; + private final EnumMap target; + + private StringMapField(String key, EnumMap target) { + this.key = key; + this.target = target; + } + } + + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull Integer> + inputSlotCount = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull Integer> + fuelSlotCount = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull Integer> + outputSlotCount = new EnumMap<>(FurnaceType.class); + private @NotNull EnumSet preventsHopperInsertNonSmeltable = + EnumSet.noneOf(FurnaceType.class); + private @NotNull EnumSet enabledFurnaceTypes = + EnumSet.noneOf(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiBorderItemId = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiFuelLeftItemId = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiFuelUsedItemId = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiProgressDoneItemId = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiProgressRemainingItemId = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiPrevRecipeItemId = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiNextRecipeItemId = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiXpIndicatorItemId = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiNextFunctionalAreaItemId = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiPrevPageItemId = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiNextPageItemId = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiTitle = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiBorderItemTitle = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiFuelItemTitle = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiFuelItemTooltip = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiProgressItemTitle = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiProgressItemTooltip = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiPrevRecipeItemTitle = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiPrevRecipeEmptyItemTooltip = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiPrevRecipeArbitraryItemTooltip = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiPrevRecipeItemTooltip = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiNextRecipeItemTitle = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiNextRecipeEmptyItemTooltip = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiNextRecipeArbitraryItemTooltip = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiNextRecipeItemTooltip = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiXpIndicatorItemTitle = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiXpIndicatorGainableItemTitle = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiXpIndicatorItemTooltip = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiNextFunctionalAreaItemTitle = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiPrevPageItemTitle = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiPrevPageItemTooltip = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiNextPageItemTitle = new EnumMap<>(FurnaceType.class); + private @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + guiNextPageItemTooltip = new EnumMap<>(FurnaceType.class); + + public FureamWorldConfigImpl() { + for (FurnaceType type : FurnaceType.values()) { + this.inputSlotCount.put(type, 9); + this.fuelSlotCount.put(type, 9); + this.outputSlotCount.put(type, 9); + this.guiBorderItemId.put( + type, "minecraft:black_stained_glass_pane" + ); + this.guiFuelLeftItemId.put( + type, "minecraft:orange_stained_glass_pane" + ); + this.guiFuelUsedItemId.put( + type, "minecraft:black_stained_glass_pane" + ); + this.guiProgressDoneItemId.put( + type, "minecraft:white_stained_glass_pane" + ); + this.guiProgressRemainingItemId.put( + type, "minecraft:black_stained_glass_pane" + ); + this.guiPrevRecipeItemId + .put(type, "minecraft:book"); + this.guiNextRecipeItemId + .put(type, "minecraft:book"); + this.guiXpIndicatorItemId.put( + type, "minecraft:lime_stained_glass_pane" + ); + this.guiNextFunctionalAreaItemId + .put(type, "minecraft:command_block"); + this.guiPrevPageItemId + .put(type, "minecraft:arrow"); + this.guiNextPageItemId + .put(type, "minecraft:arrow"); + this.guiTitle.put(type, type == FurnaceType.SMOKER ? "Smoker" : + (type == FurnaceType.BLAST_FURNACE ? "Blast Furnace" : "Furnace")); + this.guiBorderItemTitle.put(type, "GUI Border"); + this.guiFuelItemTitle.put(type, "Fuel"); + this.guiFuelItemTooltip.put(type, "Available: %d / %d"); + this.guiProgressItemTitle.put(type, "Progress"); + this.guiProgressItemTooltip.put(type, "%d / %d"); + this.guiPrevRecipeItemTitle.put(type, "Previous Recipe"); + this.guiPrevRecipeEmptyItemTooltip.put( + type, + "Please insert an item into the slot on the left to adjust" + ); + this.guiPrevRecipeArbitraryItemTooltip + .put(type, "Current: arbitrary of %d"); + this.guiPrevRecipeItemTooltip.put(type, "Current: %d / %d"); + this.guiNextRecipeItemTitle.put(type, "Next Recipe"); + this.guiNextRecipeEmptyItemTooltip.put( + type, + "Please insert an item into the slot on the left to adjust" + ); + this.guiNextRecipeArbitraryItemTooltip + .put(type, "Current: arbitrary of %d"); + this.guiNextRecipeItemTooltip.put(type, "Current: %d / %d"); + this.guiXpIndicatorItemTitle.put(type, "XP"); + this.guiXpIndicatorGainableItemTitle + .put(type, "XP (click to gain)"); + this.guiXpIndicatorItemTooltip.put(type, "Current: %.1f"); + this.guiNextFunctionalAreaItemTitle + .put(type, "Next Functional Area"); + this.guiPrevPageItemTitle.put(type, "Previous Page"); + this.guiPrevPageItemTooltip.put(type, "Page %d"); + this.guiNextPageItemTitle.put(type, "Next Page"); + this.guiNextPageItemTooltip.put(type, "Page %d"); + } + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull Integer> + getInputSlotCount() { + return this.inputSlotCount; + } + + @Override + public void setInputSlotCount( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull Integer> map + ) { + this.inputSlotCount = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull Integer> + getFuelSlotCount() { + return this.fuelSlotCount; + } + + @Override + public void setFuelSlotCount( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull Integer> map + ) { + this.fuelSlotCount = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull Integer> + getOutputSlotCount() { + return this.outputSlotCount; + } + + @Override + public void setOutputSlotCount( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull Integer> map + ) { + this.outputSlotCount = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumSet + getPreventsHopperInsertNonSmeltable() { + return this.preventsHopperInsertNonSmeltable; + } + + @Override + public void setPreventsHopperInsertNonSmeltable( + @NotNull EnumSet types + ) { + this.preventsHopperInsertNonSmeltable = types; + } + + @Override + @Contract(pure = true) + public @NotNull EnumSet getEnabledFurnaceTypes() { + return this.enabledFurnaceTypes; + } + + @Override + public void + setEnabledFurnaceTypes(@NotNull EnumSet types) { + this.enabledFurnaceTypes = types; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiBorderItemId() { + return this.guiBorderItemId; + } + + @Override + public void setGuiBorderItemId( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiBorderItemId = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiFuelLeftItemId() { + return this.guiFuelLeftItemId; + } + + @Override + public void setGuiFuelLeftItemId( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiFuelLeftItemId = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiFuelUsedItemId() { + return this.guiFuelUsedItemId; + } + + @Override + public void setGuiFuelUsedItemId( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiFuelUsedItemId = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiProgressDoneItemId() { + return this.guiProgressDoneItemId; + } + + @Override + public void setGuiProgressDoneItemId( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiProgressDoneItemId = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiProgressRemainingItemId() { + return this.guiProgressRemainingItemId; + } + + @Override + public void setGuiProgressRemainingItemId( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiProgressRemainingItemId = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiPrevRecipeItemId() { + return this.guiPrevRecipeItemId; + } + + @Override + public void setGuiPrevRecipeItemId( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiPrevRecipeItemId = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiNextRecipeItemId() { + return this.guiNextRecipeItemId; + } + + @Override + public void setGuiNextRecipeItemId( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiNextRecipeItemId = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiXpIndicatorItemId() { + return this.guiXpIndicatorItemId; + } + + @Override + public void setGuiXpIndicatorItemId( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiXpIndicatorItemId = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiNextFunctionalAreaItemId() { + return this.guiNextFunctionalAreaItemId; + } + + @Override + public void setGuiNextFunctionalAreaItemId( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiNextFunctionalAreaItemId = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiPrevPageItemId() { + return this.guiPrevPageItemId; + } + + @Override + public void setGuiPrevPageItemId( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiPrevPageItemId = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiNextPageItemId() { + return this.guiNextPageItemId; + } + + @Override + public void setGuiNextPageItemId( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiNextPageItemId = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiTitle() { + return this.guiTitle; + } + + @Override + public void setGuiTitle( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiTitle = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiBorderItemTitle() { + return this.guiBorderItemTitle; + } + + @Override + public void setGuiBorderItemTitle( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiBorderItemTitle = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiFuelItemTitle() { + return this.guiFuelItemTitle; + } + + @Override + public void setGuiFuelItemTitle( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiFuelItemTitle = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiFuelItemTooltip() { + return this.guiFuelItemTooltip; + } + + @Override + public void setGuiFuelItemTooltip( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiFuelItemTooltip = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiProgressItemTitle() { + return this.guiProgressItemTitle; + } + + @Override + public void setGuiProgressItemTitle( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiProgressItemTitle = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiProgressItemTooltip() { + return this.guiProgressItemTooltip; + } + + @Override + public void setGuiProgressItemTooltip( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiProgressItemTooltip = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiPrevRecipeItemTitle() { + return this.guiPrevRecipeItemTitle; + } + + @Override + public void setGuiPrevRecipeItemTitle( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiPrevRecipeItemTitle = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiPrevRecipeEmptyItemTooltip() { + return this.guiPrevRecipeEmptyItemTooltip; + } + + @Override + public void setGuiPrevRecipeEmptyItemTooltip( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiPrevRecipeEmptyItemTooltip = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiPrevRecipeArbitraryItemTooltip() { + return this.guiPrevRecipeArbitraryItemTooltip; + } + + @Override + public void setGuiPrevRecipeArbitraryItemTooltip( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiPrevRecipeArbitraryItemTooltip = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiPrevRecipeItemTooltip() { + return this.guiPrevRecipeItemTooltip; + } + + @Override + public void setGuiPrevRecipeItemTooltip( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiPrevRecipeItemTooltip = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiNextRecipeItemTitle() { + return this.guiNextRecipeItemTitle; + } + + @Override + public void setGuiNextRecipeItemTitle( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiNextRecipeItemTitle = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiNextRecipeEmptyItemTooltip() { + return this.guiNextRecipeEmptyItemTooltip; + } + + @Override + public void setGuiNextRecipeEmptyItemTooltip( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiNextRecipeEmptyItemTooltip = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiNextRecipeArbitraryItemTooltip() { + return this.guiNextRecipeArbitraryItemTooltip; + } + + @Override + public void setGuiNextRecipeArbitraryItemTooltip( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiNextRecipeArbitraryItemTooltip = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiNextRecipeItemTooltip() { + return this.guiNextRecipeItemTooltip; + } + + @Override + public void setGuiNextRecipeItemTooltip( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiNextRecipeItemTooltip = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiXpIndicatorItemTitle() { + return this.guiXpIndicatorItemTitle; + } + + @Override + public void setGuiXpIndicatorItemTitle( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiXpIndicatorItemTitle = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiXpIndicatorGainableItemTitle() { + return this.guiXpIndicatorGainableItemTitle; + } + + @Override + public void setGuiXpIndicatorGainableItemTitle( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiXpIndicatorGainableItemTitle = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiXpIndicatorItemTooltip() { + return this.guiXpIndicatorItemTooltip; + } + + @Override + public void setGuiXpIndicatorItemTooltip( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiXpIndicatorItemTooltip = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiNextFunctionalAreaItemTitle() { + return this.guiNextFunctionalAreaItemTitle; + } + + @Override + public void setGuiNextFunctionalAreaItemTitle( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiNextFunctionalAreaItemTitle = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiPrevPageItemTitle() { + return this.guiPrevPageItemTitle; + } + + @Override + public void setGuiPrevPageItemTitle( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiPrevPageItemTitle = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiPrevPageItemTooltip() { + return this.guiPrevPageItemTooltip; + } + + @Override + public void setGuiPrevPageItemTooltip( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiPrevPageItemTooltip = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiNextPageItemTitle() { + return this.guiNextPageItemTitle; + } + + @Override + public void setGuiNextPageItemTitle( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiNextPageItemTitle = map; + } + + @Override + @Contract(pure = true) + public @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> + getGuiNextPageItemTooltip() { + return this.guiNextPageItemTooltip; + } + + @Override + public void setGuiNextPageItemTooltip( + @NotNull EnumMap<@NotNull FurnaceType, @NotNull String> map + ) { + this.guiNextPageItemTooltip = map; + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/ResolvedFurnaceConfig.java b/spigot/src/main/java/io/github/eat_ram/fuream/ResolvedFurnaceConfig.java new file mode 100644 index 0000000..65fd3dc --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/ResolvedFurnaceConfig.java @@ -0,0 +1,40 @@ +package io.github.eat_ram.fuream; + +import io.github.eat_ram.fuream.api.FureamWorldConfig; +import io.github.eat_ram.fuream.api.FurnaceType; +import org.jetbrains.annotations.NotNull; + +/** Validated, immutable values consumed by furnace and inventory hot paths. */ +public final class ResolvedFurnaceConfig { + public static final int MAX_LANE_SLOTS = 576; + + public final int inputSlots; + public final int fuelSlots; + public final int outputSlots; + public final boolean preventNonSmeltableHopperInput; + + private ResolvedFurnaceConfig( + int inputSlots, int fuelSlots, int outputSlots, + boolean preventNonSmeltableHopperInput + ) { + this.inputSlots = inputSlots; + this.fuelSlots = fuelSlots; + this.outputSlots = outputSlots; + this.preventNonSmeltableHopperInput = preventNonSmeltableHopperInput; + } + + public static @NotNull ResolvedFurnaceConfig resolve( + @NotNull FureamWorldConfig config, @NotNull FurnaceType type + ) { + return new ResolvedFurnaceConfig( + clamp(config.getInputSlotCount().get(type)), + clamp(config.getFuelSlotCount().get(type)), + clamp(config.getOutputSlotCount().get(type)), + config.getPreventsHopperInsertNonSmeltable().contains(type) + ); + } + + private static int clamp(Integer value) { + return Math.max(1, Math.min(MAX_LANE_SLOTS, value == null ? 9 : value)); + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/compat/BlockCompat.java b/spigot/src/main/java/io/github/eat_ram/fuream/compat/BlockCompat.java new file mode 100644 index 0000000..79cd069 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/compat/BlockCompat.java @@ -0,0 +1,105 @@ +package io.github.eat_ram.fuream.compat; + +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.bukkit.Material; +import org.bukkit.block.Block; + +public final class BlockCompat { + private static final Map, ModernAccessors> MODERN = new ConcurrentHashMap<>(); + private static final Set> NO_MODERN = Collections.newSetFromMap( + new ConcurrentHashMap, Boolean>() + ); + private static final Map, LegacyAccessors> LEGACY = new ConcurrentHashMap<>(); + + public static void setLit(Block block, boolean lit) { + try { + ModernAccessors accessors = modern(block); + if (accessors != null) { + Object data = accessors.getBlockData.invoke(block); + Method setLit = accessors.setLit(data.getClass()); + setLit.invoke(data, lit); + accessors.setBlockData.invoke(block, data, false); + return; + } + } catch (ReflectiveOperationException | RuntimeException ignored) { + } + + if (!"FURNACE".equals(block.getType().name()) && !"BURNING_FURNACE".equals(block.getType().name())) { + return; + } + Material target = Material.matchMaterial(lit ? "BURNING_FURNACE" : "FURNACE"); + if (target == null || target == block.getType()) return; + try { + LegacyAccessors accessors = legacy(block.getClass()); + byte data = ((Number) accessors.getData.invoke(block)).byteValue(); + int id = ((Number) accessors.getMaterialId.invoke(target)).intValue(); + accessors.setTypeAndData.invoke(block, id, data, false); + } catch (ReflectiveOperationException ignored) { + } + } + + private static ModernAccessors modern(Block block) { + Class type = block.getClass(); + ModernAccessors cached = MODERN.get(type); + if (cached != null) return cached; + if (NO_MODERN.contains(type)) return null; + try { + ModernAccessors created = new ModernAccessors(type); + ModernAccessors existing = MODERN.putIfAbsent(type, created); + return existing == null ? created : existing; + } catch (ReflectiveOperationException failure) { + NO_MODERN.add(type); + return null; + } + } + + private static LegacyAccessors legacy(Class type) throws ReflectiveOperationException { + LegacyAccessors cached = LEGACY.get(type); + if (cached != null) return cached; + LegacyAccessors created = new LegacyAccessors(type); + LegacyAccessors existing = LEGACY.putIfAbsent(type, created); + return existing == null ? created : existing; + } + + private static final class ModernAccessors { + private final Method getBlockData; + private final Method setBlockData; + private final Map, Method> setLit = new ConcurrentHashMap<>(); + + private ModernAccessors(Class blockType) throws ReflectiveOperationException { + this.getBlockData = blockType.getMethod("getBlockData"); + Class blockDataType = this.getBlockData.getReturnType(); + this.setBlockData = blockType.getMethod("setBlockData", blockDataType, boolean.class); + } + + private Method setLit(Class dataType) throws ReflectiveOperationException { + Method cached = this.setLit.get(dataType); + if (cached != null) return cached; + Method created = dataType.getMethod("setLit", boolean.class); + Method existing = this.setLit.putIfAbsent(dataType, created); + return existing == null ? created : existing; + } + } + + private static final class LegacyAccessors { + private final Method getData; + private final Method getMaterialId; + private final Method setTypeAndData; + + private LegacyAccessors(Class blockType) throws ReflectiveOperationException { + this.getData = blockType.getMethod("getData"); + this.getMaterialId = Material.class.getMethod("getId"); + this.setTypeAndData = blockType.getMethod( + "setTypeIdAndData", int.class, byte.class, boolean.class + ); + } + } + + private BlockCompat() { + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/compat/ComparatorCompat.java b/spigot/src/main/java/io/github/eat_ram/fuream/compat/ComparatorCompat.java new file mode 100644 index 0000000..c9a0bea --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/compat/ComparatorCompat.java @@ -0,0 +1,70 @@ +package io.github.eat_ram.fuream.compat; + +import java.lang.reflect.Method; +import java.util.List; + +import io.github.eat_ram.fuream.data.FureamFurnaceData; +import io.github.eat_ram.fuream.logic.FuelTable; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.inventory.ItemStack; + +/** Maintains the comparator view exposed by Fabric's three-slot masked inventory. */ +public final class ComparatorCompat { + public static int calculate(FureamFurnaceData data) { + ItemStack input = first(data.inputs, false); + ItemStack fuel = first(data.fuels, true); + ItemStack output = first(data.outputs, false); + ItemStack[] masked = {input, fuel, output}; + float fullness = 0f; + int occupied = 0; + for (ItemStack stack : masked) { + if (ItemCompat.isEmpty(stack)) continue; + fullness += Math.min(1f, (float) stack.getAmount() / Math.max(1, stack.getMaxStackSize())); + occupied++; + } + return occupied == 0 ? 0 : (int) Math.floor(fullness / 3f * 14f) + 1; + } + + public static void requestRecalculation(Block furnace) { + // Bukkit has no version-stable comparator notification API. Re-applying + // the current snapshot with physics is the least invasive public hook. + furnace.getState().update(false, true); + } + + public static Block sourceBlock(Block comparator) { + try { + BlockFace facing = BlockFace.valueOf(facingName(comparator)); + return comparator.getRelative(facing.getOppositeFace()); + } catch (IllegalArgumentException ignored) { + return null; + } + } + + private static ItemStack first(List stacks, boolean fuelOnly) { + for (ItemStack stack : stacks) { + if (!ItemCompat.isEmpty(stack) && (!fuelOnly || FuelTable.isFuel(stack))) return stack; + } + return ItemCompat.empty(); + } + + private static String facingName(Block comparator) { + try { + Object data = comparator.getClass().getMethod("getBlockData").invoke(comparator); + Method method = data.getClass().getMethod("getFacing"); + method.setAccessible(true); + Object facing = method.invoke(data); + return facing instanceof Enum ? ((Enum) facing).name() : String.valueOf(facing); + } catch (ReflectiveOperationException ignored) { + try { + int data = ((Number) comparator.getClass().getMethod("getData").invoke(comparator)).intValue() & 3; + return new String[] {"NORTH", "EAST", "SOUTH", "WEST"}[data]; + } catch (ReflectiveOperationException e) { + return ""; + } + } + } + + private ComparatorCompat() { + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/compat/FurnaceCompat.java b/spigot/src/main/java/io/github/eat_ram/fuream/compat/FurnaceCompat.java new file mode 100644 index 0000000..ad4db9f --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/compat/FurnaceCompat.java @@ -0,0 +1,118 @@ +package io.github.eat_ram.fuream.compat; + +import java.lang.reflect.Method; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.bukkit.block.Furnace; + +public final class FurnaceCompat { + private static final Map, Accessors> ACCESSORS = new ConcurrentHashMap<>(); + + public static int getBurnTime(Furnace furnace) { + Accessors accessors = accessors(furnace); + return accessors.getInt(furnace, accessors.getBurnTime, 0); + } + + public static int getCookTime(Furnace furnace) { + Accessors accessors = accessors(furnace); + return accessors.getInt(furnace, accessors.getCookTime, 0); + } + + public static int getCookTimeTotal(Furnace furnace, int fallback) { + Accessors accessors = accessors(furnace); + return accessors.getInt(furnace, accessors.getCookTimeTotal, fallback); + } + + public static void setCookTimeTotal(Furnace furnace, int value) { + Accessors accessors = accessors(furnace); + accessors.setInt(furnace, accessors.setCookTimeTotal, value); + } + + public static void setCookTime(Furnace furnace, int value) { + Accessors accessors = accessors(furnace); + accessors.setInt(furnace, accessors.setCookTime, value); + } + + public static void setBurnTime(Furnace furnace, int value) { + Accessors accessors = accessors(furnace); + accessors.setInt(furnace, accessors.setBurnTime, value); + } + + public static void initialize(Furnace furnace) { + accessors(furnace); + } + + private static Accessors accessors(Furnace furnace) { + Class type = furnace.getClass(); + Accessors accessors = ACCESSORS.get(type); + if (accessors != null) return accessors; + Accessors created = new Accessors(type); + Accessors existing = ACCESSORS.putIfAbsent(type, created); + return existing == null ? created : existing; + } + + private static final class Accessors { + private final Method getBurnTime; + private final Method getCookTime; + private final Method getCookTimeTotal; + private final Method setBurnTime; + private final Method setCookTime; + private final Method setCookTimeTotal; + + private Accessors(Class type) { + this.getBurnTime = findGetter(type, "getBurnTime"); + this.getCookTime = findGetter(type, "getCookTime"); + this.getCookTimeTotal = findGetter(type, "getCookTimeTotal"); + this.setBurnTime = findSetter(type, "setBurnTime"); + this.setCookTime = findSetter(type, "setCookTime"); + this.setCookTimeTotal = findSetter(type, "setCookTimeTotal"); + } + + private int getInt(Object target, Method method, int fallback) { + if (method == null) return fallback; + try { + Object value = method.invoke(target); + return value instanceof Number ? ((Number) value).intValue() : fallback; + } catch (ReflectiveOperationException ignored) { + return fallback; + } + } + + private void setInt(Object target, Method method, int value) { + if (method == null) return; + Class type = method.getParameterTypes()[0]; + try { + Object argument; + if (type == short.class || type == Short.class) { + argument = Short.valueOf((short) value); + } else { + argument = Integer.valueOf(value); + } + method.invoke(target, argument); + } catch (ReflectiveOperationException ignored) { + } + } + + private static Method findGetter(Class type, String name) { + try { + return type.getMethod(name); + } catch (ReflectiveOperationException ignored) { + return null; + } + } + + private static Method findSetter(Class type, String name) { + for (Method method : type.getMethods()) { + if (!name.equals(method.getName()) || method.getParameterTypes().length != 1) continue; + Class parameter = method.getParameterTypes()[0]; + if (parameter == short.class || parameter == Short.class || + parameter == int.class || parameter == Integer.class) return method; + } + return null; + } + } + + private FurnaceCompat() { + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/compat/FurnaceEventCompat.java b/spigot/src/main/java/io/github/eat_ram/fuream/compat/FurnaceEventCompat.java new file mode 100644 index 0000000..9c2b500 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/compat/FurnaceEventCompat.java @@ -0,0 +1,93 @@ +package io.github.eat_ram.fuream.compat; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.entity.ExperienceOrb; +import org.bukkit.entity.Player; +import org.bukkit.event.Cancellable; +import org.bukkit.event.Event; +import org.bukkit.inventory.ItemStack; + +/** Optional Bukkit furnace events whose availability differs across legacy APIs. */ +public final class FurnaceEventCompat { + private static Constructor extractConstructor; + private static Method extractExperience; + private static Constructor startConstructor; + private static Method startCookTime; + + public static void initialize() { + initializeExtractEvent(); + initializeStartEvent(); + } + + public static void fireExtract(Player player, Block furnace, Material itemType, int amount) { + if (amount <= 0 || ItemCompat.isAir(itemType) || extractConstructor == null) return; + try { + Event event = (Event) extractConstructor.newInstance(player, furnace, itemType, amount, 0); + Bukkit.getPluginManager().callEvent(event); + int experience = ((Number) extractExperience.invoke(event)).intValue(); + if (experience > 0) { + ExperienceOrb orb = player.getWorld().spawn(player.getLocation(), ExperienceOrb.class); + orb.setExperience(experience); + } + } catch (ReflectiveOperationException | LinkageError ignored) { + // FurnaceExtractEvent is absent on some of the oldest Bukkit APIs. + } + } + + public static int fireStartSmelt( + Block block, ItemStack input, Object recipe, int fallbackCookTime + ) { + if (startConstructor == null || recipe == null) return fallbackCookTime; + Class recipeType = startConstructor.getParameterTypes()[2]; + if (!recipeType.isInstance(recipe)) return fallbackCookTime; + try { + Event event = (Event) startConstructor.newInstance(block, input.clone(), recipe); + FurnaceEventDispatcher.call(event); + if (event instanceof Cancellable && ((Cancellable) event).isCancelled()) { + return Integer.MAX_VALUE; + } + return Math.max(1, ((Number) startCookTime.invoke(event)).intValue()); + } catch (ReflectiveOperationException | RuntimeException failure) { + return fallbackCookTime; + } + } + + private static void initializeExtractEvent() { + try { + Class eventClass = Class.forName("org.bukkit.event.inventory.FurnaceExtractEvent"); + extractConstructor = eventClass.getConstructor( + Player.class, Block.class, Material.class, int.class, int.class + ); + extractExperience = eventClass.getMethod("getExpToDrop"); + } catch (ReflectiveOperationException | LinkageError ignored) { + extractConstructor = null; + extractExperience = null; + } + } + + private static void initializeStartEvent() { + try { + Class eventClass = Class.forName("org.bukkit.event.inventory.FurnaceStartSmeltEvent"); + for (Constructor constructor : eventClass.getConstructors()) { + Class[] types = constructor.getParameterTypes(); + if (types.length == 3 && Block.class.isAssignableFrom(types[0]) && + ItemStack.class.isAssignableFrom(types[1])) { + startConstructor = constructor; + startCookTime = eventClass.getMethod("getTotalCookTime"); + return; + } + } + } catch (ReflectiveOperationException | LinkageError ignored) { + } + startConstructor = null; + startCookTime = null; + } + + private FurnaceEventCompat() { + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/compat/FurnaceEventDispatcher.java b/spigot/src/main/java/io/github/eat_ram/fuream/compat/FurnaceEventDispatcher.java new file mode 100644 index 0000000..4cfc430 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/compat/FurnaceEventDispatcher.java @@ -0,0 +1,33 @@ +package io.github.eat_ram.fuream.compat; + +import org.bukkit.Bukkit; +import org.bukkit.event.Event; +import org.jetbrains.annotations.NotNull; + +/** Distinguishes Fuream's synthetic furnace events from the vanilla furnace engine. */ +public final class FurnaceEventDispatcher { + private static final ThreadLocal DISPATCH_DEPTH = new ThreadLocal<>(); + + public static void call(@NotNull Event event) { + Integer current = DISPATCH_DEPTH.get(); + DISPATCH_DEPTH.set(current == null ? 1 : current + 1); + try { + Bukkit.getPluginManager().callEvent(event); + } finally { + int next = DISPATCH_DEPTH.get() - 1; + if (next == 0) { + DISPATCH_DEPTH.remove(); + } else { + DISPATCH_DEPTH.set(next); + } + } + } + + public static boolean isDispatching() { + Integer depth = DISPATCH_DEPTH.get(); + return depth != null && depth > 0; + } + + private FurnaceEventDispatcher() { + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/compat/FurnaceStateTransaction.java b/spigot/src/main/java/io/github/eat_ram/fuream/compat/FurnaceStateTransaction.java new file mode 100644 index 0000000..73636ad --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/compat/FurnaceStateTransaction.java @@ -0,0 +1,92 @@ +package io.github.eat_ram.fuream.compat; + +import java.lang.reflect.Method; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.bukkit.block.Block; +import org.bukkit.block.BlockState; +import org.bukkit.block.Furnace; +import org.bukkit.inventory.FurnaceInventory; +import org.jetbrains.annotations.NotNull; + +/** + * Owns one furnace block-state snapshot from read through commit. Inventory and + * timing fields must be changed through the same transaction. + */ +public final class FurnaceStateTransaction { + private static final Map, Method> SNAPSHOT_INVENTORY_METHODS = new ConcurrentHashMap<>(); + private static final Map, Boolean> NO_SNAPSHOT_INVENTORY = new ConcurrentHashMap<>(); + + private final Furnace state; + private final FurnaceInventory inventory; + private boolean changed; + + private FurnaceStateTransaction(@NotNull Furnace state) { + this.state = state; + FurnaceCompat.initialize(state); + this.inventory = snapshotInventory(state); + } + + public static FurnaceStateTransaction begin(@NotNull Block block) { + BlockState state = block.getState(); + return state instanceof Furnace ? new FurnaceStateTransaction((Furnace) state) : null; + } + + public @NotNull Furnace state() { + return this.state; + } + + public @NotNull FurnaceInventory inventory() { + return this.inventory; + } + + public void markChanged() { + this.changed = true; + } + + public void setTimes(int burnTime, int cookTime, int cookTimeTotal) { + if (FurnaceCompat.getBurnTime(this.state) != burnTime) { + FurnaceCompat.setBurnTime(this.state, burnTime); + this.changed = true; + } + if (FurnaceCompat.getCookTime(this.state) != cookTime) { + FurnaceCompat.setCookTime(this.state, cookTime); + this.changed = true; + } + if (cookTimeTotal > 0 && FurnaceCompat.getCookTimeTotal(this.state, cookTimeTotal) != cookTimeTotal) { + FurnaceCompat.setCookTimeTotal(this.state, cookTimeTotal); + this.changed = true; + } + } + + public boolean commit() { + return !this.changed || this.state.update(false, false); + } + + private static FurnaceInventory snapshotInventory(Furnace furnace) { + Class type = furnace.getClass(); + Method method = SNAPSHOT_INVENTORY_METHODS.get(type); + if (method == null && !NO_SNAPSHOT_INVENTORY.containsKey(type)) { + try { + method = type.getMethod("getSnapshotInventory"); + method.setAccessible(true); + SNAPSHOT_INVENTORY_METHODS.put(type, method); + } catch (ReflectiveOperationException | LinkageError ignored) { + NO_SNAPSHOT_INVENTORY.put(type, Boolean.TRUE); + } + } + if (method != null) { + try { + Object result = method.invoke(furnace); + if (result instanceof FurnaceInventory) { + return (FurnaceInventory) result; + } + } catch (ReflectiveOperationException ignored) { + NO_SNAPSHOT_INVENTORY.put(type, Boolean.TRUE); + SNAPSHOT_INVENTORY_METHODS.remove(type); + } + } + return furnace.getInventory(); + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/compat/ItemCompat.java b/spigot/src/main/java/io/github/eat_ram/fuream/compat/ItemCompat.java new file mode 100644 index 0000000..a5276d9 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/compat/ItemCompat.java @@ -0,0 +1,41 @@ +package io.github.eat_ram.fuream.compat; + +import java.util.Locale; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +public final class ItemCompat { + public static boolean isEmpty(ItemStack stack) { + return stack == null || stack.getAmount() <= 0 || isAir(stack.getType()); + } + + public static boolean isAir(Material material) { + if (material == null) return true; + String name = material.name(); + return "AIR".equals(name) || name.endsWith("_AIR"); + } + + public static ItemStack empty() { + return new ItemStack(Material.AIR); + } + + public static Material matchMaterial(String configured, Material fallback) { + if (configured == null || configured.trim().isEmpty()) return fallback; + String name = configured.trim(); + int colon = name.indexOf(':'); + if (colon >= 0) name = name.substring(colon + 1); + name = name.toUpperCase(Locale.ROOT); + Material result = Material.matchMaterial(name); + if (result != null) return result; + + // GUI aliases for the pre-flattening material registry. + if (name.endsWith("_STAINED_GLASS_PANE")) { + result = Material.matchMaterial("STAINED_GLASS_PANE"); + } + return result != null ? result : fallback; + } + + private ItemCompat() { + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/compat/RecipeCompat.java b/spigot/src/main/java/io/github/eat_ram/fuream/compat/RecipeCompat.java new file mode 100644 index 0000000..a703d0a --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/compat/RecipeCompat.java @@ -0,0 +1,31 @@ +package io.github.eat_ram.fuream.compat; + +import java.util.List; + +import io.github.eat_ram.fuream.api.FurnaceType; +import org.bukkit.inventory.ItemStack; + +public final class RecipeCompat { + public static RecipeHandle findFirst(ItemStack input, FurnaceType type) { + return RecipeIndex.findFirst(input, type); + } + + public static List findAll(ItemStack input, FurnaceType type) { + return RecipeIndex.findAll(input, type); + } + + public static RecipeHandle findById(ItemStack input, FurnaceType type, String id) { + return RecipeIndex.findById(input, type, id); + } + + public static void rebuild() { + RecipeIndex.rebuild(); + } + + public static int indexedRecipeCount() { + return RecipeIndex.size(); + } + + private RecipeCompat() { + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/compat/RecipeHandle.java b/spigot/src/main/java/io/github/eat_ram/fuream/compat/RecipeHandle.java new file mode 100644 index 0000000..485e306 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/compat/RecipeHandle.java @@ -0,0 +1,20 @@ +package io.github.eat_ram.fuream.compat; + +import org.bukkit.inventory.ItemStack; + +/** Version-neutral cooking recipe snapshot. */ +public final class RecipeHandle { + public final Object nativeRecipe; + public final String id; + public final ItemStack result; + public final int cookingTime; + public final float experience; + + public RecipeHandle(Object nativeRecipe, String id, ItemStack result, int cookingTime, float experience) { + this.nativeRecipe = nativeRecipe; + this.id = id; + this.result = result; + this.cookingTime = cookingTime; + this.experience = experience; + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/compat/RecipeIndex.java b/spigot/src/main/java/io/github/eat_ram/fuream/compat/RecipeIndex.java new file mode 100644 index 0000000..e17e25d --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/compat/RecipeIndex.java @@ -0,0 +1,202 @@ +package io.github.eat_ram.fuream.compat; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import io.github.eat_ram.fuream.api.FurnaceType; +import org.bukkit.Bukkit; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.Recipe; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** Immutable cooking recipe snapshot rebuilt only when registrations may have changed. */ +public final class RecipeIndex { + private interface Matcher { + boolean matches(ItemStack input); + } + + private static final class Entry { + private final RecipeHandle handle; + private final Matcher matcher; + + private Entry(RecipeHandle handle, Matcher matcher) { + this.handle = handle; + this.matcher = matcher; + } + } + + private static volatile RecipeIndex current = empty(); + + private final EnumMap> byType; + private final EnumMap>> byId; + private final int size; + + private RecipeIndex( + EnumMap> byType, + EnumMap>> byId, + int size + ) { + this.byType = byType; + this.byId = byId; + this.size = size; + } + + public static void rebuild() { + EnumMap> byType = new EnumMap<>(FurnaceType.class); + EnumMap>> byId = new EnumMap<>(FurnaceType.class); + for (FurnaceType type : FurnaceType.values()) { + byType.put(type, new ArrayList()); + byId.put(type, new HashMap>()); + } + + int count = 0; + Iterator iterator = Bukkit.recipeIterator(); + while (iterator.hasNext()) { + Recipe recipe = iterator.next(); + FurnaceType type = furnaceType(recipe); + if (type == null) continue; + Entry entry = snapshot(recipe, type); + if (entry == null) continue; + byType.get(type).add(entry); + List sameId = byId.get(type).get(entry.handle.id); + if (sameId == null) { + sameId = new ArrayList<>(); + byId.get(type).put(entry.handle.id, sameId); + } + sameId.add(entry); + count++; + } + + for (FurnaceType type : FurnaceType.values()) { + byType.put(type, Collections.unmodifiableList(byType.get(type))); + Map> immutableIds = new HashMap<>(); + for (Map.Entry> entry : byId.get(type).entrySet()) { + immutableIds.put(entry.getKey(), Collections.unmodifiableList(entry.getValue())); + } + byId.put(type, Collections.unmodifiableMap(immutableIds)); + } + current = new RecipeIndex(byType, byId, count); + } + + public static @Nullable RecipeHandle findFirst(ItemStack input, FurnaceType type) { + if (ItemCompat.isEmpty(input)) return null; + for (Entry entry : current.byType.get(type)) { + if (entry.matcher.matches(input)) return entry.handle; + } + return null; + } + + public static @NotNull List findAll(ItemStack input, FurnaceType type) { + List result = new ArrayList<>(); + if (ItemCompat.isEmpty(input)) return result; + for (Entry entry : current.byType.get(type)) { + if (entry.matcher.matches(input)) result.add(entry.handle); + } + result.sort(Comparator.comparing(handle -> handle.id)); + return result; + } + + public static @Nullable RecipeHandle findById(ItemStack input, FurnaceType type, String id) { + if (id == null || ItemCompat.isEmpty(input)) return null; + List entries = current.byId.get(type).get(id); + if (entries == null) return null; + for (Entry entry : entries) { + if (entry.matcher.matches(input)) return entry.handle; + } + return null; + } + + public static int size() { + return current.size; + } + + private static RecipeIndex empty() { + EnumMap> byType = new EnumMap<>(FurnaceType.class); + EnumMap>> byId = new EnumMap<>(FurnaceType.class); + for (FurnaceType type : FurnaceType.values()) { + byType.put(type, Collections.emptyList()); + byId.put(type, Collections.>emptyMap()); + } + return new RecipeIndex(byType, byId, 0); + } + + private static @Nullable FurnaceType furnaceType(Recipe recipe) { + String simpleName = recipe.getClass().getSimpleName(); + if (simpleName.endsWith("SmokingRecipe")) return FurnaceType.SMOKER; + if (simpleName.endsWith("BlastingRecipe")) return FurnaceType.BLAST_FURNACE; + return simpleName.endsWith("FurnaceRecipe") ? FurnaceType.FURNACE : null; + } + + private static @Nullable Entry snapshot(Recipe recipe, FurnaceType type) { + Matcher matcher = matcher(recipe); + if (matcher == null) return null; + ItemStack output = recipe.getResult() == null ? ItemCompat.empty() : recipe.getResult().clone(); + int cookingTime = number(recipe, "getCookingTime", type == FurnaceType.FURNACE ? 200 : 100).intValue(); + float experience = number(recipe, "getExperience", 0f).floatValue(); + String id = recipeId(recipe, type, output); + return new Entry(new RecipeHandle( + recipe, id, output, Math.max(1, cookingTime), experience + ), matcher); + } + + private static @Nullable Matcher matcher(Recipe recipe) { + try { + Method choiceGetter = recipe.getClass().getMethod("getInputChoice"); + Object choice = choiceGetter.invoke(recipe); + Method choicesGetter = choice.getClass().getMethod("getChoices"); + Object rawChoices = choicesGetter.invoke(choice); + if (rawChoices instanceof List) { + final List choices = new ArrayList<>((List) rawChoices); + return input -> matchesChoiceSnapshot(choices, input); + } + } catch (ReflectiveOperationException | LinkageError ignored) { + } + try { + Method inputGetter = recipe.getClass().getMethod("getInput"); + ItemStack value = (ItemStack) inputGetter.invoke(recipe); + if (value == null) return null; + final ItemStack expected = value.clone(); + return input -> expected.getType() == input.getType() && + (expected.getDurability() == Short.MAX_VALUE || expected.getDurability() == input.getDurability()); + } catch (ReflectiveOperationException | LinkageError ignored) { + return null; + } + } + + private static boolean matchesChoiceSnapshot(List choices, ItemStack input) { + for (Object choice : choices) { + if (choice instanceof org.bukkit.Material && choice == input.getType()) return true; + if (choice instanceof ItemStack && ((ItemStack) choice).isSimilar(input)) return true; + } + return false; + } + + private static Number number(Recipe recipe, String methodName, Number fallback) { + try { + Object value = recipe.getClass().getMethod(methodName).invoke(recipe); + return value instanceof Number ? (Number) value : fallback; + } catch (ReflectiveOperationException | LinkageError ignored) { + return fallback; + } + } + + private static String recipeId(Recipe recipe, FurnaceType type, ItemStack output) { + try { + Object key = recipe.getClass().getMethod("getKey").invoke(recipe); + if (key != null) return key.toString(); + } catch (ReflectiveOperationException | LinkageError ignored) { + } + return "legacy:" + type.name().toLowerCase(Locale.ROOT) + '/' + + output.getType().name().toLowerCase(Locale.ROOT) + '/' + output.getDurability(); + } + +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/compat/ServerVersion.java b/spigot/src/main/java/io/github/eat_ram/fuream/compat/ServerVersion.java new file mode 100644 index 0000000..b70d968 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/compat/ServerVersion.java @@ -0,0 +1,77 @@ +package io.github.eat_ram.fuream.compat; + +import org.bukkit.Bukkit; + +/** Runtime Minecraft/Bukkit version without linking version-specific API classes. */ +public final class ServerVersion implements Comparable { + public static final ServerVersion CURRENT = parse(currentVersionString()); + + public final int major; + public final int minor; + public final int patch; + + public ServerVersion(int major, int minor, int patch) { + this.major = major; + this.minor = minor; + this.patch = patch; + } + + public static ServerVersion parse(String value) { + int[] parts = new int[] {0, 0, 0}; + int part = 0; + int current = -1; + for (int i = 0; i < value.length() && part < parts.length; i++) { + char c = value.charAt(i); + if (c >= '0' && c <= '9') { + current = Math.max(0, current) * 10 + (c - '0'); + } else if (current >= 0) { + parts[part++] = current; + current = -1; + if (part > 0 && c != '.') break; + } + } + if (current >= 0 && part < parts.length) parts[part] = current; + return new ServerVersion(parts[0], parts[1], parts[2]); + } + + private static String currentVersionString() { + try { + String value = Bukkit.getBukkitVersion(); + return value == null ? "0.0" : value; + } catch (Throwable ignored) { + return "0.0"; + } + } + + public boolean atLeast(int major, int minor) { + return compareTo(new ServerVersion(major, minor, 0)) >= 0; + } + + public boolean atLeast(int major, int minor, int patch) { + return compareTo(new ServerVersion(major, minor, patch)) >= 0; + } + + public boolean atMost(int major, int minor) { + return compareTo(new ServerVersion(major, minor, Integer.MAX_VALUE)) <= 0; + } + + public String adapterFamily() { + if (atMost(1, 12)) return "legacy-numeric"; + if (!atLeast(1, 17)) return "flattening-obfuscated"; + if (!atLeast(1, 20, 5)) return "mojang-named"; + return "components"; + } + + @Override + public int compareTo(ServerVersion other) { + if (major != other.major) return major < other.major ? -1 : 1; + if (minor != other.minor) return minor < other.minor ? -1 : 1; + return Integer.compare(patch, other.patch); + } + + @Override + public String toString() { + return major + "." + minor + (patch == 0 ? "" : "." + patch); + } + +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/compat/VersionAdapter.java b/spigot/src/main/java/io/github/eat_ram/fuream/compat/VersionAdapter.java new file mode 100644 index 0000000..1993cd7 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/compat/VersionAdapter.java @@ -0,0 +1,20 @@ +package io.github.eat_ram.fuream.compat; + +import org.bukkit.block.Block; + +/** Version capabilities used by the Java 8 common core. */ +public interface VersionAdapter { + String id(); + + boolean supportsSpecialFurnaces(); + + boolean supportsRecipeOverrides(); + + boolean usesDataComponents(); + + FurnaceStateTransaction beginFurnaceTransaction(Block block); + + void setFurnaceLit(Block block, boolean lit); + + void requestComparatorRecalculation(Block furnace); +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/compat/VersionAdapters.java b/spigot/src/main/java/io/github/eat_ram/fuream/compat/VersionAdapters.java new file mode 100644 index 0000000..19899fd --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/compat/VersionAdapters.java @@ -0,0 +1,97 @@ +package io.github.eat_ram.fuream.compat; + +import org.bukkit.block.Block; + +/** Loads one of the four internal adapters without linking newer Bukkit API types. */ +public final class VersionAdapters { + private static final VersionAdapter CURRENT = select(ServerVersion.CURRENT); + + public static VersionAdapter current() { + return CURRENT; + } + + static VersionAdapter select(ServerVersion version) { + if (version.atMost(1, 12)) return new LegacyAdapter(); + if (!version.atLeast(1, 17)) return new FlatteningObfuscatedAdapter(); + if (!version.atLeast(1, 20, 5)) return new MojangNamedAdapter(); + return new ComponentsAdapter(); + } + + private abstract static class BaseAdapter implements VersionAdapter { + private final String id; + private final boolean specialFurnaces; + private final boolean recipeOverrides; + private final boolean dataComponents; + + private BaseAdapter( + String id, boolean specialFurnaces, boolean recipeOverrides, boolean dataComponents + ) { + this.id = id; + this.specialFurnaces = specialFurnaces; + this.recipeOverrides = recipeOverrides; + this.dataComponents = dataComponents; + } + + @Override + public final String id() { + return this.id; + } + + @Override + public final boolean supportsSpecialFurnaces() { + return this.specialFurnaces; + } + + @Override + public final boolean supportsRecipeOverrides() { + return this.recipeOverrides; + } + + @Override + public final boolean usesDataComponents() { + return this.dataComponents; + } + + @Override + public final FurnaceStateTransaction beginFurnaceTransaction(Block block) { + return FurnaceStateTransaction.begin(block); + } + + @Override + public final void setFurnaceLit(Block block, boolean lit) { + BlockCompat.setLit(block, lit); + } + + @Override + public final void requestComparatorRecalculation(Block furnace) { + ComparatorCompat.requestRecalculation(furnace); + } + } + + private static final class LegacyAdapter extends BaseAdapter { + private LegacyAdapter() { + super("legacy-numeric", false, false, false); + } + } + + private static final class FlatteningObfuscatedAdapter extends BaseAdapter { + private FlatteningObfuscatedAdapter() { + super("flattening-obfuscated", true, true, false); + } + } + + private static final class MojangNamedAdapter extends BaseAdapter { + private MojangNamedAdapter() { + super("mojang-named", true, true, false); + } + } + + private static final class ComponentsAdapter extends BaseAdapter { + private ComponentsAdapter() { + super("components", true, true, true); + } + } + + private VersionAdapters() { + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/data/FureamData.java b/spigot/src/main/java/io/github/eat_ram/fuream/data/FureamData.java new file mode 100644 index 0000000..fbfd2e0 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/data/FureamData.java @@ -0,0 +1,10 @@ +package io.github.eat_ram.fuream.data; + +import io.github.eat_ram.fuream.nbt.NativeNbtCompound; +import org.jetbrains.annotations.NotNull; + +public interface FureamData { + void writeNbt(FureamDataHolder holder, @NotNull NativeNbtCompound nbt); + + void readNbt(FureamDataHolder holder, @NotNull NativeNbtCompound nbt); +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/data/FureamDataHolder.java b/spigot/src/main/java/io/github/eat_ram/fuream/data/FureamDataHolder.java new file mode 100644 index 0000000..d2a88f1 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/data/FureamDataHolder.java @@ -0,0 +1,13 @@ +package io.github.eat_ram.fuream.data; + +import org.jetbrains.annotations.Nullable; + +/** + * Interface implemented by furnace data holders. Gives the rest of the plugin + * a type-safe way to reach the virtual smelting state of a furnace. + */ +@FunctionalInterface +public interface FureamDataHolder { + @Nullable T + getFureamData(String key, Class clazz); +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/data/FureamFurnaceData.java b/spigot/src/main/java/io/github/eat_ram/fuream/data/FureamFurnaceData.java new file mode 100644 index 0000000..21d280a --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/data/FureamFurnaceData.java @@ -0,0 +1,182 @@ +package io.github.eat_ram.fuream.data; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; + +import io.github.eat_ram.fuream.compat.ItemCompat; +import io.github.eat_ram.fuream.nbt.NativeItemNbt; +import io.github.eat_ram.fuream.nbt.NativeNbtCompound; +import io.github.eat_ram.fuream.nbt.NativeNbtList; +import io.github.eat_ram.fuream.util.KeyableItemStack; +import io.github.eat_ram.fuream.util.NbtUtil; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * The server-side smelting state of a virtual furnace: inputs, fuels, outputs, + * recipe overrides, and accumulated experience. + */ +public class FureamFurnaceData implements FureamData { + public static final @NotNull String INPUTS_KEY = "Inputs"; + public static final @NotNull String FUELS_KEY = "Fuels"; + public static final @NotNull String OUTPUTS_KEY = "Outputs"; + public static final @NotNull String RECIPE_OVERRIDING_INPUT_KEY = "RecipeOverridingInput"; + public static final @NotNull String OVERRIDDEN_RECIPES_KEY = "OverriddenRecipes"; + public static final @NotNull String RUNNING_RECIPE_KEY = "RunningRecipe"; + public static final @NotNull String XP_KEY = "Xp"; + + public final @NotNull ArrayList<@NotNull ItemStack> inputs; + public final @NotNull ArrayList<@NotNull ItemStack> fuels; + public final @NotNull ArrayList<@NotNull ItemStack> outputs; + public @NotNull ItemStack recipeOverridingInput; + public final @NotNull HashMap<@NotNull KeyableItemStack, @NotNull String> overriddenRecipes; + public @Nullable String runningRecipe; + public float experience; + + public FureamFurnaceData() { + this.inputs = new ArrayList<>(); + this.fuels = new ArrayList<>(); + this.outputs = new ArrayList<>(); + this.inputs.add(new ItemStack(Material.AIR)); + this.fuels.add(new ItemStack(Material.AIR)); + this.outputs.add(new ItemStack(Material.AIR)); + this.recipeOverridingInput = new ItemStack(Material.AIR); + this.overriddenRecipes = new HashMap<>(); + this.runningRecipe = null; + } + + public @NotNull FureamFurnaceData copy() { + FureamFurnaceData copy = new FureamFurnaceData(); + copy.inputs.clear(); + copyStacks(this.inputs, copy.inputs); + copy.fuels.clear(); + copyStacks(this.fuels, copy.fuels); + copy.outputs.clear(); + copyStacks(this.outputs, copy.outputs); + copy.recipeOverridingInput = ItemCompat.isEmpty(this.recipeOverridingInput) + ? ItemCompat.empty() : this.recipeOverridingInput.clone(); + for (Map.Entry entry : this.overriddenRecipes.entrySet()) { + copy.overriddenRecipes.put( + new KeyableItemStack(entry.getKey().stack.clone()), entry.getValue() + ); + } + copy.runningRecipe = this.runningRecipe; + copy.experience = this.experience; + return copy; + } + + private static void copyStacks( + Iterable source, ArrayList target + ) { + for (ItemStack stack : source) { + target.add(ItemCompat.isEmpty(stack) ? ItemCompat.empty() : stack.clone()); + } + } + + public boolean hasAny() { + if (this.experience > 0f) return true; + if (this.recipeOverridingInput != null && + !ItemCompat.isEmpty(this.recipeOverridingInput)) return true; + if (!this.overriddenRecipes.isEmpty() || this.runningRecipe != null) return true; + for (ItemStack s : this.inputs) { + if (!ItemCompat.isEmpty(s)) return true; + } + for (ItemStack s : this.fuels) { + if (!ItemCompat.isEmpty(s)) return true; + } + for (ItemStack s : this.outputs) { + if (!ItemCompat.isEmpty(s)) return true; + } + return false; + } + + @Override + public void writeNbt(FureamDataHolder holder, @NotNull NativeNbtCompound nbt) { + if (!this.hasAny()) return; + NbtUtil.writeStacks(nbt, INPUTS_KEY, this.inputs); + NbtUtil.writeStacks(nbt, FUELS_KEY, this.fuels); + NbtUtil.writeStacks(nbt, OUTPUTS_KEY, this.outputs); + + if (nbt.has(RECIPE_OVERRIDING_INPUT_KEY)) { + nbt.remove(RECIPE_OVERRIDING_INPUT_KEY); + } + NativeNbtCompound overridingInputTag = nbt.getOrCreateCompound(RECIPE_OVERRIDING_INPUT_KEY); + if (!ItemCompat.isEmpty(this.recipeOverridingInput)) { + NbtUtil.writeItemToCompound(overridingInputTag, this.recipeOverridingInput); + } + + NativeNbtList list = NbtUtil.resetCompoundList(nbt, OVERRIDDEN_RECIPES_KEY); + for (Map.Entry entry : this.overriddenRecipes.entrySet()) { + ItemStack stack = entry.getKey().stack; + if (!ItemCompat.isEmpty(stack)) { + NativeNbtCompound sub = NativeItemNbt.write(stack); + sub.setString("RecipeId", entry.getValue()); + list.add(sub); + } + } + + if (this.runningRecipe != null) { + nbt.setString(RUNNING_RECIPE_KEY, this.runningRecipe); + } else if (nbt.has(RUNNING_RECIPE_KEY)) { + nbt.remove(RUNNING_RECIPE_KEY); + } + nbt.setFloat(XP_KEY, this.experience); + if (nbt.has("FureamInitialized")) { + nbt.remove("FureamInitialized"); + } + } + + @Override + public void readNbt(FureamDataHolder holder, @NotNull NativeNbtCompound nbt) { + for (int i = 0; i < this.inputs.size(); i++) { + this.inputs.set(i, new ItemStack(Material.AIR)); + } + for (int i = 0; i < this.fuels.size(); i++) { + this.fuels.set(i, new ItemStack(Material.AIR)); + } + for (int i = 0; i < this.outputs.size(); i++) { + this.outputs.set(i, new ItemStack(Material.AIR)); + } + + if (nbt.has(INPUTS_KEY)) { + NbtUtil.readStacks(nbt, INPUTS_KEY, this.inputs); + } + if (nbt.has(FUELS_KEY)) { + NbtUtil.readStacks(nbt, FUELS_KEY, this.fuels); + } + if (nbt.has(OUTPUTS_KEY)) { + NbtUtil.readStacks(nbt, OUTPUTS_KEY, this.outputs); + } + if (nbt.has(RECIPE_OVERRIDING_INPUT_KEY)) { + this.recipeOverridingInput = NbtUtil.fromNbt(nbt.getCompound(RECIPE_OVERRIDING_INPUT_KEY)); + } else { + this.recipeOverridingInput = new ItemStack(Material.AIR); + } + this.overriddenRecipes.clear(); + if (nbt.has(OVERRIDDEN_RECIPES_KEY)) { + NativeNbtList list = nbt.getList(OVERRIDDEN_RECIPES_KEY); + if (list != null) { + for (int i = 0; i < list.size(); ++i) { + NativeNbtCompound tag = list.getCompound(i); + if (tag != null && tag.has("RecipeId")) { + String recipeIdStr = tag.getString("RecipeId"); + if (!recipeIdStr.isEmpty()) { + ItemStack stack = NbtUtil.fromNbt(tag); + if (!ItemCompat.isEmpty(stack)) { + stack.setAmount(1); + this.overriddenRecipes.put(new KeyableItemStack(stack), recipeIdStr); + } + } + } + } + } + } + this.runningRecipe = nbt.has(RUNNING_RECIPE_KEY) + ? nbt.getString(RUNNING_RECIPE_KEY) + : null; + this.experience = nbt.has(XP_KEY) ? nbt.getFloat(XP_KEY, 0f) : 0f; + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/data/FurnaceFureamDataRegistry.java b/spigot/src/main/java/io/github/eat_ram/fuream/data/FurnaceFureamDataRegistry.java new file mode 100644 index 0000000..265e6b4 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/data/FurnaceFureamDataRegistry.java @@ -0,0 +1,20 @@ +package io.github.eat_ram.fuream.data; + +import java.util.HashMap; +import java.util.function.Function; + +import org.jetbrains.annotations.NotNull; + +public final class FurnaceFureamDataRegistry { + public static final @NotNull HashMap<@NotNull String, @NotNull Function< + ? super FureamDataHolder, ? extends @NotNull FureamData + >> REGISTRY = new HashMap<>(); + + private FurnaceFureamDataRegistry() { + } + + static { + FurnaceFureamDataRegistry.REGISTRY + .put("fuream", holder -> new FureamFurnaceData()); + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/hook/BlockBreakListener.java b/spigot/src/main/java/io/github/eat_ram/fuream/hook/BlockBreakListener.java new file mode 100644 index 0000000..95f1757 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/hook/BlockBreakListener.java @@ -0,0 +1,72 @@ +package io.github.eat_ram.fuream.hook; + +import java.lang.reflect.Method; +import java.util.HashSet; +import java.util.Set; + +import io.github.eat_ram.fuream.FureamMain; +import io.github.eat_ram.fuream.api.FurnaceType; +import io.github.eat_ram.fuream.hook.FurnaceManager.FurnaceContext; +import io.github.eat_ram.fuream.data.FureamFurnaceData; +import io.github.eat_ram.fuream.logic.FureamFurnaceLogic; +import io.github.eat_ram.fuream.util.FurnacePos; +import org.bukkit.Location; +import org.bukkit.Bukkit; +import org.bukkit.block.Block; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockBurnEvent; +import org.bukkit.event.entity.EntityExplodeEvent; + +public class BlockBreakListener implements Listener { + private static final Set PENDING = new HashSet<>(); + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBlockBreak(BlockBreakEvent event) { + this.handleFurnaceBreak(event.getBlock(), dropsItems(event)); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBlockBurn(BlockBurnEvent event) { + this.handleFurnaceBreak(event.getBlock(), true); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onEntityExplode(EntityExplodeEvent event) { + boolean dropItems = event.getYield() > 0f; + for (Block b : event.blockList()) { + this.handleFurnaceBreak(b, dropItems); + } + } + + static void handleFurnaceBreak(Block block, boolean dropItems) { + FurnaceType type = FureamMain.getFurnaceType(block); + if (type == null) return; + + FurnacePos pos = new FurnacePos(block); + if (!PENDING.add(pos)) return; + FurnaceContext context = FurnaceManager.prepareForRemoval(block); + FureamFurnaceData snapshot = context == null ? null : context.data.copy(); + Location location = block.getLocation().clone(); + Bukkit.getScheduler().runTask(FureamMain.getInstance(), () -> { + try { + FurnaceType remaining = FureamMain.getFurnaceType(location.getBlock()); + if (remaining == type) return; + if (dropItems && snapshot != null) FureamFurnaceLogic.dropOnBreak(location, snapshot); + } finally { + PENDING.remove(pos); + } + }); + } + + private static boolean dropsItems(BlockBreakEvent event) { + try { + Method method = event.getClass().getMethod("isDropItems"); + return !Boolean.FALSE.equals(method.invoke(event)); + } catch (ReflectiveOperationException | LinkageError ignored) { + return true; + } + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/hook/BlockExplodeListener.java b/spigot/src/main/java/io/github/eat_ram/fuream/hook/BlockExplodeListener.java new file mode 100644 index 0000000..76a70ea --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/hook/BlockExplodeListener.java @@ -0,0 +1,18 @@ +package io.github.eat_ram.fuream.hook; + +import org.bukkit.block.Block; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockExplodeEvent; + +/** Kept out of the base listener because BlockExplodeEvent is absent on 1.7. */ +public final class BlockExplodeListener implements Listener { + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBlockExplode(BlockExplodeEvent event) { + boolean dropItems = event.getYield() > 0f; + for (Block block : event.blockList()) { + BlockBreakListener.handleFurnaceBreak(block, dropItems); + } + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/hook/ChunkListener.java b/spigot/src/main/java/io/github/eat_ram/fuream/hook/ChunkListener.java new file mode 100644 index 0000000..5a835c4 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/hook/ChunkListener.java @@ -0,0 +1,36 @@ +package io.github.eat_ram.fuream.hook; + +import org.bukkit.Chunk; +import org.bukkit.block.Block; +import org.bukkit.block.BlockState; +import org.bukkit.block.Furnace; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.world.ChunkLoadEvent; +import org.bukkit.event.world.ChunkUnloadEvent; + +public class ChunkListener implements Listener { + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onChunkLoad(ChunkLoadEvent event) { + for (BlockState tile : event.getChunk().getTileEntities()) { + if (tile instanceof Furnace) { + FurnaceManager.getOrCreateContext(tile.getBlock()); + } + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onChunkUnload(ChunkUnloadEvent event) { + FurnaceManager.unloadChunk(event.getChunk()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBlockPlace(BlockPlaceEvent event) { + Block block = event.getBlock(); + if (block.getState() instanceof Furnace) { + FurnaceManager.getOrCreateContext(block); + } + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/hook/ComparatorListener.java b/spigot/src/main/java/io/github/eat_ram/fuream/hook/ComparatorListener.java new file mode 100644 index 0000000..9301423 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/hook/ComparatorListener.java @@ -0,0 +1,29 @@ +package io.github.eat_ram.fuream.hook; + +import io.github.eat_ram.fuream.FureamMain; +import io.github.eat_ram.fuream.api.FureamWorldConfig; +import io.github.eat_ram.fuream.api.FurnaceType; +import io.github.eat_ram.fuream.compat.ComparatorCompat; +import io.github.eat_ram.fuream.util.FurnacePos; +import org.bukkit.block.Block; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockRedstoneEvent; + +/** Supplies comparator recalculations with the Fabric-compatible masked signal. */ +public final class ComparatorListener implements Listener { + @EventHandler(priority = EventPriority.LOWEST) + public void onRedstone(BlockRedstoneEvent event) { + if (!event.getBlock().getType().name().contains("COMPARATOR")) return; + Block source = ComparatorCompat.sourceBlock(event.getBlock()); + if (source == null) return; + FurnaceType type = FureamMain.getFurnaceType(source); + if (type == null) return; + FureamWorldConfig config = FureamMain.getWorldConfig(source.getWorld()); + if (config == null || !config.getEnabledFurnaceTypes().contains(type)) return; + FurnaceManager.FurnaceContext context = FurnaceManager.getContext(new FurnacePos(source)); + if (context == null) context = FurnaceManager.getOrCreateContext(source); + if (context != null) event.setNewCurrent(ComparatorCompat.calculate(context.data)); + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/hook/FurnaceManager.java b/spigot/src/main/java/io/github/eat_ram/fuream/hook/FurnaceManager.java new file mode 100644 index 0000000..829c35a --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/hook/FurnaceManager.java @@ -0,0 +1,755 @@ +package io.github.eat_ram.fuream.hook; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import io.github.eat_ram.fuream.FureamMain; +import io.github.eat_ram.fuream.ResolvedFurnaceConfig; +import io.github.eat_ram.fuream.api.FureamWorldConfig; +import io.github.eat_ram.fuream.api.FurnaceType; +import io.github.eat_ram.fuream.compat.ItemCompat; +import io.github.eat_ram.fuream.compat.FurnaceCompat; +import io.github.eat_ram.fuream.compat.FurnaceStateTransaction; +import io.github.eat_ram.fuream.compat.ComparatorCompat; +import io.github.eat_ram.fuream.compat.VersionAdapters; +import io.github.eat_ram.fuream.data.FureamData; +import io.github.eat_ram.fuream.data.FureamDataHolder; +import io.github.eat_ram.fuream.data.FureamFurnaceData; +import io.github.eat_ram.fuream.data.FurnaceFureamDataRegistry; +import io.github.eat_ram.fuream.logic.FureamFurnaceEngine; +import io.github.eat_ram.fuream.logic.NativeLaneProjection; +import io.github.eat_ram.fuream.logic.PassiveLaneProjection; +import io.github.eat_ram.fuream.logic.PassiveLaneProjection.SelectionMode; +import io.github.eat_ram.fuream.nbt.FurnaceRootNbtBridge; +import io.github.eat_ram.fuream.nbt.NativeNbtCompound; +import io.github.eat_ram.fuream.screen.FureamScreenInventory; +import io.github.eat_ram.fuream.util.FurnacePos; +import io.github.eat_ram.fuream.util.KeyableItemStack; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockState; +import org.bukkit.block.Furnace; +import org.bukkit.inventory.FurnaceInventory; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class FurnaceManager { + private static final int SAVE_INTERVAL_TICKS = 40; + private static final Map CONTEXTS = new HashMap<>(); + private static final String PDC_ROOT_KEY = "PublicBukkitValues"; + private static long tickCounter = 0; + + public static final class FurnaceContext implements FureamDataHolder { + public final @NotNull FurnacePos pos; + public final @NotNull FurnaceType type; + public final @NotNull FureamFurnaceData data; + public final @NotNull Map<@NotNull String, @NotNull FureamData> extraData = new HashMap<>(); + + public int burnTime; + public int fuelTimeTotal; + public int cookTime; + public int cookTimeTotal; + + public final @NotNull Map sessions = new HashMap<>(); + public boolean dirty; + public boolean passiveProjected; + public final @NotNull PassiveLaneProjection.State passiveInput = + new PassiveLaneProjection.State(); + public final @NotNull PassiveLaneProjection.State passiveFuel = + new PassiveLaneProjection.State(); + public final @NotNull PassiveLaneProjection.State passiveOutput = + new PassiveLaneProjection.State(); + public boolean activeProjected; + public final @NotNull NativeLaneProjection.State activeFuel = + new NativeLaneProjection.State(); + public final @NotNull NativeLaneProjection.State activeOutput = + new NativeLaneProjection.State(); + public int lastInputSlot = -1; + public @Nullable KeyableItemStack lastInputKey; + public boolean startSmeltPending = true; + public int lastComparatorSignal = -1; + public @Nullable Boolean lastLit; + private @Nullable FureamWorldConfig resolvedSource; + private @Nullable ResolvedFurnaceConfig resolvedConfig; + + public FurnaceContext(@NotNull FurnacePos pos, @NotNull FurnaceType type) { + this.pos = pos; + this.type = type; + this.data = new FureamFurnaceData(); + this.cookTimeTotal = (type == FurnaceType.FURNACE ? 200 : 100); + this.fuelTimeTotal = 200; + } + + public @Nullable World getWorld() { + World byId = this.pos.worldId == null ? null : Bukkit.getWorld(this.pos.worldId); + return byId == null ? Bukkit.getWorld(this.pos.worldName) : byId; + } + + public @NotNull ResolvedFurnaceConfig resolve(@NotNull FureamWorldConfig config) { + if (this.resolvedConfig == null || this.resolvedSource != config) { + this.resolvedSource = config; + this.resolvedConfig = ResolvedFurnaceConfig.resolve(config, this.type); + } + return this.resolvedConfig; + } + + @Override + public @Nullable T getFureamData(String key, Class clazz) { + if ("fuream".equals(key)) { + if (clazz.isInstance(this.data)) { + return clazz.cast(this.data); + } + } + FureamData extra = this.extraData.get(key); + if (clazz.isInstance(extra)) { + return clazz.cast(extra); + } + return null; + } + } + + public static @Nullable FurnaceContext getContext(@NotNull FurnacePos pos) { + return CONTEXTS.get(pos); + } + + public static @Nullable FurnaceContext getContext(@NotNull Block block) { + return CONTEXTS.get(new FurnacePos(block)); + } + + public static int contextCount() { + return CONTEXTS.size(); + } + + public static boolean isManagedActive(@NotNull Block block) { + FurnaceContext context = getContext(block); + return context != null && FureamMain.getFurnaceType(block) == context.type && + isEnabled(block.getWorld(), context.type); + } + + public static @Nullable FurnaceContext prepareForRemoval(@NotNull Block block) { + FurnaceContext context = getContext(block); + if (context == null || FureamMain.getFurnaceType(block) != context.type) return context; + FureamWorldConfig config = FureamMain.getWorldConfig(block.getWorld()); + if (config != null && !context.passiveProjected) { + prepareActiveTransaction(context, block, config); + } + closeSessions(context); + return context; + } + + public static @Nullable FurnaceContext prepareForInteraction(@NotNull FurnacePos pos) { + FurnaceContext context = getContext(pos); + if (context == null) return null; + World world = context.getWorld(); + if (world == null || !world.isChunkLoaded(pos.x >> 4, pos.z >> 4)) return null; + Block block = world.getBlockAt(pos.x, pos.y, pos.z); + FureamWorldConfig config = FureamMain.getWorldConfig(world); + if (config == null || FureamMain.getFurnaceType(block) != context.type) return null; + if (!context.passiveProjected) prepareActiveTransaction(context, block, config); + return context; + } + + public static void finishInteraction(@NotNull FurnaceContext context) { + World world = context.getWorld(); + if (world == null || !isEnabled(world, context.type) || + !world.isChunkLoaded(context.pos.x >> 4, context.pos.z >> 4)) return; + Block block = world.getBlockAt(context.pos.x, context.pos.y, context.pos.z); + FureamWorldConfig config = FureamMain.getWorldConfig(world); + if (config != null && FureamMain.getFurnaceType(block) == context.type) { + publishActiveTransaction(context, block, config); + } + } + + public static @Nullable FurnaceContext getOrCreateContext(@NotNull Block block) { + FurnaceType type = FureamMain.getFurnaceType(block); + if (type == null) return null; + + FurnacePos pos = new FurnacePos(block); + FurnaceContext existing = CONTEXTS.get(pos); + if (existing != null) { + if (existing.type == type) return existing; + closeSessions(existing); + CONTEXTS.remove(pos); + } + + FurnaceContext ctx = new FurnaceContext(pos, type); + for (Map.Entry> entry : + FurnaceFureamDataRegistry.REGISTRY.entrySet()) { + if (!"fuream".equals(entry.getKey())) { + ctx.extraData.put(entry.getKey(), entry.getValue().apply(ctx)); + } + } + + BlockState state = block.getState(); + if (state instanceof Furnace) { + try { + boolean nativeLoaded = FurnaceRootNbtBridge.loadNativeData(state, ctx); + NativeNbtCompound root = FurnaceRootNbtBridge.capturedRoot(state); + NativeNbtCompound legacyNbt = getLegacyPdc(root); + boolean hasLegacyData = hasFureamStorage(legacyNbt); + NativeNbtCompound dataCompound = nativeLoaded ? null : getDataCompound(legacyNbt); + boolean loaded = nativeLoaded; + if (dataCompound != null) { + ctx.data.readNbt(ctx, dataCompound); + for (Map.Entry entry : ctx.extraData.entrySet()) { + entry.getValue().readNbt(ctx, dataCompound); + } + ctx.passiveProjected = FurnaceRootNbtBridge.hasPassiveProjectionMarker( + dataCompound + ); + ctx.burnTime = legacyNbt.getInt("BurnTime", ctx.burnTime); + ctx.cookTime = legacyNbt.getInt("CookTime", ctx.cookTime); + ctx.cookTimeTotal = legacyNbt.getInt("CookTimeTotal", ctx.cookTimeTotal); + loaded = true; + } + + boolean enabled = isEnabled(block.getWorld(), type); + if (!loaded && !enabled) { + return null; + } + if (!loaded) { + migrateVanillaLane(ctx, (Furnace) state); + } else if (enabled) { + if (ctx.passiveProjected) { + resumeActive(ctx, block); + } else { + FureamWorldConfig config = FureamMain.getWorldConfig(block.getWorld()); + if (config != null) prepareActiveTransaction(ctx, block, config); + } + } else if (!ctx.passiveProjected) { + projectPassiveLane(ctx, block); + } else { + syncPassiveTimes(ctx, (Furnace) state); + } + if (hasLegacyData) { + removeLegacyPdc(legacyNbt); + ctx.dirty = true; + } + initializeInputIdentity(ctx); + } catch (Exception e) { + FureamMain.getInstance().getLogger().log(java.util.logging.Level.SEVERE, + "Unable to load furnace data at " + pos, e); + return null; + } + } + + CONTEXTS.put(pos, ctx); + if (ctx.dirty) { + saveToNbt(ctx); + } + return ctx; + } + + private static void initializeInputIdentity(@NotNull FurnaceContext ctx) { + int inputSlot = firstPresentSlot(ctx.data.inputs); + ctx.lastInputSlot = inputSlot; + if (inputSlot < 0) { + ctx.lastInputKey = null; + ctx.startSmeltPending = true; + return; + } + + ItemStack input = ctx.data.inputs.get(inputSlot).clone(); + input.setAmount(1); + ctx.lastInputKey = new KeyableItemStack(input); + ctx.startSmeltPending = ctx.data.runningRecipe == null || ctx.cookTime <= 0; + } + + public static void saveToNbt(@NotNull FurnaceContext ctx) { + saveToNbt(ctx, true); + } + + private static void saveToNbt(@NotNull FurnaceContext ctx, boolean republishAfter) { + World world = ctx.getWorld(); + if (world == null || !world.isChunkLoaded(ctx.pos.x >> 4, ctx.pos.z >> 4)) return; + + Block block = world.getBlockAt(ctx.pos.x, ctx.pos.y, ctx.pos.z); + if (FureamMain.getFurnaceType(block) != ctx.type) return; + boolean republish = false; + try { + FureamWorldConfig config = FureamMain.getWorldConfig(world); + if (ctx.passiveProjected) { + BlockState state = block.getState(); + if (state instanceof Furnace) syncPassiveTimes(ctx, (Furnace) state); + } else if (config != null) { + prepareActiveTransaction(ctx, block, config); + republish = republishAfter && isEnabled(world, ctx.type); + } + FurnaceRootNbtBridge.store(block.getState(), ctx); + ctx.dirty = false; + } catch (Exception e) { + FureamMain.getInstance().getLogger().log(java.util.logging.Level.SEVERE, + "Unable to save furnace data at " + ctx.pos, e); + } finally { + if (republish) { + FureamWorldConfig config = FureamMain.getWorldConfig(world); + if (config != null && FureamMain.getFurnaceType(block) == ctx.type) { + publishActiveTransaction(ctx, block, config); + } + } + } + } + + private static @Nullable ItemStack absorbLaneStack( + @NotNull List target, @Nullable ItemStack stack, int maxSlots + ) { + if (ItemCompat.isEmpty(stack)) { + return null; + } + ItemStack remainder = FureamFurnaceEngine.insertStackIntoList( + target, stack.clone(), Math.max(1, maxSlots) + ); + return ItemCompat.isEmpty(remainder) ? null : remainder; + } + + private static void migrateVanillaLane( + @NotNull FurnaceContext ctx, @NotNull Furnace furnaceState + ) { + FurnaceStateTransaction transaction = VersionAdapters.current().beginFurnaceTransaction(furnaceState.getBlock()); + if (transaction == null) return; + FurnaceInventory inv = transaction.inventory(); + ItemStack smelting = inv.getSmelting(); + ItemStack fuel = inv.getFuel(); + ItemStack result = inv.getResult(); + + if (!ItemCompat.isEmpty(smelting)) { + ctx.data.inputs.set(0, smelting.clone()); + } + if (!ItemCompat.isEmpty(fuel)) { + ctx.data.fuels.set(0, fuel.clone()); + } + if (!ItemCompat.isEmpty(result)) { + ctx.data.outputs.set(0, result.clone()); + } + + inv.setSmelting(null); + inv.setFuel(null); + inv.setResult(null); + transaction.markChanged(); + ctx.burnTime = FurnaceCompat.getBurnTime(furnaceState); + ctx.cookTime = FurnaceCompat.getCookTime(furnaceState); + int nativeTotal = FurnaceCompat.getCookTimeTotal( + furnaceState, ctx.type == FurnaceType.FURNACE ? 200 : 100 + ); + ctx.cookTimeTotal = nativeTotal > 0 + ? nativeTotal + : (ctx.type == FurnaceType.FURNACE ? 200 : 100); + transaction.setTimes(0, 0, ctx.cookTimeTotal); + requireCommit(transaction, ctx); + ctx.dirty = true; + } + + private static @Nullable NativeNbtCompound getDataCompound(@Nullable NativeNbtCompound nbt) { + if (nbt == null || !nbt.has(FureamMain.DATA_KEY)) { + return null; + } + return nbt.getCompound(FureamMain.DATA_KEY); + } + + private static @Nullable NativeNbtCompound getLegacyPdc(@Nullable NativeNbtCompound rootNbt) { + if (rootNbt == null || !rootNbt.has(PDC_ROOT_KEY)) { + return null; + } + return rootNbt.getCompound(PDC_ROOT_KEY); + } + + private static boolean hasFureamStorage(@Nullable NativeNbtCompound nbt) { + return nbt != null && ( + nbt.has(FureamMain.DATA_KEY) || + nbt.has("BurnTime") || + nbt.has("CookTime") || + nbt.has("CookTimeTotal") + ); + } + + private static void removeLegacyPdc(@Nullable NativeNbtCompound legacyNbt) { + if (legacyNbt == null) return; + legacyNbt.remove(FureamMain.DATA_KEY); + legacyNbt.remove("BurnTime"); + legacyNbt.remove("CookTime"); + legacyNbt.remove("CookTimeTotal"); + } + + private static boolean isEnabled(@Nullable World world, @NotNull FurnaceType type) { + FureamWorldConfig config = FureamMain.getWorldConfig(world); + return config != null && config.getEnabledFurnaceTypes().contains(type); + } + + private static void projectPassiveLane(@NotNull FurnaceContext ctx, @NotNull Block block) { + closeSessions(ctx); + FurnaceStateTransaction transaction = VersionAdapters.current().beginFurnaceTransaction(block); + if (transaction == null) return; + FurnaceInventory inventory = transaction.inventory(); + inventory.setSmelting(PassiveLaneProjection.begin( + ctx.data.inputs, inventory.getSmelting(), SelectionMode.FIRST_PRESENT, ctx.passiveInput + )); + inventory.setFuel(PassiveLaneProjection.begin( + ctx.data.fuels, inventory.getFuel(), SelectionMode.FUEL_THEN_PRESENT, ctx.passiveFuel + )); + inventory.setResult(PassiveLaneProjection.begin( + ctx.data.outputs, inventory.getResult(), SelectionMode.FIRST_PRESENT, ctx.passiveOutput + )); + transaction.markChanged(); + transaction.setTimes( + Math.max(0, ctx.burnTime), Math.max(0, ctx.cookTime), Math.max(1, ctx.cookTimeTotal) + ); + requireCommit(transaction, ctx); + ctx.passiveProjected = true; + ctx.dirty = true; + } + + private static int firstPresentSlot(@NotNull java.util.List stacks) { + for (int i = 0; i < stacks.size(); i++) { + if (!ItemCompat.isEmpty(stacks.get(i))) return i; + } + return -1; + } + + private static void resumeActive(@NotNull FurnaceContext ctx, @NotNull Block block) { + if (!ctx.passiveProjected) return; + FurnaceStateTransaction transaction = VersionAdapters.current().beginFurnaceTransaction(block); + if (transaction == null) return; + Furnace furnace = transaction.state(); + FurnaceInventory inventory = transaction.inventory(); + ctx.burnTime = FurnaceCompat.getBurnTime(furnace); + ctx.cookTime = FurnaceCompat.getCookTime(furnace); + ctx.cookTimeTotal = FurnaceCompat.getCookTimeTotal(furnace, ctx.cookTimeTotal); + inventory.setSmelting(PassiveLaneProjection.restore( + ctx.data.inputs, inventory.getSmelting(), ctx.passiveInput + )); + inventory.setFuel(PassiveLaneProjection.restore( + ctx.data.fuels, inventory.getFuel(), ctx.passiveFuel + )); + inventory.setResult(PassiveLaneProjection.restore( + ctx.data.outputs, inventory.getResult(), ctx.passiveOutput + )); + ctx.passiveInput.reset(); + ctx.passiveFuel.reset(); + ctx.passiveOutput.reset(); + transaction.markChanged(); + transaction.setTimes(0, 0, Math.max(1, ctx.cookTimeTotal)); + requireCommit(transaction, ctx); + ctx.passiveProjected = false; + ctx.dirty = true; + } + + private static void syncPassiveTimes(@NotNull FurnaceContext ctx, @NotNull Furnace furnace) { + ctx.burnTime = FurnaceCompat.getBurnTime(furnace); + ctx.cookTime = FurnaceCompat.getCookTime(furnace); + ctx.cookTimeTotal = FurnaceCompat.getCookTimeTotal(furnace, ctx.cookTimeTotal); + } + + /** Reconciles hopper/player changes and absorbs native input buffers before custom logic. */ + public static void prepareActiveTransaction( + @NotNull FurnaceContext ctx, @NotNull Block block, @NotNull FureamWorldConfig config + ) { + FurnaceStateTransaction transaction = VersionAdapters.current().beginFurnaceTransaction(block); + if (transaction == null) return; + FurnaceInventory inventory = transaction.inventory(); + ResolvedFurnaceConfig resolved = ctx.resolve(config); + int inputCount = resolved.inputSlots; + int fuelCount = resolved.fuelSlots; + int outputCount = resolved.outputSlots; + ensureSize(ctx.data.inputs, inputCount); + ensureSize(ctx.data.fuels, fuelCount); + ensureSize(ctx.data.outputs, outputCount); + + boolean changed = false; + boolean logicalChange = false; + if (ctx.activeProjected) { + ItemStack observedFuel = inventory.getFuel(); + ItemStack observedOutput = inventory.getResult(); + logicalChange = !sameStack(observedFuel, ctx.activeFuel.getPublished()) || + !sameStack(observedOutput, ctx.activeOutput.getPublished()); + ItemStack fuelRemainder = NativeLaneProjection.reconcile( + ctx.data.fuels, observedFuel, fuelCount, ctx.activeFuel + ); + ItemStack outputRemainder = NativeLaneProjection.reconcile( + ctx.data.outputs, observedOutput, outputCount, ctx.activeOutput + ); + inventory.setFuel(ItemCompat.isEmpty(fuelRemainder) ? null : fuelRemainder); + inventory.setResult(ItemCompat.isEmpty(outputRemainder) ? null : outputRemainder); + ctx.activeProjected = false; + changed = true; + } + + ItemStack nativeInput = inventory.getSmelting(); + if (!ItemCompat.isEmpty(nativeInput)) { + inventory.setSmelting(absorbLaneStack(ctx.data.inputs, nativeInput, inputCount)); + changed = true; + logicalChange = true; + } + ItemStack nativeFuel = inventory.getFuel(); + if (!ItemCompat.isEmpty(nativeFuel)) { + inventory.setFuel(absorbLaneStack(ctx.data.fuels, nativeFuel, fuelCount)); + changed = true; + logicalChange = true; + } + ItemStack nativeResult = inventory.getResult(); + if (!ItemCompat.isEmpty(nativeResult)) { + inventory.setResult(absorbLaneStack(ctx.data.outputs, nativeResult, outputCount)); + changed = true; + logicalChange = true; + } + + if (changed) { + transaction.markChanged(); + } + if (logicalChange) ctx.dirty = true; + transaction.setTimes(0, 0, Math.max(1, ctx.cookTimeTotal)); + requireCommit(transaction, ctx); + } + + /** Publishes extractable stacks to native slots so Bukkit handles hoppers and minecarts. */ + public static void publishActiveTransaction( + @NotNull FurnaceContext ctx, @NotNull Block block, @NotNull FureamWorldConfig config + ) { + FurnaceStateTransaction transaction = VersionAdapters.current().beginFurnaceTransaction(block); + if (transaction == null) return; + FurnaceInventory inventory = transaction.inventory(); + ResolvedFurnaceConfig resolved = ctx.resolve(config); + int fuelCount = resolved.fuelSlots; + int outputCount = resolved.outputSlots; + ensureSize(ctx.data.fuels, fuelCount); + ensureSize(ctx.data.outputs, outputCount); + + ItemStack fuel = NativeLaneProjection.publish( + ctx.data.fuels, inventory.getFuel(), + NativeLaneProjection.SelectionMode.BUCKET_REMAINDER, ctx.activeFuel + ); + ItemStack output = NativeLaneProjection.publish( + ctx.data.outputs, inventory.getResult(), + NativeLaneProjection.SelectionMode.FIRST_PRESENT, ctx.activeOutput + ); + inventory.setFuel(ItemCompat.isEmpty(fuel) ? null : fuel); + inventory.setResult(ItemCompat.isEmpty(output) ? null : output); + ctx.activeProjected = ctx.activeFuel.isActive() || ctx.activeOutput.isActive(); + if (ctx.activeProjected) { + transaction.markChanged(); + } + transaction.setTimes(0, 0, Math.max(1, ctx.cookTimeTotal)); + requireCommit(transaction, ctx); + FurnaceRootNbtBridge.updateProjectionMarker(block.getState(), ctx); + } + + private static void requireCommit( + @NotNull FurnaceStateTransaction transaction, @NotNull FurnaceContext ctx + ) { + if (!transaction.commit()) { + throw new IllegalStateException("Furnace state changed before commit at " + ctx.pos); + } + } + + private static void ensureSize(@NotNull List slots, int size) { + while (slots.size() < size) slots.add(ItemCompat.empty()); + } + + private static boolean sameStack(@Nullable ItemStack left, @Nullable ItemStack right) { + return ItemCompat.isEmpty(left) && ItemCompat.isEmpty(right) || + !ItemCompat.isEmpty(left) && !ItemCompat.isEmpty(right) && + left.getAmount() == right.getAmount() && left.isSimilar(right); + } + + public static void tickAll() { + List replacementFurnaces = new ArrayList<>(); + Iterator> it = CONTEXTS.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry entry = it.next(); + FurnaceContext ctx = entry.getValue(); + World world = ctx.getWorld(); + + if (world == null || !world.isChunkLoaded(ctx.pos.x >> 4, ctx.pos.z >> 4)) { + closeSessions(ctx); + if (ctx.dirty) { + saveToNbt(ctx); + } + it.remove(); + continue; + } + + Block block = world.getBlockAt(ctx.pos.x, ctx.pos.y, ctx.pos.z); + FurnaceType currentType = FureamMain.getFurnaceType(block); + if (currentType == null) { + closeSessions(ctx); + it.remove(); + continue; + } + if (currentType != ctx.type) { + closeSessions(ctx); + it.remove(); + replacementFurnaces.add(block); + continue; + } + + FureamWorldConfig config = FureamMain.getWorldConfig(world); + boolean enabled = config != null && config.getEnabledFurnaceTypes().contains(ctx.type); + validateSessions(ctx, block, enabled); + BlockState state = block.getState(); + if (enabled) { + if (ctx.passiveProjected && state instanceof Furnace) { + resumeActive(ctx, block); + } + prepareActiveTransaction(ctx, block, config); + FureamFurnaceEngine.tick(ctx, ctx.resolve(config)); + int comparatorSignal = ComparatorCompat.calculate(ctx.data); + if (comparatorSignal != ctx.lastComparatorSignal) { + VersionAdapters.current().requestComparatorRecalculation(block); + ctx.lastComparatorSignal = comparatorSignal; + } + boolean lit = ctx.burnTime > 0; + if (ctx.lastLit == null || ctx.lastLit != lit) { + VersionAdapters.current().setFurnaceLit(block, lit); + ctx.lastLit = lit; + } + publishActiveTransaction(ctx, block, config); + refreshSessions(ctx); + } else if (!ctx.passiveProjected && state instanceof Furnace) { + if (ctx.activeProjected && config != null) { + prepareActiveTransaction(ctx, block, config); + } + projectPassiveLane(ctx, block); + } else if (ctx.passiveProjected && state instanceof Furnace) { + syncPassiveTimes(ctx, (Furnace) state); + } + + if (ctx.dirty && tickCounter % SAVE_INTERVAL_TICKS == 0) { + saveToNbt(ctx); + } + } + for (Block block : replacementFurnaces) getOrCreateContext(block); + tickCounter++; + } + + public static void flushAll() { + for (FurnaceContext ctx : CONTEXTS.values()) { + if (ctx.dirty || ctx.data.hasAny()) { + saveToNbt(ctx); + } + } + } + + public static void flushWorld(@NotNull World world) { + for (FurnaceContext ctx : new ArrayList<>(CONTEXTS.values())) { + if (sameWorld(ctx.pos, world) && (ctx.dirty || ctx.data.hasAny())) saveToNbt(ctx); + } + } + + public static void unloadWorld(@NotNull World world) { + Iterator> iterator = CONTEXTS.entrySet().iterator(); + while (iterator.hasNext()) { + FurnaceContext ctx = iterator.next().getValue(); + if (!sameWorld(ctx.pos, world)) continue; + closeSessions(ctx); + if (ctx.dirty || ctx.data.hasAny()) saveToNbt(ctx, false); + iterator.remove(); + } + } + + public static void unloadChunk(@NotNull org.bukkit.Chunk chunk) { + Iterator> iterator = CONTEXTS.entrySet().iterator(); + while (iterator.hasNext()) { + FurnaceContext ctx = iterator.next().getValue(); + if (!sameWorld(ctx.pos, chunk.getWorld()) || + (ctx.pos.x >> 4) != chunk.getX() || (ctx.pos.z >> 4) != chunk.getZ()) continue; + closeSessions(ctx); + if (ctx.dirty || ctx.data.hasAny()) saveToNbt(ctx, false); + iterator.remove(); + } + } + + public static void refreshSessions(@NotNull FurnaceContext ctx) { + for (FureamScreenInventory session : ctx.sessions.values()) { + session.burnTime = ctx.burnTime; + session.fuelTimeTotal = Math.max(1, ctx.fuelTimeTotal); + session.cookTime = ctx.cookTime; + session.cookTimeTotal = Math.max(1, ctx.cookTimeTotal); + session.refreshVisuals(); + } + } + + public static void reconcileConfiguration() { + for (FurnaceContext ctx : CONTEXTS.values()) { + World world = ctx.getWorld(); + if (world == null || !world.isChunkLoaded(ctx.pos.x >> 4, ctx.pos.z >> 4)) continue; + + Block block = world.getBlockAt(ctx.pos.x, ctx.pos.y, ctx.pos.z); + BlockState state = block.getState(); + if (!(state instanceof Furnace) || FureamMain.getFurnaceType(block) != ctx.type) { + closeSessions(ctx); + continue; + } + + boolean enabled = isEnabled(world, ctx.type); + if (enabled && ctx.passiveProjected) { + resumeActive(ctx, block); + initializeInputIdentity(ctx); + } else if (!enabled && !ctx.passiveProjected) { + closeSessions(ctx); + FureamWorldConfig config = FureamMain.getWorldConfig(world); + if (config != null && ctx.activeProjected) prepareActiveTransaction(ctx, block, config); + projectPassiveLane(ctx, block); + } + saveToNbt(ctx); + } + } + + public static void shutdown() { + for (FurnaceContext ctx : new ArrayList<>(CONTEXTS.values())) { + closeSessions(ctx); + World world = ctx.getWorld(); + if (world != null && world.isChunkLoaded(ctx.pos.x >> 4, ctx.pos.z >> 4)) { + Block block = world.getBlockAt(ctx.pos.x, ctx.pos.y, ctx.pos.z); + FureamWorldConfig config = FureamMain.getWorldConfig(world); + if (config != null && ctx.activeProjected && FureamMain.getFurnaceType(block) == ctx.type) { + prepareActiveTransaction(ctx, block, config); + } + } + } + for (FurnaceContext ctx : CONTEXTS.values()) { + if (ctx.dirty || ctx.data.hasAny()) saveToNbt(ctx, false); + } + CONTEXTS.clear(); + } + + public static void closeSessions(@NotNull FurnaceContext ctx) { + for (FureamScreenInventory session : ctx.sessions.values()) { + for (org.bukkit.entity.HumanEntity viewer : + new java.util.ArrayList<>(session.bukkitInventory.getViewers())) { + viewer.closeInventory(); + } + } + ctx.sessions.clear(); + } + + private static void validateSessions( + @NotNull FurnaceContext ctx, @NotNull Block block, boolean enabled + ) { + Iterator> iterator = ctx.sessions.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + org.bukkit.entity.Player player = Bukkit.getPlayer(entry.getKey()); + boolean valid = enabled && player != null && player.isOnline() && + player.getWorld().equals(block.getWorld()) && + player.getLocation().distanceSquared(block.getLocation().add(0.5, 0.5, 0.5)) <= 64.0 && + FureamMain.getFurnaceType(block) == ctx.type && + player.getOpenInventory().getTopInventory().equals(entry.getValue().bukkitInventory); + if (!valid) { + if (player != null && player.isOnline()) player.closeInventory(); + ctx.sessions.remove(entry.getKey(), entry.getValue()); + } + } + } + + private static boolean sameWorld(@NotNull FurnacePos pos, @NotNull World world) { + return pos.worldId != null ? pos.worldId.equals(world.getUID()) : pos.worldName.equals(world.getName()); + } + + private FurnaceManager() { + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/hook/FurnaceOpenListener.java b/spigot/src/main/java/io/github/eat_ram/fuream/hook/FurnaceOpenListener.java new file mode 100644 index 0000000..e98fbef --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/hook/FurnaceOpenListener.java @@ -0,0 +1,97 @@ +package io.github.eat_ram.fuream.hook; + +import io.github.eat_ram.fuream.FureamMain; +import io.github.eat_ram.fuream.api.FureamWorldConfig; +import io.github.eat_ram.fuream.api.FurnaceType; +import io.github.eat_ram.fuream.hook.FurnaceManager.FurnaceContext; +import io.github.eat_ram.fuream.screen.FureamScreenInventory; +import io.github.eat_ram.fuream.compat.ItemCompat; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import java.lang.reflect.InvocationTargetException; + +public class FurnaceOpenListener implements Listener { + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onPlayerInteract(PlayerInteractEvent event) { + if (event.getAction() != Action.RIGHT_CLICK_BLOCK) { + return; + } + if (isOffHand(event) || isBlockUseDenied(event)) return; + + Block block = event.getClickedBlock(); + if (block == null) return; + + FurnaceType type = FureamMain.getFurnaceType(block); + if (type == null) return; + + Player player = event.getPlayer(); + if (player.isSneaking() && !ItemCompat.isEmpty(player.getItemInHand())) { + return; + } + + FureamWorldConfig config = FureamMain.getWorldConfig(block.getWorld()); + if (config == null || !config.getEnabledFurnaceTypes().contains(type)) { + return; + } + if (!canOpenLocked(block, player)) return; + + FurnaceContext ctx = FurnaceManager.getOrCreateContext(block); + if (ctx == null) return; + ctx = FurnaceManager.prepareForInteraction(ctx.pos); + if (ctx == null) return; + + event.setCancelled(true); + + FureamScreenInventory session = new FureamScreenInventory( + player.getUniqueId(), block.getLocation(), type, ctx.data, config + ); + ctx.sessions.put(player.getUniqueId(), session); + session.burnTime = ctx.burnTime; + session.fuelTimeTotal = Math.max(1, ctx.fuelTimeTotal); + session.cookTime = ctx.cookTime; + session.cookTimeTotal = Math.max(1, ctx.cookTimeTotal); + session.refreshVisuals(); + player.openInventory(session.bukkitInventory); + FurnaceManager.finishInteraction(ctx); + } + + private static boolean canOpenLocked(Block block, Player player) { + try { + Object state = block.getState(); + Object locked = state.getClass().getMethod("isLocked").invoke(state); + if (!Boolean.TRUE.equals(locked)) return true; + Object lock = state.getClass().getMethod("getLock").invoke(state); + if (!(lock instanceof String)) return false; + org.bukkit.inventory.ItemStack held = player.getItemInHand(); + return !ItemCompat.isEmpty(held) && held.hasItemMeta() && + held.getItemMeta().hasDisplayName() && lock.equals(held.getItemMeta().getDisplayName()); + } catch (NoSuchMethodException ignored) { + return true; + } catch (InvocationTargetException | IllegalAccessException | RuntimeException failure) { + return false; + } + } + + private static boolean isOffHand(PlayerInteractEvent event) { + try { + Object hand = event.getClass().getMethod("getHand").invoke(event); + return hand instanceof Enum && "OFF_HAND".equals(((Enum) hand).name()); + } catch (ReflectiveOperationException | LinkageError ignored) { + return false; + } + } + + private static boolean isBlockUseDenied(PlayerInteractEvent event) { + try { + Object result = event.getClass().getMethod("useInteractedBlock").invoke(event); + return result instanceof Enum && "DENY".equals(((Enum) result).name()); + } catch (ReflectiveOperationException | LinkageError ignored) { + return false; + } + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/hook/GuiListener.java b/spigot/src/main/java/io/github/eat_ram/fuream/hook/GuiListener.java new file mode 100644 index 0000000..c1476b8 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/hook/GuiListener.java @@ -0,0 +1,64 @@ +package io.github.eat_ram.fuream.hook; + +import io.github.eat_ram.fuream.screen.FureamScreenInventory; +import io.github.eat_ram.fuream.util.FurnacePos; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.inventory.Inventory; + +public class GuiListener implements Listener { + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onInventoryClick(InventoryClickEvent event) { + FureamScreenInventory session = session(event.getInventory()); + if (session == null || !(event.getWhoClicked() instanceof Player)) return; + FurnaceManager.FurnaceContext ctx = FurnaceManager.prepareForInteraction( + new FurnacePos(session.location) + ); + if (ctx == null) { + event.setCancelled(true); + return; + } + session.handler.handleClick(event, (Player) event.getWhoClicked()); + FurnaceManager.finishInteraction(ctx); + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onInventoryDrag(InventoryDragEvent event) { + FureamScreenInventory session = session(event.getInventory()); + if (session == null) return; + FurnaceManager.FurnaceContext ctx = FurnaceManager.prepareForInteraction( + new FurnacePos(session.location) + ); + if (ctx == null) { + event.setCancelled(true); + return; + } + session.handler.handleDrag(event); + FurnaceManager.finishInteraction(ctx); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onInventoryClose(InventoryCloseEvent event) { + FureamScreenInventory session = session(event.getInventory()); + if (session == null) return; + FurnaceManager.FurnaceContext ctx = context(session); + if (ctx != null) { + ctx.sessions.remove(session.viewerId, session); + FurnaceManager.saveToNbt(ctx); + } + } + + private static FureamScreenInventory session(Inventory inventory) { + return inventory.getHolder() instanceof FureamScreenInventory + ? (FureamScreenInventory) inventory.getHolder() : null; + } + + private static FurnaceManager.FurnaceContext context(FureamScreenInventory session) { + return FurnaceManager.getContext(new FurnacePos(session.location)); + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/hook/HopperListener.java b/spigot/src/main/java/io/github/eat_ram/fuream/hook/HopperListener.java new file mode 100644 index 0000000..18dc2fb --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/hook/HopperListener.java @@ -0,0 +1,71 @@ +package io.github.eat_ram.fuream.hook; + +import io.github.eat_ram.fuream.FureamMain; +import io.github.eat_ram.fuream.api.FureamWorldConfig; +import io.github.eat_ram.fuream.api.FurnaceType; +import io.github.eat_ram.fuream.logic.FureamFurnaceLogic; +import io.github.eat_ram.fuream.logic.FuelTable; +import io.github.eat_ram.fuream.compat.ItemCompat; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryMoveItemEvent; +import org.bukkit.inventory.FurnaceInventory; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; + +public class HopperListener implements Listener { + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onInventoryMoveItem(InventoryMoveItemEvent event) { + Inventory dest = event.getDestination(); + Inventory src = event.getSource(); + + // Hopper pushing INTO Furnace + Location furnaceLoc = inventoryLocation(dest); + if (dest instanceof FurnaceInventory && furnaceLoc != null) { + Block block = furnaceLoc.getBlock(); + FurnaceType type = FureamMain.getFurnaceType(block); + if (type != null) { + FureamWorldConfig config = FureamMain.getWorldConfig(furnaceLoc.getWorld()); + if (config != null && config.getEnabledFurnaceTypes().contains(type)) { + Location srcLoc = inventoryLocation(src); + ItemStack itemToMove = event.getItem(); + boolean isTop = srcLoc != null && srcLoc.getBlockY() > furnaceLoc.getBlockY(); + + if (isTop) { + // Top Hopper -> Inputs: Filter non-smeltables if configured + if (config.getPreventsHopperInsertNonSmeltable().contains(type)) { + if (!FureamFurnaceLogic.isAcceptableInput(furnaceLoc.getWorld(), itemToMove, type)) { + event.setCancelled(true); + } + } + } else { + // Side Hopper -> Fuels: Filter non-fuel items + if (!FuelTable.isFuel(itemToMove) && itemToMove.getType() != Material.BUCKET) { + event.setCancelled(true); + } + } + } + } + } + } + + private static Location inventoryLocation(Inventory inventory) { + Object holder = inventory.getHolder(); + if (holder instanceof org.bukkit.block.BlockState) { + return ((org.bukkit.block.BlockState) holder).getLocation(); + } + if (holder instanceof org.bukkit.entity.Entity) { + return ((org.bukkit.entity.Entity) holder).getLocation(); + } + try { + Object value = inventory.getClass().getMethod("getLocation").invoke(inventory); + return value instanceof Location ? (Location) value : null; + } catch (ReflectiveOperationException ignored) { + return null; + } + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/hook/RecipeIndexListener.java b/spigot/src/main/java/io/github/eat_ram/fuream/hook/RecipeIndexListener.java new file mode 100644 index 0000000..2cce781 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/hook/RecipeIndexListener.java @@ -0,0 +1,34 @@ +package io.github.eat_ram.fuream.hook; + +import io.github.eat_ram.fuream.FureamMain; +import io.github.eat_ram.fuream.compat.RecipeCompat; +import org.bukkit.Bukkit; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.server.PluginDisableEvent; +import org.bukkit.event.server.PluginEnableEvent; + +/** Rebuilds the immutable recipe snapshot after plugins can change registrations. */ +public final class RecipeIndexListener implements Listener { + private boolean scheduled; + + @EventHandler(priority = EventPriority.MONITOR) + public void onPluginEnable(PluginEnableEvent event) { + schedule(); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPluginDisable(PluginDisableEvent event) { + schedule(); + } + + private void schedule() { + if (this.scheduled) return; + this.scheduled = true; + Bukkit.getScheduler().runTask(FureamMain.getInstance(), () -> { + this.scheduled = false; + RecipeCompat.rebuild(); + }); + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/hook/VanillaFurnaceGuardListener.java b/spigot/src/main/java/io/github/eat_ram/fuream/hook/VanillaFurnaceGuardListener.java new file mode 100644 index 0000000..44cd9f1 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/hook/VanillaFurnaceGuardListener.java @@ -0,0 +1,25 @@ +package io.github.eat_ram.fuream.hook; + +import io.github.eat_ram.fuream.compat.FurnaceEventDispatcher; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.FurnaceBurnEvent; +import org.bukkit.event.inventory.FurnaceSmeltEvent; + +/** Prevents the vanilla engine from consuming Fuream's native ingress/projection slots. */ +public final class VanillaFurnaceGuardListener implements Listener { + @EventHandler(priority = EventPriority.HIGHEST) + public void onBurn(FurnaceBurnEvent event) { + if (!FurnaceEventDispatcher.isDispatching() && FurnaceManager.isManagedActive(event.getBlock())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onSmelt(FurnaceSmeltEvent event) { + if (!FurnaceEventDispatcher.isDispatching() && FurnaceManager.isManagedActive(event.getBlock())) { + event.setCancelled(true); + } + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/logic/FuelTable.java b/spigot/src/main/java/io/github/eat_ram/fuream/logic/FuelTable.java new file mode 100644 index 0000000..2655e59 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/logic/FuelTable.java @@ -0,0 +1,263 @@ +package io.github.eat_ram.fuream.logic; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.EnumMap; +import java.util.Map; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.Nullable; +import io.github.eat_ram.fuream.compat.ItemCompat; + +public final class FuelTable { + private static final Map FUEL_TIMES = new EnumMap<>(Material.class); + + static { + loadDefaults(); + initDynamic(); + } + + public static void initDynamic() { + try { + Class furnaceClass = getFurnaceNmsClass(); + if (furnaceClass == null) return; + + Class craftMagicClass = getCraftMagicNumbersClass(); + if (craftMagicClass == null) return; + + Method getMaterialMethod = null; + for (Method m : craftMagicClass.getMethods()) { + if (m.getName().equals("getMaterial") && m.getParameterCount() == 1 && m.getReturnType() == Material.class) { + getMaterialMethod = m; + break; + } + } + if (getMaterialMethod == null) return; + + Map best = new EnumMap<>(Material.class); + for (Method candidate : furnaceClass.getDeclaredMethods()) { + if (!Modifier.isStatic(candidate.getModifiers()) || candidate.getParameterCount() != 0 || + !Map.class.isAssignableFrom(candidate.getReturnType())) continue; + try { + candidate.setAccessible(true); + Object value = candidate.invoke(null); + if (!(value instanceof Map)) continue; + Map converted = convertFuelMap((Map) value, getMaterialMethod); + if (converted.size() > best.size()) best = converted; + } catch (Throwable ignored) { + // One inaccessible or unrelated map must not hide later candidates. + } + } + if (!best.isEmpty()) FUEL_TIMES.putAll(best); + } catch (Throwable ignored) { + // Graceful fallback to default vanilla table + } + } + + private static Map convertFuelMap(Map source, Method getMaterialMethod) { + Map converted = new EnumMap<>(Material.class); + for (Map.Entry entry : source.entrySet()) { + if (!(entry.getValue() instanceof Number) || entry.getKey() == null) continue; + int ticks = ((Number) entry.getValue()).intValue(); + if (ticks <= 0 || !getMaterialMethod.getParameterTypes()[0].isInstance(entry.getKey())) continue; + try { + Object material = getMaterialMethod.invoke(null, entry.getKey()); + if (material instanceof Material) converted.put((Material) material, ticks); + } catch (ReflectiveOperationException | IllegalArgumentException ignored) { + } + } + return converted; + } + + private static @Nullable Class getFurnaceNmsClass() { + String serverPkg = Bukkit.getServer() != null ? Bukkit.getServer().getClass().getPackage().getName() : ""; + String[] candidates = { + "net.minecraft.world.level.block.entity.TileEntityFurnace", + "net.minecraft.world.level.block.entity.AbstractFurnaceBlockEntity", + serverPkg + ".block.entity.TileEntityFurnace", + serverPkg.replace("org.bukkit.craftbukkit", "net.minecraft.server") + ".TileEntityFurnace" + }; + for (String c : candidates) { + try { + return Class.forName(c); + } catch (ClassNotFoundException ignored) {} + } + return null; + } + + private static @Nullable Class getCraftMagicNumbersClass() { + String serverPkg = Bukkit.getServer() != null ? Bukkit.getServer().getClass().getPackage().getName() : ""; + String[] candidates = { + serverPkg + ".util.CraftMagicNumbers", + "org.bukkit.craftbukkit.util.CraftMagicNumbers" + }; + for (String c : candidates) { + try { + return Class.forName(c); + } catch (ClassNotFoundException ignored) {} + } + return null; + } + + private static void safePut(String materialName, int ticks) { + Material mat = Material.matchMaterial(materialName); + if (mat != null) { + FUEL_TIMES.put(mat, ticks); + } + } + + private static void safePutAll(String[] names, int ticks) { + for (String n : names) { + safePut(n, ticks); + } + } + + private static void loadDefaults() { + // High tier + safePut("LAVA_BUCKET", 20000); + safePut("COAL_BLOCK", 16000); + safePut("DRIED_KELP_BLOCK", 4000); + safePut("BLAZE_ROD", 2400); + safePut("COAL", 1600); + safePut("CHARCOAL", 1600); + + // Boats & Rafts + safePutAll(new String[]{ + "OAK_BOAT", "SPRUCE_BOAT", "BIRCH_BOAT", "JUNGLE_BOAT", + "ACACIA_BOAT", "DARK_OAK_BOAT", "MANGROVE_BOAT", "CHERRY_BOAT", + "BAMBOO_RAFT", + "OAK_CHEST_BOAT", "SPRUCE_CHEST_BOAT", "BIRCH_CHEST_BOAT", "JUNGLE_CHEST_BOAT", + "ACACIA_CHEST_BOAT", "DARK_OAK_CHEST_BOAT", "MANGROVE_CHEST_BOAT", "CHERRY_CHEST_BOAT", + "BAMBOO_CHEST_RAFT" + }, 1200); + + // Standard wood items (300 ticks) + safePutAll(new String[]{ + // Logs + "OAK_LOG", "SPRUCE_LOG", "BIRCH_LOG", "JUNGLE_LOG", + "ACACIA_LOG", "DARK_OAK_LOG", "MANGROVE_LOG", "CHERRY_LOG", + "STRIPPED_OAK_LOG", "STRIPPED_SPRUCE_LOG", "STRIPPED_BIRCH_LOG", "STRIPPED_JUNGLE_LOG", + "STRIPPED_ACACIA_LOG", "STRIPPED_DARK_OAK_LOG", "STRIPPED_MANGROVE_LOG", "STRIPPED_CHERRY_LOG", + // Woods + "OAK_WOOD", "SPRUCE_WOOD", "BIRCH_WOOD", "JUNGLE_WOOD", + "ACACIA_WOOD", "DARK_OAK_WOOD", "MANGROVE_WOOD", "CHERRY_WOOD", + "STRIPPED_OAK_WOOD", "STRIPPED_SPRUCE_WOOD", "STRIPPED_BIRCH_WOOD", "STRIPPED_JUNGLE_WOOD", + "STRIPPED_ACACIA_WOOD", "STRIPPED_DARK_OAK_WOOD", "STRIPPED_MANGROVE_WOOD", "STRIPPED_CHERRY_WOOD", + // Planks + "OAK_PLANKS", "SPRUCE_PLANKS", "BIRCH_PLANKS", "JUNGLE_PLANKS", + "ACACIA_PLANKS", "DARK_OAK_PLANKS", "MANGROVE_PLANKS", "CHERRY_PLANKS", + "BAMBOO_PLANKS", "BAMBOO_MOSAIC", + // Stairs + "OAK_STAIRS", "SPRUCE_STAIRS", "BIRCH_STAIRS", "JUNGLE_STAIRS", + "ACACIA_STAIRS", "DARK_OAK_STAIRS", "MANGROVE_STAIRS", "CHERRY_STAIRS", + "BAMBOO_STAIRS", "BAMBOO_MOSAIC_STAIRS", + // Fences & Gates + "OAK_FENCE", "SPRUCE_FENCE", "BIRCH_FENCE", "JUNGLE_FENCE", + "ACACIA_FENCE", "DARK_OAK_FENCE", "MANGROVE_FENCE", "CHERRY_FENCE", "BAMBOO_FENCE", + "OAK_FENCE_GATE", "SPRUCE_FENCE_GATE", "BIRCH_FENCE_GATE", "JUNGLE_FENCE_GATE", + "ACACIA_FENCE_GATE", "DARK_OAK_FENCE_GATE", "MANGROVE_FENCE_GATE", "CHERRY_FENCE_GATE", "BAMBOO_FENCE_GATE", + // Doors & Trapdoors + "OAK_DOOR", "SPRUCE_DOOR", "BIRCH_DOOR", "JUNGLE_DOOR", + "ACACIA_DOOR", "DARK_OAK_DOOR", "MANGROVE_DOOR", "CHERRY_DOOR", "BAMBOO_DOOR", + "OAK_TRAPDOOR", "SPRUCE_TRAPDOOR", "BIRCH_TRAPDOOR", "JUNGLE_TRAPDOOR", + "ACACIA_TRAPDOOR", "DARK_OAK_TRAPDOOR", "MANGROVE_TRAPDOOR", "CHERRY_TRAPDOOR", "BAMBOO_TRAPDOOR", + // Pressure plates & Buttons + "OAK_PRESSURE_PLATE", "SPRUCE_PRESSURE_PLATE", "BIRCH_PRESSURE_PLATE", "JUNGLE_PRESSURE_PLATE", + "ACACIA_PRESSURE_PLATE", "DARK_OAK_PRESSURE_PLATE", "MANGROVE_PRESSURE_PLATE", "CHERRY_PRESSURE_PLATE", + "BAMBOO_PRESSURE_PLATE", + "OAK_BUTTON", "SPRUCE_BUTTON", "BIRCH_BUTTON", "JUNGLE_BUTTON", + "ACACIA_BUTTON", "DARK_OAK_BUTTON", "MANGROVE_BUTTON", "CHERRY_BUTTON", + "BAMBOO_BUTTON", + // Signs & Hanging Signs + "OAK_SIGN", "SPRUCE_SIGN", "BIRCH_SIGN", "JUNGLE_SIGN", + "ACACIA_SIGN", "DARK_OAK_SIGN", "MANGROVE_SIGN", "CHERRY_SIGN", "BAMBOO_SIGN", + "OAK_HANGING_SIGN", "SPRUCE_HANGING_SIGN", "BIRCH_HANGING_SIGN", "JUNGLE_HANGING_SIGN", + "ACACIA_HANGING_SIGN", "DARK_OAK_HANGING_SIGN", "MANGROVE_HANGING_SIGN", "CHERRY_HANGING_SIGN", "BAMBOO_HANGING_SIGN", + // Utility Blocks + "CRAFTING_TABLE", "BOOKSHELF", "CHISELED_BOOKSHELF", "CHEST", "TRAPPED_CHEST", + "BARREL", "DAYLIGHT_DETECTOR", "JUKEBOX", "NOTE_BLOCK", "COMPOSTER", + "LOOM", "FLETCHING_TABLE", "CARTOGRAPHY_TABLE", "SMITHING_TABLE", "LECTERN", + "BEEHIVE", + // Banners + "WHITE_BANNER", "ORANGE_BANNER", "MAGENTA_BANNER", "LIGHT_BLUE_BANNER", + "YELLOW_BANNER", "LIME_BANNER", "PINK_BANNER", "GRAY_BANNER", + "LIGHT_GRAY_BANNER", "CYAN_BANNER", "PURPLE_BANNER", "BLUE_BANNER", + "BROWN_BANNER", "GREEN_BANNER", "RED_BANNER", "BLACK_BANNER", + // Tools & Weapons + "WOODEN_AXE", "WOODEN_HOE", "WOODEN_PICKAXE", "WOODEN_SHOVEL", "WOODEN_SWORD", + "BOW", "CROSSBOW", "FISHING_ROD" + }, 300); + + // Pre-flattening aliases used by 1.7-1.12. + safePutAll(new String[]{ + "LOG", "LOG_2", "WOOD", "WOOD_STAIRS", "SPRUCE_WOOD_STAIRS", + "BIRCH_WOOD_STAIRS", "JUNGLE_WOOD_STAIRS", "ACACIA_STAIRS", + "DARK_OAK_STAIRS", "FENCE", "FENCE_GATE", "SPRUCE_FENCE", + "BIRCH_FENCE", "JUNGLE_FENCE", "DARK_OAK_FENCE", "ACACIA_FENCE", + "SPRUCE_FENCE_GATE", "BIRCH_FENCE_GATE", "JUNGLE_FENCE_GATE", + "DARK_OAK_FENCE_GATE", "ACACIA_FENCE_GATE", "WOOD_DOOR", + "TRAP_DOOR", "WOOD_PLATE", "WOOD_BUTTON", "SIGN", "WORKBENCH", + "WOOD_AXE", "WOOD_HOE", "WOOD_PICKAXE", "WOOD_SPADE", "WOOD_SWORD" + }, 300); + + // Slabs (150 ticks) + safePutAll(new String[]{ + "OAK_SLAB", "SPRUCE_SLAB", "BIRCH_SLAB", "JUNGLE_SLAB", + "ACACIA_SLAB", "DARK_OAK_SLAB", "MANGROVE_SLAB", "CHERRY_SLAB", + "BAMBOO_SLAB", "BAMBOO_MOSAIC_SLAB" + }, 150); + safePut("WOOD_STEP", 150); + + // 200 ticks + safePutAll(new String[]{"BOWL", "LADDER"}, 200); + + // 100 ticks + safePutAll(new String[]{ + "STICK", "OAK_SAPLING", "SPRUCE_SAPLING", "BIRCH_SAPLING", + "JUNGLE_SAPLING", "ACACIA_SAPLING", "DARK_OAK_SAPLING", "MANGROVE_PROPAGULE", + "CHERRY_SAPLING", "BAMBOO", "AZALEA", "FLOWERING_AZALEA" + }, 100); + safePut("SAPLING", 100); + + // 67 ticks (wool, carpet) + safePutAll(new String[]{ + "WHITE_WOOL", "ORANGE_WOOL", "MAGENTA_WOOL", "LIGHT_BLUE_WOOL", + "YELLOW_WOOL", "LIME_WOOL", "PINK_WOOL", "GRAY_WOOL", + "LIGHT_GRAY_WOOL", "CYAN_WOOL", "PURPLE_WOOL", "BLUE_WOOL", + "BROWN_WOOL", "GREEN_WOOL", "RED_WOOL", "BLACK_WOOL", + "WHITE_CARPET", "ORANGE_CARPET", "MAGENTA_CARPET", "LIGHT_BLUE_CARPET", + "YELLOW_CARPET", "LIME_CARPET", "PINK_CARPET", "GRAY_CARPET", + "LIGHT_GRAY_CARPET", "CYAN_CARPET", "PURPLE_CARPET", "BLUE_CARPET", + "BROWN_CARPET", "GREEN_CARPET", "RED_CARPET", "BLACK_CARPET" + }, 67); + safePutAll(new String[]{"WOOL", "CARPET"}, 67); + + // Scaffolding (50 ticks) + safePut("SCAFFOLDING", 50); + } + + public static boolean isFuel(@Nullable ItemStack stack) { + if (ItemCompat.isEmpty(stack)) return false; + return isFuel(stack.getType()); + } + + public static boolean isFuel(@Nullable Material material) { + if (ItemCompat.isAir(material)) return false; + return FUEL_TIMES.containsKey(material); + } + + public static int getFuelTime(@Nullable ItemStack stack) { + if (ItemCompat.isEmpty(stack)) return 0; + return getFuelTime(stack.getType()); + } + + public static int getFuelTime(@Nullable Material material) { + if (ItemCompat.isAir(material)) return 0; + return FUEL_TIMES.getOrDefault(material, 0); + } + + private FuelTable() { + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/logic/FureamFurnaceEngine.java b/spigot/src/main/java/io/github/eat_ram/fuream/logic/FureamFurnaceEngine.java new file mode 100644 index 0000000..c20736d --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/logic/FureamFurnaceEngine.java @@ -0,0 +1,276 @@ +package io.github.eat_ram.fuream.logic; + +import java.util.List; + +import io.github.eat_ram.fuream.ResolvedFurnaceConfig; +import io.github.eat_ram.fuream.compat.FurnaceEventDispatcher; +import io.github.eat_ram.fuream.compat.FurnaceEventCompat; +import io.github.eat_ram.fuream.compat.ItemCompat; +import io.github.eat_ram.fuream.compat.RecipeCompat; +import io.github.eat_ram.fuream.compat.RecipeHandle; +import io.github.eat_ram.fuream.compat.VersionAdapters; +import io.github.eat_ram.fuream.data.FureamFurnaceData; +import io.github.eat_ram.fuream.hook.FurnaceManager.FurnaceContext; +import io.github.eat_ram.fuream.util.KeyableItemStack; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.event.inventory.FurnaceBurnEvent; +import org.bukkit.event.inventory.FurnaceSmeltEvent; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class FureamFurnaceEngine { + public static void tick(FurnaceContext ctx, ResolvedFurnaceConfig config) { + World world = ctx.getWorld(); + if (world == null) return; + FureamFurnaceData data = ctx.data; + int inputCount = config.inputSlots; + int fuelCount = config.fuelSlots; + int outputCount = config.outputSlots; + ensureSize(data.inputs, inputCount); + ensureSize(data.fuels, fuelCount); + ensureSize(data.outputs, outputCount); + + Block block = world.getBlockAt(ctx.pos.x, ctx.pos.y, ctx.pos.z); + if (ctx.burnTime > 0) { + ctx.burnTime--; + ctx.dirty = true; + } + + int inputSlot = firstPresent(data.inputs); + ItemStack input = inputSlot < 0 ? null : data.inputs.get(inputSlot); + updateInputIdentity(ctx, inputSlot, input); + + RecipeHandle recipe = input == null ? null : getRecipeForInput(input, ctx); + ItemStack result = recipe == null ? null : recipe.result.clone(); + boolean canSmelt = recipe != null && canFitAll(data.outputs, result, outputCount); + if (recipe != null) { + if (!recipe.id.equals(data.runningRecipe)) { + ctx.cookTime = 0; + ctx.cookTimeTotal = Math.max(1, recipe.cookingTime); + ctx.startSmeltPending = true; + } + data.runningRecipe = recipe.id; + } else { + data.runningRecipe = null; + } + + if (ctx.burnTime <= 0 && canSmelt) { + ignite(ctx, block, fuelCount); + } + + if (ctx.burnTime > 0 && canSmelt) { + if (ctx.startSmeltPending) { + int requestedTime = fireStartSmelt(block, input, recipe); + ctx.cookTimeTotal = Math.max(1, requestedTime); + ctx.startSmeltPending = false; + ctx.dirty = true; + } + ctx.cookTime++; + ctx.dirty = true; + if (ctx.cookTime >= Math.max(1, ctx.cookTimeTotal)) { + FurnaceSmeltEvent event = new FurnaceSmeltEvent(block, one(input), result.clone()); + FurnaceEventDispatcher.call(event); + ItemStack eventResult = event.getResult(); + if (event.isCancelled() || ItemCompat.isEmpty(eventResult) || + !canFitAll(data.outputs, eventResult, outputCount)) { + ctx.cookTime = Math.max(0, ctx.cookTimeTotal - 1); + } else { + ItemStack remainder = insertStackIntoList(data.outputs, eventResult, outputCount); + if (ItemCompat.isEmpty(remainder)) { + FureamFurnaceLogic.stashExperience(recipe, data); + handleWetSponge(input, data.fuels, fuelCount); + decrement(data.inputs, inputSlot); + ctx.cookTime = 0; + data.runningRecipe = null; + ctx.lastInputKey = null; + ctx.lastInputSlot = -1; + ctx.startSmeltPending = true; + } + } + } + } else if (!canSmelt && ctx.cookTime > 0) { + ctx.cookTime = Math.max(0, ctx.cookTime - 2); + ctx.dirty = true; + } + + } + + private static void ignite(FurnaceContext ctx, Block block, int fuelCount) { + int fuelSlot = firstFuel(ctx.data.fuels, fuelCount); + if (fuelSlot < 0) return; + ItemStack fuel = ctx.data.fuels.get(fuelSlot); + int fuelTime = effectiveFuelTime(FuelTable.getFuelTime(fuel), ctx.type); + + FurnaceBurnEvent event = new FurnaceBurnEvent(block, one(fuel), fuelTime); + FurnaceEventDispatcher.call(event); + if (event.isCancelled() || !event.isBurning() || event.getBurnTime() <= 0) return; + + ctx.burnTime = event.getBurnTime(); + ctx.fuelTimeTotal = event.getBurnTime(); + Material lavaBucket = Material.matchMaterial("LAVA_BUCKET"); + Material bucket = Material.matchMaterial("BUCKET"); + if (lavaBucket != null && bucket != null && fuel.getType() == lavaBucket) { + ctx.data.fuels.set(fuelSlot, new ItemStack(bucket)); + } else { + decrement(ctx.data.fuels, fuelSlot); + } + ctx.dirty = true; + } + + private static int fireStartSmelt(Block block, ItemStack input, RecipeHandle recipe) { + return FurnaceEventCompat.fireStartSmelt( + block, one(input), recipe.nativeRecipe, recipe.cookingTime + ); + } + + private static RecipeHandle getRecipeForInput(ItemStack input, FurnaceContext ctx) { + if (ctx.data.runningRecipe != null) { + RecipeHandle running = RecipeCompat.findById(input, ctx.type, ctx.data.runningRecipe); + if (running != null) return running; + } + if (VersionAdapters.current().supportsRecipeOverrides()) { + String overridden = ctx.data.overriddenRecipes.get(new KeyableItemStack(one(input))); + RecipeHandle selected = RecipeCompat.findById(input, ctx.type, overridden); + if (selected != null) return selected; + } + return RecipeCompat.findFirst(input, ctx.type); + } + + static void handleWetSponge(ItemStack input, List fuels, int maxSlots) { + Material wetSponge = Material.matchMaterial("WET_SPONGE"); + Material bucket = Material.matchMaterial("BUCKET"); + Material waterBucket = Material.matchMaterial("WATER_BUCKET"); + if (wetSponge == null || bucket == null || waterBucket == null || input.getType() != wetSponge) return; + + int bucketSlot = -1; + for (int i = 0; i < Math.min(maxSlots, fuels.size()); i++) { + if (!ItemCompat.isEmpty(fuels.get(i)) && fuels.get(i).getType() == bucket) { + bucketSlot = i; + break; + } + } + if (bucketSlot < 0) return; + ItemStack found = fuels.get(bucketSlot); + found.setAmount(found.getAmount() - 1); + if (found.getAmount() <= 0) { + fuels.set(bucketSlot, new ItemStack(waterBucket)); + return; + } + int empty = firstEmpty(fuels, maxSlots); + if (empty < 0) { + found.setAmount(found.getAmount() + 1); + return; + } + fuels.set(empty, new ItemStack(waterBucket)); + } + + static int effectiveFuelTime(int vanillaFuelTime, io.github.eat_ram.fuream.api.FurnaceType type) { + return Math.max(0, vanillaFuelTime); + } + + public static ItemStack insertStackIntoList(List slots, ItemStack incoming, int maxSlots) { + if (ItemCompat.isEmpty(incoming)) return ItemCompat.empty(); + ensureSize(slots, maxSlots); + ItemStack remaining = incoming.clone(); + for (int i = 0; i < maxSlots && !ItemCompat.isEmpty(remaining); i++) { + ItemStack current = slots.get(i); + if (!ItemCompat.isEmpty(current) && current.isSimilar(remaining)) { + int move = Math.min(remaining.getAmount(), current.getMaxStackSize() - current.getAmount()); + if (move > 0) { + current.setAmount(current.getAmount() + move); + remaining.setAmount(remaining.getAmount() - move); + } + } + } + for (int i = 0; i < maxSlots && !ItemCompat.isEmpty(remaining); i++) { + if (ItemCompat.isEmpty(slots.get(i))) { + int move = Math.min(remaining.getAmount(), remaining.getMaxStackSize()); + ItemStack placed = remaining.clone(); + placed.setAmount(move); + slots.set(i, placed); + remaining.setAmount(remaining.getAmount() - move); + } + } + return ItemCompat.isEmpty(remaining) ? ItemCompat.empty() : remaining; + } + + public static ItemStack recoverVanillaOutput( + List outputs, ItemStack vanillaResult, int maxSlots + ) { + if (ItemCompat.isEmpty(vanillaResult)) return null; + ItemStack remainder = insertStackIntoList(outputs, vanillaResult, Math.max(1, maxSlots)); + return ItemCompat.isEmpty(remainder) ? null : remainder; + } + + private static boolean canFitAll(List slots, ItemStack stack, int maxSlots) { + if (ItemCompat.isEmpty(stack)) return false; + int capacity = 0; + ensureSize(slots, maxSlots); + for (int i = 0; i < maxSlots; i++) { + ItemStack current = slots.get(i); + if (ItemCompat.isEmpty(current)) capacity += stack.getMaxStackSize(); + else if (current.isSimilar(stack)) capacity += current.getMaxStackSize() - current.getAmount(); + if (capacity >= stack.getAmount()) return true; + } + return false; + } + + private static int firstFuel(List fuels, int maxSlots) { + for (int i = 0; i < Math.min(maxSlots, fuels.size()); i++) { + if (!ItemCompat.isEmpty(fuels.get(i)) && FuelTable.isFuel(fuels.get(i))) return i; + } + return -1; + } + + private static int firstPresent(List stacks) { + for (int i = 0; i < stacks.size(); i++) if (!ItemCompat.isEmpty(stacks.get(i))) return i; + return -1; + } + + private static int firstEmpty(List stacks, int maxSlots) { + ensureSize(stacks, maxSlots); + for (int i = 0; i < maxSlots; i++) if (ItemCompat.isEmpty(stacks.get(i))) return i; + return -1; + } + + private static void decrement(List slots, int slot) { + ItemStack stack = slots.get(slot); + stack.setAmount(stack.getAmount() - 1); + if (stack.getAmount() <= 0) slots.set(slot, ItemCompat.empty()); + } + + private static ItemStack one(ItemStack stack) { + ItemStack result = stack.clone(); + result.setAmount(1); + return result; + } + + private static boolean sameKey(KeyableItemStack left, KeyableItemStack right) { + return left == right || left != null && left.equals(right); + } + + static boolean updateInputIdentity( + FurnaceContext ctx, int inputSlot, @Nullable ItemStack input + ) { + KeyableItemStack inputKey = ItemCompat.isEmpty(input) + ? null : new KeyableItemStack(one(input)); + if (inputSlot == ctx.lastInputSlot && sameKey(ctx.lastInputKey, inputKey)) return false; + ctx.lastInputSlot = inputSlot; + ctx.lastInputKey = inputKey; + ctx.cookTime = 0; + ctx.data.runningRecipe = null; + ctx.startSmeltPending = true; + ctx.dirty = true; + return true; + } + + private static void ensureSize(List slots, int size) { + while (slots.size() < size) slots.add(ItemCompat.empty()); + } + + private FureamFurnaceEngine() { + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/logic/FureamFurnaceLogic.java b/spigot/src/main/java/io/github/eat_ram/fuream/logic/FureamFurnaceLogic.java new file mode 100644 index 0000000..dc48250 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/logic/FureamFurnaceLogic.java @@ -0,0 +1,92 @@ +package io.github.eat_ram.fuream.logic; + +import java.util.List; +import java.util.Optional; + +import io.github.eat_ram.fuream.api.FurnaceType; +import io.github.eat_ram.fuream.compat.ItemCompat; +import io.github.eat_ram.fuream.compat.RecipeCompat; +import io.github.eat_ram.fuream.compat.RecipeHandle; +import io.github.eat_ram.fuream.data.FureamFurnaceData; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.ExperienceOrb; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class FureamFurnaceLogic { + public static boolean isFuel(@Nullable ItemStack stack) { + return FuelTable.isFuel(stack); + } + + public static boolean isAcceptableInput( + @Nullable World world, @NotNull ItemStack stack, @NotNull FurnaceType furnaceType + ) { + return RecipeCompat.findFirst(stack, furnaceType) != null; + } + + public static Optional findRecipe( + @Nullable World world, @NotNull ItemStack stack, @NotNull FurnaceType furnaceType + ) { + return Optional.ofNullable(RecipeCompat.findFirst(stack, furnaceType)); + } + + public static List findAllMatches( + @Nullable World world, @NotNull ItemStack stack, @NotNull FurnaceType furnaceType + ) { + return RecipeCompat.findAll(stack, furnaceType); + } + + public static void stashExperience( + @Nullable RecipeHandle recipe, @NotNull FureamFurnaceData data + ) { + if (recipe != null) data.experience += recipe.experience; + } + + public static void grantExperience( + @NotNull Player player, @NotNull Location loc, @NotNull FureamFurnaceData data + ) { + int amount = (int) data.experience; + if (amount <= 0) return; + data.experience -= amount; + ExperienceOrb orb = player.getWorld().spawn(player.getLocation(), ExperienceOrb.class); + orb.setExperience(amount); + } + + public static void dropOnBreak( + @NotNull Location pos, @NotNull FureamFurnaceData data + ) { + World world = pos.getWorld(); + if (world == null) return; + Location center = pos.clone().add(0.5, 0.5, 0.5); + + for (ItemStack stack : data.inputs) dropIfPresent(world, center, stack); + data.inputs.clear(); + for (ItemStack stack : data.fuels) dropIfPresent(world, center, stack); + data.fuels.clear(); + for (ItemStack stack : data.outputs) dropIfPresent(world, center, stack); + data.outputs.clear(); + + // Deliberately retained for Fabric parity (known Fabric bug). + if (!ItemCompat.isEmpty(data.recipeOverridingInput)) { + dropIfPresent(world, center, data.recipeOverridingInput); + data.recipeOverridingInput = ItemCompat.empty(); + } + + int amount = (int) Math.floor(data.experience); + if (amount > 0) { + data.experience -= amount; + ExperienceOrb orb = world.spawn(center, ExperienceOrb.class); + orb.setExperience(amount); + } + } + + private static void dropIfPresent(World world, Location location, ItemStack stack) { + if (!ItemCompat.isEmpty(stack)) world.dropItemNaturally(location, stack.clone()); + } + + private FureamFurnaceLogic() { + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/logic/NativeLaneProjection.java b/spigot/src/main/java/io/github/eat_ram/fuream/logic/NativeLaneProjection.java new file mode 100644 index 0000000..647735d --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/logic/NativeLaneProjection.java @@ -0,0 +1,121 @@ +package io.github.eat_ram.fuream.logic; + +import java.util.List; + +import io.github.eat_ram.fuream.compat.ItemCompat; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** Publishes one virtual stack into a native furnace slot and merges the observed result back. */ +public final class NativeLaneProjection { + public enum SelectionMode { + FIRST_PRESENT, + BUCKET_REMAINDER + } + + public static final class State { + private int sourceSlot = -1; + private ItemStack published; + + public int getSourceSlot() { + return this.sourceSlot; + } + + public boolean isActive() { + return this.sourceSlot >= 0; + } + + public @Nullable ItemStack getPublished() { + return ItemCompat.isEmpty(this.published) ? null : this.published.clone(); + } + + public void reset() { + this.sourceSlot = -1; + this.published = null; + } + + public void restore(int sourceSlot, @Nullable ItemStack published) { + this.sourceSlot = sourceSlot; + this.published = ItemCompat.isEmpty(published) ? null : published.clone(); + } + } + + public static @Nullable ItemStack publish( + @NotNull List virtual, @Nullable ItemStack nativeStack, + @NotNull SelectionMode mode, @NotNull State state + ) { + state.reset(); + if (!ItemCompat.isEmpty(nativeStack)) { + return nativeStack.clone(); + } + int selected = selectSlot(virtual, mode); + if (selected < 0) { + return null; + } + ItemStack projected = virtual.get(selected).clone(); + state.sourceSlot = selected; + state.published = projected.clone(); + return projected; + } + + public static @Nullable ItemStack reconcile( + @NotNull List virtual, @Nullable ItemStack nativeStack, + int maxSlots, @NotNull State state + ) { + if (!state.isActive()) { + return nativeStack; + } + int source = state.sourceSlot; + ItemStack published = state.published; + state.reset(); + if (source >= 0 && source < maxSlots && source < virtual.size() && + sameStack(virtual.get(source), published)) { + virtual.set(source, ItemCompat.isEmpty(nativeStack) ? ItemCompat.empty() : nativeStack.clone()); + return null; + } + + removePublished(virtual, published, maxSlots); + if (ItemCompat.isEmpty(nativeStack)) return null; + ItemStack remainder = FureamFurnaceEngine.insertStackIntoList( + virtual, nativeStack.clone(), maxSlots + ); + return ItemCompat.isEmpty(remainder) ? null : remainder; + } + + private static void removePublished(List virtual, ItemStack published, int maxSlots) { + if (ItemCompat.isEmpty(published)) return; + int remaining = published.getAmount(); + for (int i = 0; i < Math.min(maxSlots, virtual.size()) && remaining > 0; i++) { + ItemStack current = virtual.get(i); + if (ItemCompat.isEmpty(current) || !current.isSimilar(published)) continue; + int remove = Math.min(remaining, current.getAmount()); + current.setAmount(current.getAmount() - remove); + remaining -= remove; + if (current.getAmount() <= 0) virtual.set(i, ItemCompat.empty()); + } + } + + private static boolean sameStack(ItemStack left, ItemStack right) { + return ItemCompat.isEmpty(left) && ItemCompat.isEmpty(right) || + !ItemCompat.isEmpty(left) && !ItemCompat.isEmpty(right) && + left.getAmount() == right.getAmount() && left.isSimilar(right); + } + + private static int selectSlot(List virtual, SelectionMode mode) { + for (int i = 0; i < virtual.size(); i++) { + ItemStack stack = virtual.get(i); + if (ItemCompat.isEmpty(stack)) continue; + if (mode == SelectionMode.FIRST_PRESENT || isBucket(stack)) return i; + } + return -1; + } + + private static boolean isBucket(ItemStack stack) { + String name = stack.getType().name(); + return "BUCKET".equals(name) || "WATER_BUCKET".equals(name); + } + + private NativeLaneProjection() { + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/logic/PassiveLaneProjection.java b/spigot/src/main/java/io/github/eat_ram/fuream/logic/PassiveLaneProjection.java new file mode 100644 index 0000000..93ff064 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/logic/PassiveLaneProjection.java @@ -0,0 +1,119 @@ +package io.github.eat_ram.fuream.logic; + +import java.util.List; + +import io.github.eat_ram.fuream.compat.ItemCompat; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** Moves one virtual stack into a native furnace slot while Fuream is disabled. */ +public final class PassiveLaneProjection { + public enum SelectionMode { + FIRST_PRESENT, + FUEL_THEN_PRESENT + } + + public static final class State { + private int sourceSlot = -1; + private ItemStack published; + + public int getSourceSlot() { + return this.sourceSlot; + } + + public @Nullable ItemStack getPublished() { + return ItemCompat.isEmpty(this.published) ? null : this.published.clone(); + } + + public void reset() { + this.sourceSlot = -1; + this.published = null; + } + } + + /** + * Projects at most one stack. Callers must retain the projected state and + * must not call this again until Fuream is re-enabled for the furnace. + */ + public static @Nullable ItemStack begin( + @NotNull List virtual, @Nullable ItemStack nativeStack, + @NotNull SelectionMode mode, @NotNull State state + ) { + state.reset(); + if (!ItemCompat.isEmpty(nativeStack)) { + return nativeStack; + } + int selected = selectSlot(virtual, mode); + if (selected < 0) { + return null; + } + + ItemStack projected = virtual.get(selected).clone(); + virtual.set(selected, ItemCompat.empty()); + state.sourceSlot = selected; + state.published = projected.clone(); + return projected; + } + + /** + * Returns the native stack to its source slot when possible. Any remainder + * stays native so callers can retry without losing items. + */ + public static @Nullable ItemStack restore( + @NotNull List virtual, @Nullable ItemStack nativeStack, + @NotNull State state + ) { + if (ItemCompat.isEmpty(nativeStack)) { + state.sourceSlot = -1; + return null; + } + + int source = state.sourceSlot; + if (source >= 0 && source < virtual.size() && ItemCompat.isEmpty(virtual.get(source))) { + virtual.set(source, nativeStack.clone()); + state.sourceSlot = -1; + return null; + } + + ItemStack remainder = FureamFurnaceEngine.insertStackIntoList( + virtual, nativeStack, Math.max(1, virtual.size()) + ); + state.sourceSlot = -1; + return ItemCompat.isEmpty(remainder) ? null : remainder; + } + + private static int selectSlot( + @NotNull List virtual, @NotNull SelectionMode mode + ) { + if (mode == SelectionMode.FUEL_THEN_PRESENT) { + int fuel = firstFuel(virtual); + if (fuel >= 0) { + return fuel; + } + } + return firstPresent(virtual); + } + + private static int firstFuel(@NotNull List virtual) { + for (int i = 0; i < virtual.size(); i++) { + ItemStack stack = virtual.get(i); + if (!ItemCompat.isEmpty(stack) && FuelTable.isFuel(stack)) { + return i; + } + } + return -1; + } + + private static int firstPresent(@NotNull List virtual) { + for (int i = 0; i < virtual.size(); i++) { + if (!ItemCompat.isEmpty(virtual.get(i))) { + return i; + } + } + return -1; + } + + private PassiveLaneProjection() { + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/nbt/FurnaceNbtInstrumentation.java b/spigot/src/main/java/io/github/eat_ram/fuream/nbt/FurnaceNbtInstrumentation.java new file mode 100644 index 0000000..af76bc6 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/nbt/FurnaceNbtInstrumentation.java @@ -0,0 +1,234 @@ +package io.github.eat_ram.fuream.nbt; + +import java.lang.instrument.Instrumentation; +import java.io.File; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.HashMap; +import java.util.Map; + +import net.bytebuddy.agent.ByteBuddyAgent; +import net.bytebuddy.agent.builder.AgentBuilder; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.dynamic.ClassFileLocator; +import net.bytebuddy.dynamic.loading.ClassInjector; +import net.bytebuddy.description.method.MethodDescription; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.matcher.ElementMatcher; +import org.bukkit.Bukkit; +import org.bukkit.plugin.java.JavaPlugin; + +import static net.bytebuddy.matcher.ElementMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.takesArguments; + +/** + * Adds the two persistence hooks that Fabric gets from its furnace mixin. + * Spigot otherwise discards unknown block-entity root tags during load/save. + */ +public final class FurnaceNbtInstrumentation { + private static final String RUNTIME_NAME = FurnaceNbtRuntime.class.getName(); + + public static void install(JavaPlugin plugin) { + Class furnaceClass = findFurnaceClass(); + Method loadMethod = findPersistenceMethod(furnaceClass, true); + Method saveMethod = findPersistenceMethod(furnaceClass, false); + Instrumentation instrumentation; + try { + instrumentation = ByteBuddyAgent.install(); + } catch (IllegalStateException defaultAttachmentFailure) { + try { + instrumentation = ByteBuddyAgent.install( + ByteBuddyAgent.AttachmentProvider.ForEmulatedAttachment.INSTANCE + ); + plugin.getLogger().info( + "Default JVM attachment unavailable; using bundled native attachment provider" + ); + } catch (RuntimeException emulatedAttachmentFailure) { + emulatedAttachmentFailure.addSuppressed(defaultAttachmentFailure); + throw emulatedAttachmentFailure; + } + } + + if (!instrumentation.isModifiableClass(furnaceClass)) { + throw new IllegalStateException("Furnace block entity class is not modifiable: " + furnaceClass.getName()); + } + + Class runtimeClass = installRuntime(instrumentation, furnaceClass, plugin); + FurnaceRootNbtBridge.bindRuntime(runtimeClass); + try { + if (Boolean.TRUE.equals(runtimeClass.getMethod("isInstalled").invoke(null))) return; + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Unable to query root-NBT runtime", e); + } + + ElementMatcher.Junction loadMatcher = named(loadMethod.getName()) + .and(takesArguments(loadMethod.getParameterTypes())); + ElementMatcher.Junction saveMatcher = named(saveMethod.getName()) + .and(takesArguments(saveMethod.getParameterTypes())); + + new AgentBuilder.Default() + .disableClassFormatChanges() + .with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION) + .type(named(furnaceClass.getName())) + .transform((builder, type, classLoader, module, protectionDomain) -> builder + .visit(Advice.to(LoadAdvice.class).on(loadMatcher)) + .visit(Advice.to(SaveAdvice.class).on(saveMatcher))) + .installOn(instrumentation); + + try { + runtimeClass.getMethod("markInstalled").invoke(null); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Unable to finalize root-NBT runtime", e); + } + plugin.getLogger().info( + "Installed Fabric-compatible furnace root-NBT hooks on " + furnaceClass.getName() + ); + } + + private static Class installRuntime( + Instrumentation instrumentation, Class furnaceClass, JavaPlugin plugin + ) { + try { + return Class.forName(RUNTIME_NAME, false, null); + } catch (ClassNotFoundException ignored) { + } + try { + File injectionFolder = new File(plugin.getDataFolder(), ".runtime"); + if (!injectionFolder.isDirectory() && !injectionFolder.mkdirs()) { + throw new IllegalStateException("Unable to create " + injectionFolder); + } + Map definitions = new HashMap<>(); + definitions.put( + new TypeDescription.ForLoadedType(FurnaceNbtRuntime.class), + ClassFileLocator.ForClassLoader.read(FurnaceNbtRuntime.class) + ); + ClassInjector.UsingInstrumentation.of( + injectionFolder, ClassInjector.UsingInstrumentation.Target.BOOTSTRAP, instrumentation + ).inject(definitions); + return Class.forName(RUNTIME_NAME, true, null); + } catch (Exception e) { + throw new IllegalStateException("Unable to install the root-NBT runtime", e); + } + } + + private static Class findFurnaceClass() { + String[] candidates = { + "net.minecraft.world.level.block.entity.AbstractFurnaceBlockEntity", + "net.minecraft.world.level.block.entity.TileEntityFurnace" + }; + for (String candidate : candidates) { + try { + return Class.forName(candidate, false, Bukkit.getServer().getClass().getClassLoader()); + } catch (ClassNotFoundException ignored) { + } + } + + String craftPackage = Bukkit.getServer().getClass().getPackage().getName(); + String version = craftPackage.substring(craftPackage.lastIndexOf('.') + 1); + try { + return Class.forName( + "net.minecraft.server." + version + ".TileEntityFurnace", + false, + Bukkit.getServer().getClass().getClassLoader() + ); + } catch (ClassNotFoundException e) { + throw new IllegalStateException("Unable to locate the furnace block entity class", e); + } + } + + private static Method findPersistenceMethod(Class furnaceClass, boolean load) { + String[] preferredNames = load + ? new String[] {"loadAdditional", "load", "loadData", "a"} + : new String[] {"saveAdditional", "save", "saveData", "b"}; + List candidates = new ArrayList<>(); + for (Class current = furnaceClass; current != null; current = current.getSuperclass()) { + for (Method method : current.getDeclaredMethods()) { + if (!Modifier.isStatic(method.getModifiers()) && hasCompoundArgument(method)) { + candidates.add(method); + } + } + } + + for (String name : preferredNames) { + for (Method method : candidates) { + if (name.equals(method.getName()) && returnTypeMatches(method, load)) { + return method; + } + } + } + + for (Method method : candidates) { + int modifiers = method.getModifiers(); + boolean accessMatches = load + ? Modifier.isPublic(modifiers) + : Modifier.isProtected(modifiers) || !Modifier.isPublic(modifiers); + if (accessMatches && returnTypeMatches(method, load)) return method; + } + + throw new IllegalStateException( + "Unable to locate furnace " + (load ? "load" : "save") + " method on " + + furnaceClass.getName() + "; candidate methods=" + Arrays.toString(candidates.toArray()) + ); + } + + private static boolean returnTypeMatches(Method method, boolean load) { + if (load) return method.getReturnType() == void.class; + if (method.getReturnType() == void.class) return true; + String name = method.getReturnType().getName(); + return name.endsWith(".CompoundTag") || name.endsWith(".NBTTagCompound"); + } + + private static boolean hasCompoundArgument(Method method) { + for (Class parameter : method.getParameterTypes()) { + String name = parameter.getName(); + if (name.endsWith(".CompoundTag") || name.endsWith(".NBTTagCompound")) return true; + } + return false; + } + + public static class LoadAdvice { + @Advice.OnMethodExit(suppress = Throwable.class) + public static void exit( + @Advice.This Object furnace, + @Advice.AllArguments Object[] arguments + ) { + Object rootNbt = null; + for (Object argument : arguments) { + if (argument == null) continue; + String name = argument.getClass().getName(); + if (name.endsWith(".CompoundTag") || name.endsWith(".NBTTagCompound")) { + rootNbt = argument; + break; + } + } + if (rootNbt == null) return; + FurnaceNbtRuntime.capture(furnace, rootNbt, "FureamData"); + } + } + + public static class SaveAdvice { + @Advice.OnMethodExit(suppress = Throwable.class) + public static void exit( + @Advice.This Object furnace, + @Advice.AllArguments Object[] arguments + ) { + Object rootNbt = null; + for (Object argument : arguments) { + if (argument == null) continue; + String name = argument.getClass().getName(); + if (name.endsWith(".CompoundTag") || name.endsWith(".NBTTagCompound")) { + rootNbt = argument; + break; + } + } + if (rootNbt == null) return; + FurnaceNbtRuntime.inject(furnace, rootNbt, "FureamData"); + } + } + + private FurnaceNbtInstrumentation() { + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/nbt/FurnaceNbtRuntime.java b/spigot/src/main/java/io/github/eat_ram/fuream/nbt/FurnaceNbtRuntime.java new file mode 100644 index 0000000..e048ab7 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/nbt/FurnaceNbtRuntime.java @@ -0,0 +1,138 @@ +package io.github.eat_ram.fuream.nbt; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Collections; +import java.util.Map; +import java.util.WeakHashMap; + +/** + * Pure-JDK state used by instrumented NMS classes. A second copy of this class + * is injected into the bootstrap class loader, so it must not reference Bukkit or + * plugin classes. + */ +public final class FurnaceNbtRuntime { + private static final Map STATES = + Collections.synchronizedMap(new WeakHashMap()); + private static final Map, Field> TAG_FIELDS = + Collections.synchronizedMap(new WeakHashMap, Field>()); + private static final Map, Method> SHORT_SETTERS = + Collections.synchronizedMap(new WeakHashMap, Method>()); + private static final ThreadLocal SUPPRESSED = new ThreadLocal(); + private static volatile boolean installed; + private static volatile long failureCount; + private static volatile String lastFailure = ""; + + public static void capture(Object tileEntity, Object rootNbt, String dataKey) { + try { + Object data = tags(rootNbt).get(dataKey); + STATES.put(tileEntity, new Object[] {data, rootNbt}); + } catch (Throwable failure) { + recordFailure(failure); + } + } + + public static void inject(Object tileEntity, Object rootNbt, String dataKey) { + if (Boolean.TRUE.equals(SUPPRESSED.get())) return; + try { + Object[] stored = STATES.get(tileEntity); + if (stored == null || stored.length == 0) return; + if (stored[0] == null) tags(rootNbt).remove(dataKey); + else tags(rootNbt).put(dataKey, stored[0]); + if (stored.length >= 4 && stored[1] instanceof Integer) { + setShort(rootNbt, "BurnTime", ((Integer) stored[1]).shortValue()); + setShort(rootNbt, "CookTime", ((Integer) stored[2]).shortValue()); + setShort(rootNbt, "CookTimeTotal", ((Integer) stored[3]).shortValue()); + } + } catch (Throwable failure) { + recordFailure(failure); + } + } + + public static Object[] getState(Object tileEntity) { + return STATES.get(tileEntity); + } + + public static void putState(Object tileEntity, Object[] state) { + if (tileEntity != null && state != null) STATES.put(tileEntity, state); + } + + public static void setSuppressed(boolean suppressed) { + if (suppressed) SUPPRESSED.set(Boolean.TRUE); + else SUPPRESSED.remove(); + } + + public static boolean isInstalled() { + return installed; + } + + public static void markInstalled() { + installed = true; + } + + public static long getFailureCount() { + return failureCount; + } + + public static String getLastFailure() { + return lastFailure; + } + + @SuppressWarnings("unchecked") + private static Map tags(Object compound) throws ReflectiveOperationException { + Class type = compound.getClass(); + Field field = TAG_FIELDS.get(type); + if (field == null) { + field = findTagField(type); + TAG_FIELDS.put(type, field); + } + return (Map) field.get(compound); + } + + private static Field findTagField(Class type) throws NoSuchFieldException { + for (Class current = type; current != null; current = current.getSuperclass()) { + for (Field field : current.getDeclaredFields()) { + if (!Modifier.isStatic(field.getModifiers()) && Map.class.isAssignableFrom(field.getType())) { + field.setAccessible(true); + return field; + } + } + } + throw new NoSuchFieldException("NBT tag map on " + type.getName()); + } + + private static void setShort(Object compound, String key, short value) + throws ReflectiveOperationException { + Class type = compound.getClass(); + Method setter = SHORT_SETTERS.get(type); + if (setter == null) { + setter = findShortSetter(type); + SHORT_SETTERS.put(type, setter); + } + setter.invoke(compound, key, value); + } + + private static Method findShortSetter(Class type) throws NoSuchMethodException { + for (Class current = type; current != null; current = current.getSuperclass()) { + for (Method method : current.getDeclaredMethods()) { + Class[] parameters = method.getParameterTypes(); + if (!Modifier.isStatic(method.getModifiers()) && parameters.length == 2 && + parameters[0] == String.class && parameters[1] == short.class && + method.getReturnType() == void.class) { + method.setAccessible(true); + return method; + } + } + } + throw new NoSuchMethodException("NBT short setter on " + type.getName()); + } + + private static void recordFailure(Throwable failure) { + failureCount++; + lastFailure = failure.getClass().getName() + ": " + String.valueOf(failure.getMessage()); + } + + private FurnaceNbtRuntime() { + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/nbt/FurnaceRootNbtBridge.java b/spigot/src/main/java/io/github/eat_ram/fuream/nbt/FurnaceRootNbtBridge.java new file mode 100644 index 0000000..e7c9ff9 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/nbt/FurnaceRootNbtBridge.java @@ -0,0 +1,277 @@ +package io.github.eat_ram.fuream.nbt; + +import java.lang.reflect.Method; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import io.github.eat_ram.fuream.data.FureamData; +import io.github.eat_ram.fuream.hook.FurnaceManager.FurnaceContext; +import io.github.eat_ram.fuream.logic.NativeLaneProjection; +import org.bukkit.block.BlockState; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** Bridges instrumented NMS furnace serialization to the Bukkit-side context. */ +public final class FurnaceRootNbtBridge { + private static final String PASSIVE_PROJECTION_KEY = "PassiveProjected"; + private static final String ACTIVE_PROJECTION_KEY = "SpigotProjection"; + private static final String FUEL_SLOT_KEY = "FuelSlot"; + private static final String FUEL_AMOUNT_KEY = "FuelAmount"; + private static final String OUTPUT_SLOT_KEY = "OutputSlot"; + private static final String OUTPUT_AMOUNT_KEY = "OutputAmount"; + + private static final ConcurrentMap, Method> TILE_ENTITY_METHODS = new ConcurrentHashMap<>(); + private static volatile Method runtimeGetState; + private static volatile Method runtimePutState; + private static volatile Method runtimeSetSuppressed; + private static volatile Method runtimeFailureCount; + private static volatile Method runtimeLastFailure; + + public static void bindRuntime(@NotNull Class runtimeClass) { + try { + runtimeGetState = runtimeClass.getMethod("getState", Object.class); + runtimePutState = runtimeClass.getMethod("putState", Object.class, Object[].class); + runtimeSetSuppressed = runtimeClass.getMethod("setSuppressed", boolean.class); + runtimeFailureCount = runtimeClass.getMethod("getFailureCount"); + runtimeLastFailure = runtimeClass.getMethod("getLastFailure"); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Unable to bind the root-NBT runtime", e); + } + } + + public static boolean loadNativeData(@NotNull BlockState state, @NotNull FurnaceContext ctx) { + Object tileEntity = getTileEntity(state); + if (tileEntity == null) return false; + Object[] stored = runtimeState(tileEntity); + if (stored == null || stored.length < 2 || stored[0] == null) return false; + + NativeNbtCompound dataNbt = new NativeNbtCompound(stored[0]); + ctx.data.readNbt(ctx, dataNbt); + for (Map.Entry entry : ctx.extraData.entrySet()) { + entry.getValue().readNbt(ctx, dataNbt); + } + ctx.passiveProjected = hasPassiveProjectionMarker(dataNbt); + readActiveProjectionMarker(dataNbt, ctx); + + if (stored[1] != null && !(stored[1] instanceof Integer)) { + NativeNbtCompound rootNbt = new NativeNbtCompound(stored[1]); + ctx.burnTime = readNumericTag(rootNbt, "BurnTime", ctx.burnTime); + ctx.cookTime = readNumericTag(rootNbt, "CookTime", ctx.cookTime); + ctx.cookTimeTotal = readNumericTag(rootNbt, "CookTimeTotal", ctx.cookTimeTotal); + } else if (stored.length >= 4) { + ctx.burnTime = (Integer) stored[1]; + ctx.cookTime = (Integer) stored[2]; + ctx.cookTimeTotal = (Integer) stored[3]; + } + + putRuntimeState(tileEntity, new Object[] { + stored[0], ctx.burnTime, ctx.cookTime, ctx.cookTimeTotal + }); + return true; + } + + public static void store(@NotNull BlockState state, @NotNull FurnaceContext ctx) { + Object tileEntity = getTileEntity(state); + if (tileEntity == null) { + throw new IllegalStateException("Unable to access the live furnace block entity"); + } + + NativeNbtCompound dataNbt = NativeNbtCompound.createForServer(); + ctx.data.writeNbt(ctx, dataNbt); + for (FureamData extra : ctx.extraData.values()) extra.writeNbt(ctx, dataNbt); + if (ctx.passiveProjected) dataNbt.setInt(PASSIVE_PROJECTION_KEY, 1); + writeActiveProjectionMarker(dataNbt, ctx); + boolean dataEmpty = dataNbt.keys().isEmpty(); + putRuntimeState(tileEntity, new Object[] { + shouldKeepDataTag(dataEmpty, ctx.passiveProjected || ctx.activeProjected) + ? dataNbt.raw() : null, + ctx.burnTime, ctx.cookTime, ctx.cookTimeTotal + }); + markTileDirty(tileEntity); + } + + public static void updateProjectionMarker(@NotNull BlockState state, @NotNull FurnaceContext ctx) { + Object tileEntity = getTileEntity(state); + if (tileEntity == null) return; + Object[] stored = runtimeState(tileEntity); + if (stored == null || stored.length == 0 || stored[0] == null) { + if (ctx.activeProjected) store(state, ctx); + return; + } + writeActiveProjectionMarker(new NativeNbtCompound(stored[0]), ctx); + markTileDirty(tileEntity); + } + + public static boolean hasPassiveProjectionMarker(@Nullable NativeNbtCompound dataNbt) { + return dataNbt != null && dataNbt.getInt(PASSIVE_PROJECTION_KEY, 0) != 0; + } + + static boolean shouldKeepDataTag(boolean dataEmpty, boolean projected) { + return !dataEmpty || projected; + } + + public static @Nullable NativeNbtCompound capturedRoot(@NotNull BlockState state) { + Object tileEntity = getTileEntity(state); + if (tileEntity == null) return null; + Object[] stored = runtimeState(tileEntity); + if (stored == null || stored.length < 2 || stored[1] == null || stored[1] instanceof Integer) { + return null; + } + return new NativeNbtCompound(stored[1]); + } + + public static void withoutInjection(@NotNull Runnable action) { + setRuntimeSuppressed(true); + try { + action.run(); + } finally { + setRuntimeSuppressed(false); + } + } + + public static long failureCount() { + try { + Method method = runtimeFailureCount; + return method == null ? 0L : ((Number) method.invoke(null)).longValue(); + } catch (ReflectiveOperationException e) { + return -1L; + } + } + + public static @NotNull String lastFailure() { + try { + Method method = runtimeLastFailure; + Object value = method == null ? "runtime unavailable" : method.invoke(null); + return value == null ? "" : value.toString(); + } catch (ReflectiveOperationException e) { + return e.toString(); + } + } + + private static @Nullable Object[] runtimeState(Object tileEntity) { + Method method = runtimeGetState; + if (method == null) return null; + try { + return (Object[]) method.invoke(null, tileEntity); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Unable to read root-NBT runtime state", e); + } + } + + private static void putRuntimeState(Object tileEntity, Object[] state) { + Method method = runtimePutState; + if (method == null) throw new IllegalStateException("Root-NBT runtime is unavailable"); + try { + method.invoke(null, tileEntity, (Object) state); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Unable to update root-NBT runtime state", e); + } + } + + private static void setRuntimeSuppressed(boolean suppressed) { + Method method = runtimeSetSuppressed; + if (method == null) return; + try { + method.invoke(null, suppressed); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Unable to change root-NBT runtime scope", e); + } + } + + private static @Nullable Object getTileEntity(@NotNull BlockState state) { + try { + Method method = TILE_ENTITY_METHODS.get(state.getClass()); + if (method == null) { + method = findTileEntityMethod(state.getClass()); + TILE_ENTITY_METHODS.put(state.getClass(), method); + } + return method.invoke(state); + } catch (ReflectiveOperationException e) { + return null; + } + } + + private static @NotNull Method findTileEntityMethod(@NotNull Class stateClass) + throws NoSuchMethodException { + for (String preferred : new String[] {"getTileEntityFromWorld", "getTileEntity"}) { + for (Class current = stateClass; current != null; current = current.getSuperclass()) { + for (Method method : current.getDeclaredMethods()) { + if (preferred.equals(method.getName()) && method.getParameterTypes().length == 0) { + method.setAccessible(true); + return method; + } + } + } + } + throw new NoSuchMethodException("No live tile entity accessor on " + stateClass.getName()); + } + + private static void markTileDirty(Object tileEntity) { + for (String name : new String[] {"setChanged", "markDirty"}) { + try { + Method method = tileEntity.getClass().getMethod(name); + method.invoke(tileEntity); + return; + } catch (ReflectiveOperationException ignored) { + } + } + } + + private static int readNumericTag(@Nullable NativeNbtCompound nbt, @NotNull String key, int fallback) { + return nbt == null ? fallback : nbt.getInt(key, fallback); + } + + private static void writeActiveProjectionMarker( + @NotNull NativeNbtCompound dataNbt, @NotNull FurnaceContext ctx + ) { + if (!ctx.activeProjected) { + dataNbt.remove(ACTIVE_PROJECTION_KEY); + return; + } + NativeNbtCompound marker = dataNbt.getOrCreateCompound(ACTIVE_PROJECTION_KEY); + writeLaneMarker(marker, FUEL_SLOT_KEY, FUEL_AMOUNT_KEY, ctx.activeFuel); + writeLaneMarker(marker, OUTPUT_SLOT_KEY, OUTPUT_AMOUNT_KEY, ctx.activeOutput); + } + + private static void writeLaneMarker( + NativeNbtCompound marker, String slotKey, String amountKey, NativeLaneProjection.State state + ) { + ItemStack published = state.getPublished(); + marker.setInt(slotKey, state.getSourceSlot()); + marker.setInt(amountKey, published == null ? 0 : published.getAmount()); + } + + private static void readActiveProjectionMarker( + @NotNull NativeNbtCompound dataNbt, @NotNull FurnaceContext ctx + ) { + NativeNbtCompound marker = dataNbt.getCompound(ACTIVE_PROJECTION_KEY); + if (marker == null) return; + boolean fuel = restoreLaneMarker( + marker, FUEL_SLOT_KEY, FUEL_AMOUNT_KEY, ctx.data.fuels, ctx.activeFuel + ); + boolean output = restoreLaneMarker( + marker, OUTPUT_SLOT_KEY, OUTPUT_AMOUNT_KEY, ctx.data.outputs, ctx.activeOutput + ); + ctx.activeProjected = fuel || output; + } + + private static boolean restoreLaneMarker( + NativeNbtCompound marker, String slotKey, String amountKey, + java.util.List lane, NativeLaneProjection.State state + ) { + int slot = marker.getInt(slotKey, -1); + int amount = marker.getInt(amountKey, 0); + if (slot < 0 || slot >= lane.size() || amount <= 0) return false; + ItemStack stack = lane.get(slot); + if (io.github.eat_ram.fuream.compat.ItemCompat.isEmpty(stack) || stack.getAmount() != amount) { + return false; + } + state.restore(slot, stack); + return true; + } + + private FurnaceRootNbtBridge() { + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/nbt/NativeItemNbt.java b/spigot/src/main/java/io/github/eat_ram/fuream/nbt/NativeItemNbt.java new file mode 100644 index 0000000..005b844 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/nbt/NativeItemNbt.java @@ -0,0 +1,166 @@ +package io.github.eat_ram.fuream.nbt; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Optional; + +import io.github.eat_ram.fuream.compat.ItemCompat; +import org.bukkit.Bukkit; +import org.bukkit.inventory.ItemStack; + +/** Native ItemStack codec, including registry-provider signatures used by 1.20.5+. */ +public final class NativeItemNbt { + private static Method asNmsCopy; + private static Method asBukkitCopy; + private static Method writeMethod; + private static Method readMethod; + private static Object registryProvider; + + public static NativeNbtCompound write(ItemStack stack) { + if (ItemCompat.isEmpty(stack)) return NativeNbtCompound.createForServer(); + try { + ensureCraftMethods(); + Object nms = asNmsCopy.invoke(null, stack); + NativeNbtCompound target = NativeNbtCompound.createForServer(); + if (writeMethod != null) return invokeWrite(nms, target, writeMethod); + for (Method method : nms.getClass().getMethods()) { + if (Modifier.isStatic(method.getModifiers())) continue; + Object[] args = serializationArguments(method.getParameterTypes(), target.raw()); + if (args == null) continue; + Class returnType = method.getReturnType(); + if (!returnType.isAssignableFrom(target.raw().getClass()) && + !target.raw().getClass().isAssignableFrom(returnType)) continue; + try { + Object result = method.invoke(nms, args); + writeMethod = method; + if (result != null && target.raw().getClass().isInstance(result)) { + return new NativeNbtCompound(result); + } + return target; + } catch (ReflectiveOperationException | IllegalArgumentException ignored) { + } + } + throw new NbtCodecException("Unable to locate native ItemStack save method"); + } catch (ReflectiveOperationException e) { + throw new NbtCodecException("Unable to serialize Bukkit ItemStack", e); + } + } + + public static ItemStack read(NativeNbtCompound compound) { + if (compound == null || compound.keys().isEmpty()) return ItemCompat.empty(); + try { + ensureCraftMethods(); + Class nmsClass = asNmsCopy.getReturnType(); + if (readMethod != null) return invokeRead(compound, nmsClass, readMethod); + Throwable lastFailure = null; + for (Method method : nmsClass.getMethods()) { + if (!Modifier.isStatic(method.getModifiers())) continue; + Object[] args = serializationArguments(method.getParameterTypes(), compound.raw()); + if (args == null) continue; + if (method.getReturnType() != nmsClass && method.getReturnType() != Optional.class) continue; + try { + Object result = method.invoke(null, args); + if (result instanceof Optional) result = ((Optional) result).orElse(null); + if (result != null && nmsClass.isInstance(result)) { + ItemStack stack = (ItemStack) asBukkitCopy.invoke(null, result); + if (stack != null && !ItemCompat.isEmpty(stack)) { + readMethod = method; + return stack; + } + } + } catch (ReflectiveOperationException | IllegalArgumentException failure) { + lastFailure = failure; + } + } + throw new NbtCodecException("Unable to locate native ItemStack load method", lastFailure); + } catch (ReflectiveOperationException e) { + throw new NbtCodecException("Unable to deserialize native ItemStack", e); + } + } + + public static void initialize() { + try { + ensureCraftMethods(); + } catch (ReflectiveOperationException e) { + throw new NbtCodecException("Unable to initialize CraftItemStack conversion", e); + } + } + + private static NativeNbtCompound invokeWrite( + Object nms, NativeNbtCompound target, Method method + ) throws ReflectiveOperationException { + Object[] args = serializationArguments(method.getParameterTypes(), target.raw()); + if (args == null) throw new NbtCodecException("Cached ItemStack save method is no longer compatible"); + Object result = method.invoke(nms, args); + return result != null && target.raw().getClass().isInstance(result) + ? new NativeNbtCompound(result) : target; + } + + private static ItemStack invokeRead( + NativeNbtCompound compound, Class nmsClass, Method method + ) throws ReflectiveOperationException { + Object[] args = serializationArguments(method.getParameterTypes(), compound.raw()); + if (args == null) throw new NbtCodecException("Cached ItemStack load method is no longer compatible"); + Object result = method.invoke(null, args); + if (result instanceof Optional) result = ((Optional) result).orElse(null); + if (result == null || !nmsClass.isInstance(result)) { + throw new NbtCodecException("Native ItemStack load method returned no item"); + } + ItemStack stack = (ItemStack) asBukkitCopy.invoke(null, result); + if (stack == null || ItemCompat.isEmpty(stack)) { + throw new NbtCodecException("Native ItemStack decoded as an empty item"); + } + return stack; + } + + private static Object[] serializationArguments(Class[] parameterTypes, Object compound) { + if (parameterTypes.length == 0 || parameterTypes.length > 2) return null; + Object[] args = new Object[parameterTypes.length]; + boolean hasCompound = false; + for (int i = 0; i < parameterTypes.length; i++) { + Class type = parameterTypes[i]; + if (type.isInstance(compound)) { + args[i] = compound; + hasCompound = true; + } else if (type.getName().contains("HolderLookup") || type.getName().contains("RegistryAccess")) { + Object provider = registryProvider(type); + if (provider == null || !type.isInstance(provider)) return null; + args[i] = provider; + } else { + return null; + } + } + return hasCompound ? args : null; + } + + private static void ensureCraftMethods() throws ReflectiveOperationException { + if (asNmsCopy != null && asBukkitCopy != null) return; + String craftRoot = Bukkit.getServer().getClass().getPackage().getName(); + Class craftItemStack = Class.forName(craftRoot + ".inventory.CraftItemStack"); + asNmsCopy = craftItemStack.getMethod("asNMSCopy", ItemStack.class); + Class nmsClass = asNmsCopy.getReturnType(); + asBukkitCopy = craftItemStack.getMethod("asBukkitCopy", nmsClass); + } + + private static Object registryProvider(Class expectedType) { + if (registryProvider != null && expectedType.isInstance(registryProvider)) return registryProvider; + try { + Object craftServer = Bukkit.getServer(); + Object minecraftServer = craftServer.getClass().getMethod("getServer").invoke(craftServer); + for (Method method : minecraftServer.getClass().getMethods()) { + if (method.getParameterTypes().length == 0 && expectedType.isAssignableFrom(method.getReturnType())) { + Object result = method.invoke(minecraftServer); + if (result != null) { + registryProvider = result; + return result; + } + } + } + } catch (ReflectiveOperationException ignored) { + } + return null; + } + + private NativeItemNbt() { + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/nbt/NativeNbtCompound.java b/spigot/src/main/java/io/github/eat_ram/fuream/nbt/NativeNbtCompound.java new file mode 100644 index 0000000..543f6d9 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/nbt/NativeNbtCompound.java @@ -0,0 +1,246 @@ +package io.github.eat_ram.fuream.nbt; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.bukkit.Bukkit; + +/** Minimal reflective NBT view shared by legacy, flattened and component servers. */ +public final class NativeNbtCompound { + private static final Map, Field> MAP_FIELDS = new ConcurrentHashMap<>(); + private static final Map SETTERS = new ConcurrentHashMap<>(); + private static final Map, Class> LIST_CLASSES = new ConcurrentHashMap<>(); + private static final Map, Field> PRIMITIVE_FIELDS = new ConcurrentHashMap<>(); + private static volatile Class serverCompoundClass; + + private final Object raw; + + public NativeNbtCompound(Object raw) { + if (raw == null) throw new IllegalArgumentException("raw NBT compound cannot be null"); + this.raw = raw; + } + + public Object raw() { + return raw; + } + + public static NativeNbtCompound create(Class compoundClass) { + try { + Constructor constructor = compoundClass.getDeclaredConstructor(); + constructor.setAccessible(true); + return new NativeNbtCompound(constructor.newInstance()); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Unable to create " + compoundClass.getName(), e); + } + } + + public static NativeNbtCompound createForServer() { + Class cached = serverCompoundClass; + if (cached != null) return create(cached); + ClassLoader loader = Bukkit.getServer().getClass().getClassLoader(); + String craftPackage = Bukkit.getServer().getClass().getPackage().getName(); + String version = craftPackage.substring(craftPackage.lastIndexOf('.') + 1); + String[] candidates = { + "net.minecraft.nbt.CompoundTag", + "net.minecraft.nbt.NBTTagCompound", + "net.minecraft.server." + version + ".NBTTagCompound" + }; + for (String candidate : candidates) { + try { + Class located = Class.forName(candidate, false, loader); + serverCompoundClass = located; + return create(located); + } catch (ClassNotFoundException ignored) { + } + } + throw new IllegalStateException("Unable to locate the server NBT compound class"); + } + + public boolean has(String key) { + return tags().containsKey(key); + } + + public void remove(String key) { + tags().remove(key); + } + + public Set keys() { + return Collections.unmodifiableSet(tags().keySet()); + } + + public NativeNbtCompound getCompound(String key) { + Object value = tags().get(key); + return isCompound(value) ? new NativeNbtCompound(value) : null; + } + + public NativeNbtCompound getOrCreateCompound(String key) { + NativeNbtCompound existing = getCompound(key); + if (existing != null) return existing; + NativeNbtCompound created = create(raw.getClass()); + tags().put(key, created.raw()); + return created; + } + + public NativeNbtList getList(String key) { + Object value = tags().get(key); + return value == null ? null : new NativeNbtList(value); + } + + public NativeNbtList resetList(String key) { + Class listClass = LIST_CLASSES.get(raw.getClass()); + if (listClass == null) { + listClass = locateListClass(raw.getClass()); + LIST_CLASSES.put(raw.getClass(), listClass); + } + try { + Constructor constructor = listClass.getDeclaredConstructor(); + constructor.setAccessible(true); + Object list = constructor.newInstance(); + tags().put(key, list); + return new NativeNbtList(list); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Unable to create NBT list", e); + } + } + + public void setString(String key, String value) { + invokeSetter(String.class, key, value); + } + + public void setInt(String key, int value) { + invokeSetter(int.class, key, value); + } + + public void setFloat(String key, float value) { + invokeSetter(float.class, key, value); + } + + public void setShort(String key, short value) { + invokeSetter(short.class, key, value); + } + + public String getString(String key) { + Object value = primitiveValue(tags().get(key)); + return value instanceof String ? (String) value : ""; + } + + public int getInt(String key, int fallback) { + Object value = primitiveValue(tags().get(key)); + return value instanceof Number ? ((Number) value).intValue() : fallback; + } + + public float getFloat(String key, float fallback) { + Object value = primitiveValue(tags().get(key)); + return value instanceof Number ? ((Number) value).floatValue() : fallback; + } + + @SuppressWarnings("unchecked") + private Map tags() { + try { + Field field = MAP_FIELDS.get(raw.getClass()); + if (field == null) { + field = findMapField(raw.getClass()); + MAP_FIELDS.put(raw.getClass(), field); + } + return (Map) field.get(raw); + } catch (IllegalAccessException e) { + throw new IllegalStateException("Unable to access NBT compound", e); + } + } + + private void invokeSetter(Class valueType, String key, Object value) { + String cacheKey = raw.getClass().getName() + '#' + valueType.getName(); + Method method = SETTERS.get(cacheKey); + if (method == null) { + method = findSetter(raw.getClass(), valueType); + SETTERS.put(cacheKey, method); + } + try { + method.invoke(raw, key, value); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Unable to write NBT value " + key, e); + } + } + + private static Method findSetter(Class type, Class valueType) { + for (Class current = type; current != null; current = current.getSuperclass()) { + for (Method method : current.getDeclaredMethods()) { + Class[] parameters = method.getParameterTypes(); + if (!Modifier.isStatic(method.getModifiers()) && parameters.length == 2 && + parameters[0] == String.class && parameters[1] == valueType && + method.getReturnType() == void.class) { + method.setAccessible(true); + return method; + } + } + } + throw new IllegalStateException("Unable to locate NBT setter for " + valueType.getName()); + } + + private static Field findMapField(Class type) { + for (Class current = type; current != null; current = current.getSuperclass()) { + for (Field field : current.getDeclaredFields()) { + if (!Modifier.isStatic(field.getModifiers()) && Map.class.isAssignableFrom(field.getType())) { + field.setAccessible(true); + return field; + } + } + } + throw new IllegalStateException("Unable to locate NBT compound map on " + type.getName()); + } + + private static Class locateListClass(Class compoundClass) { + String packageName = compoundClass.getPackage().getName(); + ClassLoader loader = compoundClass.getClassLoader(); + String[] candidates = { + packageName + ".ListTag", + packageName + ".NBTTagList" + }; + for (String candidate : candidates) { + try { + return Class.forName(candidate, false, loader); + } catch (ClassNotFoundException ignored) { + } + } + throw new IllegalStateException("Unable to locate NBT list next to " + compoundClass.getName()); + } + + private static boolean isCompound(Object value) { + if (value == null) return false; + String name = value.getClass().getSimpleName(); + return name.equals("CompoundTag") || name.equals("NBTTagCompound"); + } + + private static Object primitiveValue(Object tag) { + if (tag == null) return null; + Field cached = PRIMITIVE_FIELDS.get(tag.getClass()); + if (cached != null) { + try { + return cached.get(tag); + } catch (IllegalAccessException ignored) { + } + } + for (Class current = tag.getClass(); current != null; current = current.getSuperclass()) { + for (Field field : current.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers())) continue; + Class type = field.getType(); + if (type == String.class || type.isPrimitive() || Number.class.isAssignableFrom(type)) { + try { + field.setAccessible(true); + PRIMITIVE_FIELDS.put(tag.getClass(), field); + return field.get(tag); + } catch (IllegalAccessException ignored) { + } + } + } + } + return null; + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/nbt/NativeNbtList.java b/spigot/src/main/java/io/github/eat_ram/fuream/nbt/NativeNbtList.java new file mode 100644 index 0000000..9c22f14 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/nbt/NativeNbtList.java @@ -0,0 +1,83 @@ +package io.github.eat_ram.fuream.nbt; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public final class NativeNbtList { + private static final Map, Field> LIST_FIELDS = new ConcurrentHashMap<>(); + private static final Map, Method> ADD_METHODS = new ConcurrentHashMap<>(); + private final Object raw; + + NativeNbtList(Object raw) { + this.raw = raw; + } + + public int size() { + return values().size(); + } + + public NativeNbtCompound getCompound(int index) { + Object value = values().get(index); + return value == null ? null : new NativeNbtCompound(value); + } + + public void add(NativeNbtCompound compound) { + if (raw instanceof List) { + @SuppressWarnings("unchecked") + List list = (List) raw; + list.add(compound.raw()); + return; + } + Method cached = ADD_METHODS.get(raw.getClass()); + if (cached != null) { + try { + cached.invoke(raw, compound.raw()); + return; + } catch (ReflectiveOperationException ignored) { + ADD_METHODS.remove(raw.getClass()); + } + } + for (Method method : raw.getClass().getMethods()) { + Class[] parameters = method.getParameterTypes(); + if (parameters.length == 1 && parameters[0].isInstance(compound.raw())) { + try { + method.invoke(raw, compound.raw()); + ADD_METHODS.put(raw.getClass(), method); + return; + } catch (ReflectiveOperationException ignored) { + } + } + } + throw new IllegalStateException("Unable to append to NBT list " + raw.getClass().getName()); + } + + @SuppressWarnings("unchecked") + private List values() { + try { + Field field = LIST_FIELDS.get(raw.getClass()); + if (field == null) { + field = findListField(raw.getClass()); + LIST_FIELDS.put(raw.getClass(), field); + } + return (List) field.get(raw); + } catch (IllegalAccessException e) { + throw new IllegalStateException("Unable to access NBT list", e); + } + } + + private static Field findListField(Class type) { + for (Class current = type; current != null; current = current.getSuperclass()) { + for (Field field : current.getDeclaredFields()) { + if (!Modifier.isStatic(field.getModifiers()) && List.class.isAssignableFrom(field.getType())) { + field.setAccessible(true); + return field; + } + } + } + throw new IllegalStateException("Unable to locate NBT list values on " + type.getName()); + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/nbt/NbtCodecException.java b/spigot/src/main/java/io/github/eat_ram/fuream/nbt/NbtCodecException.java new file mode 100644 index 0000000..ceea233 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/nbt/NbtCodecException.java @@ -0,0 +1,12 @@ +package io.github.eat_ram.fuream.nbt; + +/** Indicates that a non-empty native item tag could not be decoded safely. */ +public final class NbtCodecException extends IllegalStateException { + public NbtCodecException(String message) { + super(message); + } + + public NbtCodecException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/nbt/NbtSelfTest.java b/spigot/src/main/java/io/github/eat_ram/fuream/nbt/NbtSelfTest.java new file mode 100644 index 0000000..3f7e3a4 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/nbt/NbtSelfTest.java @@ -0,0 +1,49 @@ +package io.github.eat_ram.fuream.nbt; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +/** Startup round trips for the NBT primitives required before any furnace is adopted. */ +public final class NbtSelfTest { + public static void run() { + NativeItemNbt.initialize(); + NativeNbtCompound compound = NativeNbtCompound.createForServer(); + compound.setInt("Int", 37); + compound.setString("String", "fuream"); + compound.setFloat("Float", 1.25f); + NativeNbtList list = compound.resetList("List"); + NativeNbtCompound child = NativeNbtCompound.createForServer(); + child.setInt("Value", 9); + list.add(child); + if (compound.getInt("Int", -1) != 37 || !"fuream".equals(compound.getString("String")) || + Math.abs(compound.getFloat("Float", 0f) - 1.25f) > 0.0001f || + list.size() != 1 || list.getCompound(0).getInt("Value", -1) != 9) { + throw new IllegalStateException("Native NBT primitive/list round trip failed"); + } + + ItemStack plain = new ItemStack(Material.STONE, 3); + assertItemRoundTrip(plain, false); + ItemStack named = plain.clone(); + ItemMeta meta = named.getItemMeta(); + if (meta != null) { + meta.setDisplayName("Fuream NBT self-test"); + named.setItemMeta(meta); + assertItemRoundTrip(named, true); + } + } + + private static void assertItemRoundTrip(ItemStack expected, boolean checkName) { + ItemStack actual = NativeItemNbt.read(NativeItemNbt.write(expected)); + if (actual.getType() != expected.getType() || actual.getAmount() != expected.getAmount()) { + throw new IllegalStateException("Native item NBT round trip changed the item"); + } + if (checkName && (!actual.hasItemMeta() || !actual.getItemMeta().hasDisplayName() || + !expected.getItemMeta().getDisplayName().equals(actual.getItemMeta().getDisplayName()))) { + throw new IllegalStateException("Native item NBT round trip lost item metadata"); + } + } + + private NbtSelfTest() { + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/screen/FureamFunctionalArea.java b/spigot/src/main/java/io/github/eat_ram/fuream/screen/FureamFunctionalArea.java new file mode 100644 index 0000000..bdf455b --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/screen/FureamFunctionalArea.java @@ -0,0 +1,31 @@ +package io.github.eat_ram.fuream.screen; + +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.NotNull; + +public interface FureamFunctionalArea { + @NotNull ItemStack getGuiStack(FureamScreenInventory inv, int slot); + + void setGuiStack(FureamScreenInventory inv, int slot, ItemStack newStack); + + void onGuiClick( + SlotClick slotClick, FureamScreenInventory display, int slot, + int button, ClickType clickType, Player player + ); + + boolean isStorable(FureamScreenInventory inv, int slot); + + boolean isTakable(FureamScreenInventory inv, int slot); + + void refreshVisuals(FureamScreenInventory inv); + + @FunctionalInterface + interface SlotClick { + void onSlotClick( + int slotIndex, int button, ClickType clickType, + Player player + ); + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/screen/FureamScreenHandler.java b/spigot/src/main/java/io/github/eat_ram/fuream/screen/FureamScreenHandler.java new file mode 100644 index 0000000..9d564b8 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/screen/FureamScreenHandler.java @@ -0,0 +1,538 @@ +package io.github.eat_ram.fuream.screen; + +import java.util.Map; + +import io.github.eat_ram.fuream.hook.FurnaceManager; +import io.github.eat_ram.fuream.hook.FurnaceManager.FurnaceContext; +import io.github.eat_ram.fuream.logic.FureamFurnaceLogic; +import io.github.eat_ram.fuream.compat.ItemCompat; +import io.github.eat_ram.fuream.compat.FurnaceEventCompat; +import io.github.eat_ram.fuream.util.FurnacePos; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.entity.Item; +import org.bukkit.Bukkit; +import org.bukkit.event.player.PlayerDropItemEvent; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.event.inventory.InventoryAction; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.NotNull; + +public final class FureamScreenHandler { + private final @NotNull FureamScreenInventory display; + + public FureamScreenHandler(@NotNull FureamScreenInventory display) { + this.display = display; + } + + public void handleClick(InventoryClickEvent event, Player player) { + int rawSlot = event.getRawSlot(); + ClickType clickType = event.getClick(); + InventoryAction action = event.getAction(); + + if (rawSlot < 0) { + return; + } + if ("UNKNOWN".equals(action.name()) || "CLONE_STACK".equals(action.name()) || + "CREATIVE".equals(clickType.name())) { + event.setCancelled(rawSlot < FureamScreenInventory.SIZE); + return; + } + + if (clickType == ClickType.DOUBLE_CLICK) { + event.setCancelled(true); + boolean changed = this.collectMatchingToCursor(event, player); + this.display.syncToBukkitInventory(); + if (changed) this.markDirty(); + return; + } + + // Click in player inventory with shift-click into custom GUI + if (rawSlot >= FureamScreenInventory.SIZE) { + if (event.isShiftClick()) { + event.setCancelled(true); + ItemStack current = event.getCurrentItem(); + if (!ItemCompat.isEmpty(current)) { + ItemStack remaining = this.quickMoveToContainer(current); + event.setCurrentItem(ItemCompat.isEmpty(remaining) ? null : remaining); + this.display.syncToBukkitInventory(); + } + } + return; + } + + // Click is within the 54-slot furnace GUI + event.setCancelled(true); + + if (this.display.isFunctionalAreaSlot(rawSlot)) { + int ca = this.display.getCurrentFunctionalArea(); + if (ca < this.display.functionalAreas.size()) { + FureamFunctionalArea area = this.display.functionalAreas.get(ca); + area.onGuiClick( + (sIdx, btn, ct, p) -> this.handleFunctionalSlotDefault(rawSlot, sIdx, event, p), + this.display, rawSlot - FureamScreenInventory.FUNCTIONAL_START, + event.getHotbarButton(), clickType, player + ); + this.display.syncToBukkitInventory(); + } + return; + } + + if (this.display.isBarOrDecorSlot(rawSlot)) { + switch (rawSlot) { + case FureamScreenInventory.NEXT_FUNCTIONAL_SLOT: { + int size = this.display.functionalAreas.size(); + if (size > 0) { + int na = this.display.getCurrentFunctionalArea() + 1; + this.display.setCurrentFunctionalArea(na < size ? na : 0); + } + break; + } + case FureamScreenInventory.PREVIOUS_PAGE_SLOT: + this.display.switchPage(this.display.getPage() - 1); + break; + case FureamScreenInventory.NEXT_PAGE_SLOT: + this.display.switchPage(this.display.getPage() + 1); + break; + } + return; + } + + if (this.display.isOutputSlot(rawSlot)) { + this.onOutputSlotClick(rawSlot, event, player); + this.display.syncToBukkitInventory(); + return; + } + + if (this.display.isInputSlot(rawSlot) || this.display.isFuelRowSlot(rawSlot)) { + this.onMaterialSlotClick(rawSlot, event, player); + this.display.syncToBukkitInventory(); + } + } + + private void handleFunctionalSlotDefault(int rawSlot, int targetSlot, InventoryClickEvent event, Player player) { + int ca = this.display.getCurrentFunctionalArea(); + if (ca >= this.display.functionalAreas.size()) return; + FureamFunctionalArea area = this.display.functionalAreas.get(ca); + int functionalSlot = rawSlot - FureamScreenInventory.FUNCTIONAL_START; + if (!area.isStorable(this.display, functionalSlot) && !area.isTakable(this.display, functionalSlot)) return; + + ItemStack cursor = event.getCursor(); + ItemStack current = area.getGuiStack(this.display, functionalSlot); + ClickType clickType = event.getClick(); + + // 1. Shift click + if (event.isShiftClick()) { + boolean changed = false; + if (!ItemCompat.isEmpty(current) && area.isTakable(this.display, functionalSlot)) { + int before = current.getAmount(); + Map leftover = player.getInventory().addItem(current.clone()); + if (leftover.isEmpty()) { + area.setGuiStack(this.display, functionalSlot, new ItemStack(Material.AIR)); + changed = true; + } else { + current.setAmount(leftover.values().iterator().next().getAmount()); + area.setGuiStack(this.display, functionalSlot, current); + changed = current.getAmount() != before; + } + } + if (changed) this.markDirty(); + return; + } + + // 2. Number key (Hotbar 1-9) + if (clickType == ClickType.NUMBER_KEY) { + int hotbarSlot = event.getHotbarButton(); + if (hotbarSlot >= 0 && hotbarSlot < 9) { + ItemStack hotbarItem = player.getInventory().getItem(hotbarSlot); + if (ItemCompat.isEmpty(hotbarItem)) { + if (!ItemCompat.isEmpty(current) && area.isTakable(this.display, functionalSlot)) { + player.getInventory().setItem(hotbarSlot, current.clone()); + area.setGuiStack(this.display, functionalSlot, new ItemStack(Material.AIR)); + this.markDirty(); + } + } else if (area.isStorable(this.display, functionalSlot)) { + if (ItemCompat.isEmpty(current)) { + area.setGuiStack(this.display, functionalSlot, hotbarItem.clone()); + player.getInventory().setItem(hotbarSlot, new ItemStack(Material.AIR)); + this.markDirty(); + } else if (area.isTakable(this.display, functionalSlot)) { + area.setGuiStack(this.display, functionalSlot, hotbarItem.clone()); + player.getInventory().setItem(hotbarSlot, current.clone()); + this.markDirty(); + } + } + } + return; + } + + // 3. Cursor empty -> Take item from slot + if (ItemCompat.isEmpty(cursor)) { + if (!ItemCompat.isEmpty(current) && area.isTakable(this.display, functionalSlot)) { + if (event.isRightClick()) { + int half = (current.getAmount() + 1) / 2; + ItemStack take = current.clone(); + take.setAmount(half); + current.setAmount(current.getAmount() - half); + event.getView().setCursor(take); + area.setGuiStack(this.display, functionalSlot, current.getAmount() > 0 ? current : new ItemStack(Material.AIR)); + } else { + event.getView().setCursor(current.clone()); + area.setGuiStack(this.display, functionalSlot, new ItemStack(Material.AIR)); + } + } + } else { + // 4. Cursor has item -> Place/Swap into slot + if (area.isStorable(this.display, functionalSlot)) { + if (ItemCompat.isEmpty(current)) { + if (event.isRightClick()) { + ItemStack putOne = cursor.clone(); + putOne.setAmount(1); + cursor.setAmount(cursor.getAmount() - 1); + area.setGuiStack(this.display, functionalSlot, putOne); + event.getView().setCursor(cursor.getAmount() > 0 ? cursor : new ItemStack(Material.AIR)); + } else { + area.setGuiStack(this.display, functionalSlot, cursor.clone()); + event.getView().setCursor(new ItemStack(Material.AIR)); + } + } else if (current.isSimilar(cursor)) { + int max = current.getMaxStackSize(); + if (event.isRightClick()) { + if (current.getAmount() < max) { + current.setAmount(current.getAmount() + 1); + cursor.setAmount(cursor.getAmount() - 1); + area.setGuiStack(this.display, functionalSlot, current); + event.getView().setCursor(cursor.getAmount() > 0 ? cursor : new ItemStack(Material.AIR)); + } + } else { + int space = max - current.getAmount(); + int move = Math.min(space, cursor.getAmount()); + if (move > 0) { + current.setAmount(current.getAmount() + move); + cursor.setAmount(cursor.getAmount() - move); + area.setGuiStack(this.display, functionalSlot, current); + event.getView().setCursor(cursor.getAmount() > 0 ? cursor : new ItemStack(Material.AIR)); + } + } + } else if (area.isTakable(this.display, functionalSlot)) { + area.setGuiStack(this.display, functionalSlot, cursor.clone()); + event.getView().setCursor(current.clone()); + } + } + } + this.markDirty(); + } + + private void markDirty() { + FurnacePos pos = new FurnacePos(this.display.location); + FurnaceContext ctx = FurnaceManager.getContext(pos); + if (ctx != null) { + ctx.dirty = true; + } + } + + private void onOutputSlotClick(int slotIndex, InventoryClickEvent event, Player player) { + ItemStack current = this.display.getStack(slotIndex); + if (ItemCompat.isEmpty(current)) return; + ItemStack extractIdentity = current.clone(); + extractIdentity.setAmount(1); + int moved = 0; + + if (event.getClick() == ClickType.NUMBER_KEY) { + int hotbarSlot = event.getHotbarButton(); + if (hotbarSlot >= 0 && hotbarSlot < 9 && + ItemCompat.isEmpty(player.getInventory().getItem(hotbarSlot))) { + player.getInventory().setItem(hotbarSlot, current.clone()); + moved = current.getAmount(); + this.display.setStack(slotIndex, new ItemStack(Material.AIR)); + } + this.notifyExtract(player, extractIdentity, moved); + if (moved > 0) this.markDirty(); + return; + } + + if (event.getClick() == ClickType.DROP || event.getClick() == ClickType.CONTROL_DROP) { + moved = event.getClick() == ClickType.DROP ? 1 : current.getAmount(); + ItemStack dropped = current.clone(); + dropped.setAmount(moved); + if (!this.dropThroughEvent(player, dropped)) return; + current.setAmount(current.getAmount() - moved); + this.display.setStack(slotIndex, current.getAmount() > 0 ? current : new ItemStack(Material.AIR)); + this.notifyExtract(player, dropped, moved); + this.markDirty(); + return; + } + + if (event.isShiftClick()) { + int before = current.getAmount(); + Map leftover = player.getInventory().addItem(current.clone()); + if (leftover.isEmpty()) { + this.display.setStack(slotIndex, new ItemStack(Material.AIR)); + } else { + current.setAmount(leftover.values().iterator().next().getAmount()); + this.display.setStack(slotIndex, current); + } + moved = before - (leftover.isEmpty() ? 0 : current.getAmount()); + this.notifyExtract(player, extractIdentity, moved); + if (moved > 0) this.markDirty(); + return; + } + + ItemStack cursor = event.getCursor(); + if (ItemCompat.isEmpty(cursor)) { + moved = event.isRightClick() ? (current.getAmount() + 1) / 2 : current.getAmount(); + ItemStack taken = current.clone(); + taken.setAmount(moved); + event.getView().setCursor(taken); + current.setAmount(current.getAmount() - moved); + this.display.setStack(slotIndex, current.getAmount() > 0 ? current : new ItemStack(Material.AIR)); + } else if (cursor.isSimilar(current)) { + int max = cursor.getMaxStackSize(); + int space = max - cursor.getAmount(); + int move = Math.min(space, current.getAmount()); + if (move > 0) { + moved = move; + cursor.setAmount(cursor.getAmount() + move); + current.setAmount(current.getAmount() - move); + event.getView().setCursor(cursor); + this.display.setStack(slotIndex, current.getAmount() > 0 ? current : new ItemStack(Material.AIR)); + } + } + this.notifyExtract(player, extractIdentity, moved); + if (moved > 0) this.markDirty(); + } + + private boolean collectMatchingToCursor(InventoryClickEvent event, Player player) { + ItemStack cursor = event.getCursor(); + if (ItemCompat.isEmpty(cursor)) return false; + int space = cursor.getMaxStackSize() - cursor.getAmount(); + int extracted = 0; + for (int i = 0; i < this.display.data.outputs.size() && space > 0; i++) { + ItemStack stack = this.display.data.outputs.get(i); + if (ItemCompat.isEmpty(stack) || !stack.isSimilar(cursor)) continue; + int move = Math.min(space, stack.getAmount()); + cursor.setAmount(cursor.getAmount() + move); + stack.setAmount(stack.getAmount() - move); + if (stack.getAmount() <= 0) this.display.data.outputs.set(i, ItemCompat.empty()); + space -= move; + extracted += move; + } + for (int i = 0; i < player.getInventory().getSize() && space > 0; i++) { + ItemStack stack = player.getInventory().getItem(i); + if (ItemCompat.isEmpty(stack) || !stack.isSimilar(cursor)) continue; + int move = Math.min(space, stack.getAmount()); + cursor.setAmount(cursor.getAmount() + move); + stack.setAmount(stack.getAmount() - move); + player.getInventory().setItem(i, stack.getAmount() > 0 ? stack : null); + space -= move; + } + event.getView().setCursor(cursor); + this.notifyExtract(player, cursor, extracted); + return extracted > 0; + } + + private void notifyExtract(Player player, ItemStack stack, int amount) { + if (amount > 0 && !ItemCompat.isEmpty(stack)) { + FurnaceEventCompat.fireExtract( + player, this.display.location.getBlock(), stack.getType(), amount + ); + } + } + + private boolean dropThroughEvent(Player player, ItemStack stack) { + Item entity = player.getWorld().dropItem(player.getEyeLocation(), stack.clone()); + PlayerDropItemEvent event = new PlayerDropItemEvent(player, entity); + Bukkit.getPluginManager().callEvent(event); + if (event.isCancelled()) { + entity.remove(); + return false; + } + return true; + } + + private void onMaterialSlotClick(int slotIndex, InventoryClickEvent event, Player player) { + boolean isFuel = this.display.isFuelRowSlot(slotIndex); + ItemStack cursor = event.getCursor(); + ItemStack current = this.display.getStack(slotIndex); + + if (event.getClick() == ClickType.NUMBER_KEY) { + int hotbarSlot = event.getHotbarButton(); + if (hotbarSlot < 0 || hotbarSlot >= 9) return; + ItemStack hotbar = player.getInventory().getItem(hotbarSlot); + if (!ItemCompat.isEmpty(hotbar) && !this.display.isValid(slotIndex, hotbar)) return; + player.getInventory().setItem( + hotbarSlot, ItemCompat.isEmpty(current) ? null : current.clone() + ); + this.display.setStack( + slotIndex, ItemCompat.isEmpty(hotbar) ? ItemCompat.empty() : hotbar.clone() + ); + this.markDirty(); + return; + } + + if (event.getClick() == ClickType.DROP || event.getClick() == ClickType.CONTROL_DROP) { + if (ItemCompat.isEmpty(current)) return; + int amount = event.getClick() == ClickType.DROP ? 1 : current.getAmount(); + ItemStack dropped = current.clone(); + dropped.setAmount(amount); + if (!this.dropThroughEvent(player, dropped)) return; + current.setAmount(current.getAmount() - amount); + this.display.setStack( + slotIndex, current.getAmount() <= 0 ? ItemCompat.empty() : current + ); + this.markDirty(); + return; + } + + if (event.isShiftClick()) { + int before = ItemCompat.isEmpty(current) ? 0 : current.getAmount(); + if (!ItemCompat.isEmpty(current)) { + Map leftover = player.getInventory().addItem(current.clone()); + if (leftover.isEmpty()) { + this.display.setStack(slotIndex, new ItemStack(Material.AIR)); + } else { + current.setAmount(leftover.values().iterator().next().getAmount()); + this.display.setStack(slotIndex, current); + } + } + ItemStack afterStack = this.display.getStack(slotIndex); + int after = ItemCompat.isEmpty(afterStack) ? 0 : afterStack.getAmount(); + if (before != after) this.markDirty(); + return; + } + + if (ItemCompat.isEmpty(cursor)) { + if (!ItemCompat.isEmpty(current)) { + if (event.isRightClick()) { + int half = (current.getAmount() + 1) / 2; + ItemStack take = current.clone(); + take.setAmount(half); + current.setAmount(current.getAmount() - half); + event.getView().setCursor(take); + this.display.setStack(slotIndex, current.getAmount() > 0 ? current : new ItemStack(Material.AIR)); + } else { + event.getView().setCursor(current.clone()); + this.display.setStack(slotIndex, new ItemStack(Material.AIR)); + } + } + } else { + Material bucket = Material.matchMaterial("BUCKET"); + if (isFuel && !FureamFurnaceLogic.isFuel(cursor) && + (bucket == null || cursor.getType() != bucket)) { + return; + } + if (ItemCompat.isEmpty(current)) { + if (event.isRightClick()) { + ItemStack putOne = cursor.clone(); + putOne.setAmount(1); + cursor.setAmount(cursor.getAmount() - 1); + this.display.setStack(slotIndex, putOne); + event.getView().setCursor(cursor.getAmount() > 0 ? cursor : new ItemStack(Material.AIR)); + } else { + this.display.setStack(slotIndex, cursor.clone()); + event.getView().setCursor(new ItemStack(Material.AIR)); + } + } else if (current.isSimilar(cursor)) { + int max = current.getMaxStackSize(); + if (event.isRightClick()) { + if (current.getAmount() < max) { + current.setAmount(current.getAmount() + 1); + cursor.setAmount(cursor.getAmount() - 1); + this.display.setStack(slotIndex, current); + event.getView().setCursor(cursor.getAmount() > 0 ? cursor : new ItemStack(Material.AIR)); + } + } else { + int space = max - current.getAmount(); + int move = Math.min(space, cursor.getAmount()); + if (move > 0) { + current.setAmount(current.getAmount() + move); + cursor.setAmount(cursor.getAmount() - move); + this.display.setStack(slotIndex, current); + event.getView().setCursor(cursor.getAmount() > 0 ? cursor : new ItemStack(Material.AIR)); + } + } + } else { + this.display.setStack(slotIndex, cursor.clone()); + event.getView().setCursor(current.clone()); + } + } + this.markDirty(); + } + + private @NotNull ItemStack quickMoveToContainer(@NotNull ItemStack original) { + ItemStack remaining = original.clone(); + int originalAmount = original.getAmount(); + if (FureamFurnaceLogic.isAcceptableInput(this.display.location.getWorld(), remaining, this.display.furnaceType)) { + int stop = this.display.getInputSlotStop(); + for (int i = 0; i < stop; i++) { + remaining = this.insertIntoSlot(i, remaining); + if (ItemCompat.isEmpty(remaining)) { + this.markDirty(); + return new ItemStack(Material.AIR); + } + } + } + Material bucket = Material.matchMaterial("BUCKET"); + if (FureamFurnaceLogic.isFuel(remaining) || + bucket != null && remaining.getType() == bucket) { + int fuelStart = 18; + int stop = this.display.getFuelSlotStop(); + for (int i = fuelStart; i < stop; i++) { + remaining = this.insertIntoSlot(i, remaining); + if (ItemCompat.isEmpty(remaining)) { + this.markDirty(); + return new ItemStack(Material.AIR); + } + } + } + if (remaining.getAmount() != originalAmount) this.markDirty(); + return remaining; + } + + private @NotNull ItemStack insertIntoSlot(int slotIndex, @NotNull ItemStack stack) { + ItemStack current = this.display.getStack(slotIndex); + if (ItemCompat.isEmpty(current)) { + this.display.setStack(slotIndex, stack.clone()); + return new ItemStack(Material.AIR); + } + if (current.isSimilar(stack)) { + int max = current.getMaxStackSize(); + int space = max - current.getAmount(); + int move = Math.min(space, stack.getAmount()); + if (move > 0) { + current.setAmount(current.getAmount() + move); + stack.setAmount(stack.getAmount() - move); + this.display.setStack(slotIndex, current); + } + } + return stack.getAmount() > 0 ? stack : new ItemStack(Material.AIR); + } + + public void handleDrag(@NotNull InventoryDragEvent event) { + boolean touchesDisplay = false; + for (Map.Entry entry : event.getNewItems().entrySet()) { + int rawSlot = entry.getKey(); + if (rawSlot >= FureamScreenInventory.SIZE) continue; + touchesDisplay = true; + if (!this.display.isValid(rawSlot, entry.getValue())) { + event.setCancelled(true); + return; + } + } + if (!touchesDisplay) return; + event.setCancelled(true); + for (Map.Entry entry : event.getNewItems().entrySet()) { + int rawSlot = entry.getKey(); + if (rawSlot < FureamScreenInventory.SIZE) { + this.display.setStack(rawSlot, entry.getValue().clone()); + } else { + event.getView().setItem(rawSlot, entry.getValue().clone()); + } + } + event.getView().setCursor(ItemCompat.isEmpty(event.getCursor()) ? null : event.getCursor().clone()); + this.markDirty(); + this.display.refreshVisuals(); + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/screen/FureamScreenInventory.java b/spigot/src/main/java/io/github/eat_ram/fuream/screen/FureamScreenInventory.java new file mode 100644 index 0000000..4a3b05f --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/screen/FureamScreenInventory.java @@ -0,0 +1,455 @@ +package io.github.eat_ram.fuream.screen; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.IllegalFormatException; +import java.util.List; +import java.util.Set; +import java.util.function.BiFunction; +import java.util.UUID; + +import io.github.eat_ram.fuream.api.FureamWorldConfig; +import io.github.eat_ram.fuream.api.FurnaceType; +import io.github.eat_ram.fuream.FureamMain; +import io.github.eat_ram.fuream.ResolvedFurnaceConfig; +import io.github.eat_ram.fuream.data.FureamFurnaceData; +import io.github.eat_ram.fuream.hook.FurnaceManager; +import io.github.eat_ram.fuream.hook.FurnaceManager.FurnaceContext; +import io.github.eat_ram.fuream.logic.FureamFurnaceLogic; +import io.github.eat_ram.fuream.compat.ItemCompat; +import io.github.eat_ram.fuream.util.FurnacePos; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Range; + +/** + * The 54-slot view representing the virtual furnace GUI. + * + *

Row layout: + *

+ *  0..8   inputs (row 1)
+ *  9..17  fuel remaining bar (row 2)
+ * 18..26  fuels (row 3)
+ * 27..35  smelt progress bar (row 4)
+ * 36..44  outputs (row 5)
+ * 45..53  functional area & decor (row 6)
+ * 
+ */ +public final class FureamScreenInventory implements InventoryHolder { + public static final int COLS = 9; + public static final int SIZE = 54; + public static final int INPUT_START = 0; + public static final int FUEL_BAR_START = 9; + public static final int FUEL_START = 18; + public static final int PROGRESS_BAR_START = 27; + public static final int OUTPUT_START = 36; + public static final int FUNCTIONAL_START = 45; + public static final int NEXT_FUNCTIONAL_SLOT = 51; + public static final int PREVIOUS_PAGE_SLOT = 52; + public static final int NEXT_PAGE_SLOT = 53; + private static final Set WARNED_FORMATS = new HashSet<>(); + public static final ArrayList<@NotNull BiFunction< + ? super FureamScreenInventory, ? super FureamWorldConfig, + ? extends @NotNull FureamFunctionalArea + >> FUNCTIONAL_AREA_REGISTRY = new ArrayList<>(); + + public final @NotNull FurnaceType furnaceType; + public final @Nullable FureamWorldConfig config; + public final @NotNull ItemStack border; + public final @NotNull Material fuelLeftItem; + public final @NotNull Material fuelUsedItem; + public final @NotNull Material progressDoneItem; + public final @NotNull Material progressRemainingItem; + public final @NotNull ArrayList<@NotNull FureamFunctionalArea> functionalAreas; + private @Range(from = 0, to = Integer.MAX_VALUE) int currentFunctionalArea; + public final @NotNull Material nextFunctionalItem; + public final @NotNull Material prevPageItem; + public final @NotNull Material nextPageItem; + + public final @Range(from = 1, to = Integer.MAX_VALUE) int inputSlots; + public final @Range(from = 1, to = Integer.MAX_VALUE) int fuelSlots; + public final @Range(from = 1, to = Integer.MAX_VALUE) int outputSlots; + public final @NotNull Location location; + public final @NotNull FureamFurnaceData data; + public final @NotNull UUID viewerId; + public final @NotNull FureamScreenHandler handler; + + public int burnTime; + public int fuelTimeTotal; + public int cookTime; + public int cookTimeTotal = 200; + + public final @NotNull ItemStack @NotNull[] fuelBar = new ItemStack[COLS]; + public final @NotNull ItemStack @NotNull[] progBar = new ItemStack[COLS]; + public @NotNull ItemStack nextFunctionalBtn = new ItemStack(Material.AIR); + public @NotNull ItemStack prevPageBtn = new ItemStack(Material.AIR); + public @NotNull ItemStack nextPageBtn = new ItemStack(Material.AIR); + private @Range(from = 0, to = Integer.MAX_VALUE) int page; + public final @NotNull Inventory bukkitInventory; + private int visualTick; + private boolean forceBarRefresh = true; + + public FureamScreenInventory( + @NotNull UUID viewerId, @NotNull Location location, @NotNull FurnaceType type, + @NotNull FureamFurnaceData data, @Nullable FureamWorldConfig config + ) { + this.location = location; + this.viewerId = viewerId; + this.furnaceType = type; + this.data = data; + this.config = config; + this.functionalAreas = new ArrayList<>(); + + ResolvedFurnaceConfig resolved = config == null + ? null : ResolvedFurnaceConfig.resolve(config, type); + this.inputSlots = resolved == null ? 9 : resolved.inputSlots; + this.fuelSlots = resolved == null ? 9 : resolved.fuelSlots; + this.outputSlots = resolved == null ? 9 : resolved.outputSlots; + + this.fuelLeftItem = parseMaterial(config != null ? config.getGuiFuelLeftItemId().get(type) : null, Material.GLASS_PANE); + this.fuelUsedItem = parseMaterial(config != null ? config.getGuiFuelUsedItemId().get(type) : null, Material.GLASS_PANE); + this.progressDoneItem = parseMaterial(config != null ? config.getGuiProgressDoneItemId().get(type) : null, Material.GLASS_PANE); + this.progressRemainingItem = parseMaterial(config != null ? config.getGuiProgressRemainingItemId().get(type) : null, Material.GLASS_PANE); + this.nextFunctionalItem = parseMaterial(config != null ? config.getGuiNextFunctionalAreaItemId().get(type) : null, Material.BOOK); + this.prevPageItem = parseMaterial(config != null ? config.getGuiPrevPageItemId().get(type) : null, Material.ARROW); + this.nextPageItem = parseMaterial(config != null ? config.getGuiNextPageItemId().get(type) : null, Material.ARROW); + + Material borderMat = parseMaterial(config != null ? config.getGuiBorderItemId().get(type) : null, Material.GLASS_PANE); + this.border = makeTooltipItem( + new ItemStack(borderMat, 1), + config != null ? config.getGuiBorderItemTitle().get(type) : "GUI Border" + ); + + String title = resolveTitle(location, type, config); + this.bukkitInventory = Bukkit.createInventory(this, SIZE, title.length() > 32 ? title.substring(0, 32) : title); + this.handler = new FureamScreenHandler(this); + + FurnacePos pos = new FurnacePos(this.location); + FurnaceContext ctx = FurnaceManager.getContext(pos); + if (ctx != null) { + this.burnTime = ctx.burnTime; + this.fuelTimeTotal = Math.max(1, ctx.fuelTimeTotal); + this.cookTime = ctx.cookTime; + this.cookTimeTotal = Math.max(1, ctx.cookTimeTotal); + } else { + this.cookTimeTotal = (type == FurnaceType.FURNACE ? 200 : 100); + this.fuelTimeTotal = 200; + } + this.switchPage(0); + for (BiFunction< + ? super FureamScreenInventory, ? super FureamWorldConfig, + ? extends FureamFunctionalArea + > factory : FUNCTIONAL_AREA_REGISTRY) { + this.functionalAreas.add(factory.apply(this, this.config)); + } + + this.nextFunctionalBtn = this.functionalAreas.size() < 2 ? this.border.clone() : + makeTooltipItem( + new ItemStack(this.nextFunctionalItem, 1), + config != null ? config.getGuiNextFunctionalAreaItemTitle().get(type) : "Next Functional Area" + ); + + this.refreshVisuals(); + } + + private static Material parseMaterial(@Nullable String id, Material fallback) { + return ItemCompat.matchMaterial(id, fallback); + } + + private static String resolveTitle( + Location location, FurnaceType type, @Nullable FureamWorldConfig config + ) { + try { + Object state = location.getBlock().getState(); + Object custom = state.getClass().getMethod("getCustomName").invoke(state); + if (custom instanceof String && !((String) custom).isEmpty()) return (String) custom; + } catch (ReflectiveOperationException ignored) { + } + if (config != null) { + String configured = config.getGuiTitle().get(type); + if (configured != null && !configured.isEmpty()) return configured; + } + return type == FurnaceType.SMOKER ? "Smoker" : + (type == FurnaceType.BLAST_FURNACE ? "Blast Furnace" : "Furnace"); + } + + @Override + public Inventory getInventory() { + return this.bukkitInventory; + } + + public boolean isInputSlot(int slot) { + return slot >= INPUT_START && slot < FUEL_BAR_START && this.page * COLS + slot < this.inputSlots; + } + + public boolean isFuelRowSlot(int slot) { + return slot >= FUEL_START && slot < PROGRESS_BAR_START && + this.page * COLS + slot - FUEL_START < this.fuelSlots; + } + + public boolean isOutputSlot(int slot) { + return slot >= OUTPUT_START && slot < FUNCTIONAL_START && + this.page * COLS + slot - OUTPUT_START < this.outputSlots; + } + + public boolean isFunctionalAreaSlot(int slot) { + return slot >= FUNCTIONAL_START && slot < NEXT_FUNCTIONAL_SLOT; + } + + public boolean isBarOrDecorSlot(int slot) { + return !(this.isInputSlot(slot) || this.isFuelRowSlot(slot) || + this.isOutputSlot(slot) || this.isFunctionalAreaSlot(slot)); + } + + public int getInputSlotStop() { + int diff = this.inputSlots - (this.page * COLS); + return diff >= COLS ? COLS : (diff <= 0 ? 0 : diff); + } + + public int getFuelSlotStop() { + int diff = this.fuelSlots - (this.page * COLS); + return diff >= COLS ? PROGRESS_BAR_START : + (diff <= 0 ? FUEL_START : diff + FUEL_START); + } + + public int getOutputSlotStop() { + int diff = this.outputSlots - (this.page * COLS); + return diff >= COLS ? FUNCTIONAL_START : + (diff <= 0 ? OUTPUT_START : diff + OUTPUT_START); + } + + public void refreshVisuals() { + this.cookTimeTotal = Math.max(1, this.cookTimeTotal); + if (this.cookTime >= this.cookTimeTotal) { + this.cookTime = Math.max(0, this.cookTimeTotal - 1); + } + + boolean refreshBars = this.forceBarRefresh || (this.visualTick++ & 1) == 0; + if (refreshBars) { + int fuelCount = barCount(this.burnTime, this.fuelTimeTotal); + int progressCount = barCount(this.cookTime, this.cookTimeTotal); + for (int i = 0; i < COLS; ++i) { + this.fuelBar[i] = this.addFuelTooltip(new ItemStack( + i < fuelCount ? this.fuelLeftItem : this.fuelUsedItem, 1 + )); + this.progBar[i] = this.addProgressTooltip(new ItemStack( + i < progressCount ? this.progressDoneItem : this.progressRemainingItem, 1 + )); + } + this.forceBarRefresh = false; + } + + for (FureamFunctionalArea area : this.functionalAreas) { + area.refreshVisuals(this); + } + + this.syncToBukkitInventory(); + } + + public void syncToBukkitInventory() { + for (int i = 0; i < SIZE; i++) { + ItemStack stack = this.getStack(i); + ItemStack current = this.bukkitInventory.getItem(i); + if (!sameStack(current, stack)) { + this.bukkitInventory.setItem(i, ItemCompat.isEmpty(stack) ? null : stack.clone()); + } + } + } + + @Contract(pure = true) + public @Range(from = 0, to = Integer.MAX_VALUE) int getPage() { + return this.page; + } + + @Contract(pure = true) + public @Range(from = 0, to = Integer.MAX_VALUE) int getMaxPage() { + int maxSlots = Math.max(this.inputSlots, Math.max(this.fuelSlots, this.outputSlots)); + return Math.max(0, (maxSlots - 1) / COLS); + } + + public void switchPage(int page) { + if (page >= 0 && page <= this.getMaxPage()) { + this.page = page; + this.forceBarRefresh = true; + this.prevPageBtn = makeTooltipItem( + new ItemStack(this.prevPageItem, 1), + this.config != null ? this.config.getGuiPrevPageItemTitle().get(this.furnaceType) : "Previous Page", + safeFormat(this.config != null ? this.config.getGuiPrevPageItemTooltip().get(this.furnaceType) : "Page %d", "Page %d", page + 1) + ); + this.nextPageBtn = makeTooltipItem( + new ItemStack(this.nextPageItem, 1), + this.config != null ? this.config.getGuiNextPageItemTitle().get(this.furnaceType) : "Next Page", + safeFormat(this.config != null ? this.config.getGuiNextPageItemTooltip().get(this.furnaceType) : "Page %d", "Page %d", page + 1) + ); + this.syncToBukkitInventory(); + } + } + + @Contract(pure = true) + public @Range(from = 0, to = Integer.MAX_VALUE) int getCurrentFunctionalArea() { + return this.currentFunctionalArea; + } + + public void setCurrentFunctionalArea(int currentFunctionalArea) { + this.currentFunctionalArea = Math.max(0, currentFunctionalArea); + this.syncToBukkitInventory(); + } + + @Contract(pure = true) + private static @Range(from = 0, to = Integer.MAX_VALUE) int barCount(int value, int total) { + if (total <= 0 || value <= 0) return 0; + int k = Math.round(9f * value / (float) total); + return Math.max(0, Math.min(COLS, k)); + } + + private @NotNull ItemStack addFuelTooltip(@NotNull ItemStack stack) { + String title = this.config != null ? this.config.getGuiFuelItemTitle().get(this.furnaceType) : "Fuel"; + String lore = safeFormat( + this.config != null ? this.config.getGuiFuelItemTooltip().get(this.furnaceType) : "Available: %d / %d", + "Available: %d / %d", this.burnTime, this.fuelTimeTotal + ); + return makeTooltipItem(stack, title, lore); + } + + private @NotNull ItemStack addProgressTooltip(@NotNull ItemStack stack) { + String title = this.config != null ? this.config.getGuiProgressItemTitle().get(this.furnaceType) : "Progress"; + String lore = safeFormat( + this.config != null ? this.config.getGuiProgressItemTooltip().get(this.furnaceType) : "%d / %d", + "%d / %d", this.cookTime, this.cookTimeTotal + ); + return makeTooltipItem(stack, title, lore); + } + + public static @NotNull ItemStack makeTooltipItem( + @NotNull ItemStack stack, @Nullable String title, + @NotNull String @NotNull... lores + ) { + ItemMeta meta = stack.getItemMeta(); + if (meta != null) { + if (title != null) { + meta.setDisplayName("§r" + title); + } + if (lores.length > 0) { + List loreList = new ArrayList<>(); + for (String lore : lores) { + loreList.add("§7" + lore); + } + meta.setLore(loreList); + } + stack.setItemMeta(meta); + } + return stack; + } + + public @NotNull ItemStack getStack(int slot) { + if (this.isInputSlot(slot)) { + int idx = this.page * COLS + slot; + return cloneOrAir(idx < this.data.inputs.size() ? this.data.inputs.get(idx) : null); + } + if (this.isFuelRowSlot(slot)) { + int idx = this.page * COLS + slot - FUEL_START; + return cloneOrAir(idx < this.data.fuels.size() ? this.data.fuels.get(idx) : null); + } + if (this.isOutputSlot(slot)) { + int idx = this.page * COLS + slot - OUTPUT_START; + return cloneOrAir(idx < this.data.outputs.size() ? this.data.outputs.get(idx) : null); + } + if (this.isFunctionalAreaSlot(slot)) { + int ca = this.getCurrentFunctionalArea(); + return cloneOrAir(ca < this.functionalAreas.size() ? + this.functionalAreas.get(ca).getGuiStack(this, slot - FUNCTIONAL_START) : this.border); + } + if (slot >= FUEL_BAR_START && slot < FUEL_START) { + return cloneOrAir(this.fuelBar[slot - FUEL_BAR_START]); + } + if (slot >= PROGRESS_BAR_START && slot < OUTPUT_START) { + return cloneOrAir(this.progBar[slot - PROGRESS_BAR_START]); + } + if (slot == NEXT_FUNCTIONAL_SLOT) return this.nextFunctionalBtn.clone(); + if (slot == PREVIOUS_PAGE_SLOT) return this.prevPageBtn.clone(); + if (slot == NEXT_PAGE_SLOT) return this.nextPageBtn.clone(); + return this.border.clone(); + } + + public void setStack(int slot, @NotNull ItemStack stack) { + if (this.isInputSlot(slot)) { + int idx = this.page * COLS + slot; + while (this.data.inputs.size() <= idx) { + this.data.inputs.add(new ItemStack(Material.AIR)); + } + this.data.inputs.set(idx, cloneOrAir(stack)); + } else if (this.isFuelRowSlot(slot)) { + int idx = this.page * COLS + slot - FUEL_START; + while (this.data.fuels.size() <= idx) { + this.data.fuels.add(new ItemStack(Material.AIR)); + } + this.data.fuels.set(idx, cloneOrAir(stack)); + } else if (this.isOutputSlot(slot)) { + int idx = this.page * COLS + slot - OUTPUT_START; + while (this.data.outputs.size() <= idx) { + this.data.outputs.add(new ItemStack(Material.AIR)); + } + this.data.outputs.set(idx, cloneOrAir(stack)); + } else if (this.isFunctionalAreaSlot(slot)) { + int ca = this.getCurrentFunctionalArea(); + if (ca < this.functionalAreas.size()) { + FureamFunctionalArea area = this.functionalAreas.get(ca); + if (area.isStorable(this, slot - FUNCTIONAL_START)) { + area.setGuiStack(this, slot - FUNCTIONAL_START, cloneOrAir(stack)); + } + } + } + } + + public boolean isValid(int slot, @NotNull ItemStack stack) { + if (!ItemCompat.isEmpty(stack)) { + if (this.isInputSlot(slot)) return true; + if (this.isFuelRowSlot(slot)) { + Material bucket = Material.matchMaterial("BUCKET"); + return FureamFurnaceLogic.isFuel(stack) || bucket != null && stack.getType() == bucket; + } + if (this.isFunctionalAreaSlot(slot)) { + int area = this.getCurrentFunctionalArea(); + return area < this.functionalAreas.size() && + this.functionalAreas.get(area).isStorable(this, slot - 45); + } + } + return false; + } + + private static @NotNull ItemStack cloneOrAir(@Nullable ItemStack stack) { + return ItemCompat.isEmpty(stack) ? new ItemStack(Material.AIR) : stack.clone(); + } + + private static boolean sameStack(@Nullable ItemStack left, @Nullable ItemStack right) { + return ItemCompat.isEmpty(left) && ItemCompat.isEmpty(right) || + !ItemCompat.isEmpty(left) && !ItemCompat.isEmpty(right) && + left.getAmount() == right.getAmount() && left.isSimilar(right); + } + + private static String safeFormat(String template, String fallback, Object... arguments) { + String selected = template == null ? fallback : template; + try { + return String.format(selected, arguments); + } catch (IllegalFormatException failure) { + if (WARNED_FORMATS.add(selected) && FureamMain.getInstance() != null) { + FureamMain.getInstance().getLogger().warning( + "Invalid GUI format string '" + selected + "'; using '" + fallback + "'" + ); + } + return String.format(fallback, arguments); + } + } + + static { + FUNCTIONAL_AREA_REGISTRY.add(RecipeSettingAndXpFunctionalArea::new); + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/screen/RecipeSettingAndXpFunctionalArea.java b/spigot/src/main/java/io/github/eat_ram/fuream/screen/RecipeSettingAndXpFunctionalArea.java new file mode 100644 index 0000000..5a59694 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/screen/RecipeSettingAndXpFunctionalArea.java @@ -0,0 +1,240 @@ +package io.github.eat_ram.fuream.screen; + +import java.util.ArrayList; +import java.util.List; + +import io.github.eat_ram.fuream.api.FureamWorldConfig; +import io.github.eat_ram.fuream.api.FurnaceType; +import io.github.eat_ram.fuream.logic.FureamFurnaceLogic; +import io.github.eat_ram.fuream.compat.ItemCompat; +import io.github.eat_ram.fuream.compat.RecipeHandle; +import io.github.eat_ram.fuream.compat.VersionAdapters; +import io.github.eat_ram.fuream.util.KeyableItemStack; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Range; + +import static io.github.eat_ram.fuream.screen.FureamScreenInventory.makeTooltipItem; + +public class RecipeSettingAndXpFunctionalArea implements FureamFunctionalArea { + private final FureamWorldConfig config; + public final @NotNull Material prevRecipeItem; + public final @NotNull Material nextRecipeItem; + public final @NotNull Material xpIndicatorItem; + + /** Slot 1 (46 in {@link FureamScreenInventory}). */ + public @NotNull ItemStack prevRecipeBtn = new ItemStack(Material.AIR); + /** Slot 2 (47 in {@link FureamScreenInventory}). */ + public @NotNull ItemStack nextRecipeBtn = new ItemStack(Material.AIR); + /** Slot 3 (48 in {@link FureamScreenInventory}). */ + public @NotNull ItemStack recipeResultPreview = new ItemStack(Material.AIR); + /** Slot 4 (49 in {@link FureamScreenInventory}). */ + public @NotNull ItemStack xpIndicator = new ItemStack(Material.AIR); + + public final @NotNull ArrayList<@NotNull RecipeHandle> currentMatchingRecipes = new ArrayList<>(); + private final boolean recipeSelectionAvailable; + private @Range(from = 0, to = Integer.MAX_VALUE) int selectedRecipeIndex; + private float cachedXp = -1f; + + public RecipeSettingAndXpFunctionalArea( + FureamScreenInventory inv, FureamWorldConfig config + ) { + this.config = config; + this.recipeSelectionAvailable = VersionAdapters.current().supportsRecipeOverrides(); + FurnaceType type = inv.furnaceType; + + Material prevMat = parseMaterial(config != null ? config.getGuiPrevRecipeItemId().get(type) : null, Material.BOOK); + Material nextMat = parseMaterial(config != null ? config.getGuiNextRecipeItemId().get(type) : null, Material.BOOK); + Material xpMat = parseMaterial(config != null ? config.getGuiXpIndicatorItemId().get(type) : null, Material.GLASS_PANE); + + this.prevRecipeItem = prevMat; + this.nextRecipeItem = nextMat; + this.xpIndicatorItem = xpMat; + this.updateRecipeSettingArea(inv); + this.refreshVisuals(inv); + } + + private static Material parseMaterial(@Nullable String id, Material fallback) { + return ItemCompat.matchMaterial(id, fallback); + } + + @Override + @Contract(pure = true) + public @NotNull ItemStack getGuiStack(FureamScreenInventory inv, int slot) { + switch (slot) { + case 0: return this.recipeSelectionAvailable ? inv.data.recipeOverridingInput : inv.border; + case 1: return this.recipeSelectionAvailable ? this.prevRecipeBtn : inv.border; + case 2: return this.recipeSelectionAvailable ? this.nextRecipeBtn : inv.border; + case 3: return this.recipeSelectionAvailable ? this.recipeResultPreview : inv.border; + case 4: return this.xpIndicator; + default: return inv.border; + } + } + + @Override + public void setGuiStack(FureamScreenInventory inv, int slot, ItemStack newStack) { + if (this.recipeSelectionAvailable && slot == 0) { + inv.data.recipeOverridingInput = newStack != null ? newStack : new ItemStack(Material.AIR); + this.updateRecipeSettingArea(inv); + } + } + + @Override + public void onGuiClick( + SlotClick slotClick, FureamScreenInventory display, int slot, + int button, ClickType clickType, Player player + ) { + if (!this.recipeSelectionAvailable && slot != 4) return; + switch (slot) { + case 0: + slotClick.onSlotClick(45, button, clickType, player); + this.updateRecipeSettingArea(display); + break; + case 1: { + int curRecipeIndex = this.getSelectedRecipeIndex(); + this.switchSelectedRecipeIndex( + display, curRecipeIndex > 0 ? curRecipeIndex - 1 : this.currentMatchingRecipes.size() + ); + break; + } + case 2: { + int curRecipeIndex = this.getSelectedRecipeIndex(); + this.switchSelectedRecipeIndex( + display, curRecipeIndex < this.currentMatchingRecipes.size() ? curRecipeIndex + 1 : 0 + ); + break; + } + case 4: + FureamFurnaceLogic.grantExperience(player, display.location, display.data); + this.refreshVisuals(display); + break; + } + } + + @Override + @Contract(pure = true) + public boolean isStorable(FureamScreenInventory inv, int slot) { + return this.recipeSelectionAvailable && slot == 0; + } + + @Override + @Contract(pure = true) + public boolean isTakable(FureamScreenInventory inv, int slot) { + return this.recipeSelectionAvailable && slot == 0; + } + + @Contract(pure = true) + public @Range(from = 0, to = Integer.MAX_VALUE) int getSelectedRecipeIndex() { + return this.selectedRecipeIndex; + } + + public void switchSelectedRecipeIndex(FureamScreenInventory inv, int recipeIndex) { + ItemStack toFind = inv.data.recipeOverridingInput; + FurnaceType type = inv.furnaceType; + if (!this.recipeSelectionAvailable || ItemCompat.isEmpty(toFind)) { + this.selectedRecipeIndex = 0; + this.prevRecipeBtn = makeTooltipItem( + new ItemStack(this.prevRecipeItem, 1), + this.config != null ? this.config.getGuiPrevRecipeItemTitle().get(type) : "Previous Recipe", + this.config != null ? this.config.getGuiPrevRecipeEmptyItemTooltip().get(type) : "Please insert an item into the slot on the left to adjust" + ); + this.nextRecipeBtn = makeTooltipItem( + new ItemStack(this.nextRecipeItem, 1), + this.config != null ? this.config.getGuiNextRecipeItemTitle().get(type) : "Next Recipe", + this.config != null ? this.config.getGuiNextRecipeEmptyItemTooltip().get(type) : "Please insert an item into the slot on the left to adjust" + ); + this.recipeResultPreview = new ItemStack(Material.AIR); + return; + } + int size = this.currentMatchingRecipes.size(); + if (recipeIndex > 0 && recipeIndex <= size) { + this.selectedRecipeIndex = recipeIndex; + RecipeHandle recipe = this.currentMatchingRecipes.get(recipeIndex - 1); + inv.data.overriddenRecipes.put(new KeyableItemStack(toFind), recipe.id); + this.prevRecipeBtn = makeTooltipItem( + new ItemStack(this.prevRecipeItem, 1), + this.config != null ? this.config.getGuiPrevRecipeItemTitle().get(type) : "Previous Recipe", + String.format(this.config != null ? this.config.getGuiPrevRecipeItemTooltip().get(type) : "Current: %d / %d", recipeIndex, size) + ); + this.nextRecipeBtn = makeTooltipItem( + new ItemStack(this.nextRecipeItem, 1), + this.config != null ? this.config.getGuiNextRecipeItemTitle().get(type) : "Next Recipe", + String.format(this.config != null ? this.config.getGuiNextRecipeItemTooltip().get(type) : "Current: %d / %d", recipeIndex, size) + ); + this.recipeResultPreview = recipe.result.clone(); + } else { + this.selectedRecipeIndex = 0; + inv.data.overriddenRecipes.remove(new KeyableItemStack(toFind)); + this.prevRecipeBtn = makeTooltipItem( + new ItemStack(this.prevRecipeItem, 1), + this.config != null ? this.config.getGuiPrevRecipeItemTitle().get(type) : "Previous Recipe", + String.format(this.config != null ? this.config.getGuiPrevRecipeArbitraryItemTooltip().get(type) : "Current: arbitrary of %d", size) + ); + this.nextRecipeBtn = makeTooltipItem( + new ItemStack(this.nextRecipeItem, 1), + this.config != null ? this.config.getGuiNextRecipeItemTitle().get(type) : "Next Recipe", + String.format(this.config != null ? this.config.getGuiNextRecipeArbitraryItemTooltip().get(type) : "Current: arbitrary of %d", size) + ); + RecipeHandle recipe = FureamFurnaceLogic.findRecipe(this.displayWorld(inv), toFind, inv.furnaceType).orElse(null); + this.recipeResultPreview = recipe != null ? recipe.result.clone() : new ItemStack(Material.AIR); + } + } + + private org.bukkit.World displayWorld(FureamScreenInventory inv) { + return inv.location.getWorld(); + } + + public void updateMatchingRecipes(FureamScreenInventory inv) { + this.currentMatchingRecipes.clear(); + ItemStack toFind = inv.data.recipeOverridingInput; + if (this.recipeSelectionAvailable && !ItemCompat.isEmpty(toFind)) { + List matches = FureamFurnaceLogic.findAllMatches(this.displayWorld(inv), toFind, inv.furnaceType); + this.currentMatchingRecipes.addAll(matches); + } + } + + public void updateRecipeSettingArea(FureamScreenInventory inv) { + this.updateMatchingRecipes(inv); + ItemStack toFind = inv.data.recipeOverridingInput; + if (!this.recipeSelectionAvailable || ItemCompat.isEmpty(toFind)) { + this.switchSelectedRecipeIndex(inv, 0); + } else { + String overridden = inv.data.overriddenRecipes.get(new KeyableItemStack(toFind)); + boolean found = false; + int i = 1; + for (RecipeHandle recipe : this.currentMatchingRecipes) { + if (recipe.id.equals(overridden)) { + this.switchSelectedRecipeIndex(inv, i); + found = true; + break; + } + ++i; + } + if (!found) { + inv.data.overriddenRecipes.remove(new KeyableItemStack(toFind)); + this.switchSelectedRecipeIndex(inv, 0); + } + } + } + + @Override + public void refreshVisuals(FureamScreenInventory inv) { + if (inv.data.experience != this.cachedXp) { + this.cachedXp = inv.data.experience; + FurnaceType type = inv.furnaceType; + String title = ((int) inv.data.experience > 0) ? + (this.config != null ? this.config.getGuiXpIndicatorGainableItemTitle().get(type) : "XP (click to gain)") : + (this.config != null ? this.config.getGuiXpIndicatorItemTitle().get(type) : "XP"); + String tooltip = String.format( + this.config != null ? this.config.getGuiXpIndicatorItemTooltip().get(type) : "Current: %.1f", + this.cachedXp + ); + this.xpIndicator = makeTooltipItem(new ItemStack(this.xpIndicatorItem, 1), title, tooltip); + } + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/util/FurnacePos.java b/spigot/src/main/java/io/github/eat_ram/fuream/util/FurnacePos.java new file mode 100644 index 0000000..55ae5bc --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/util/FurnacePos.java @@ -0,0 +1,60 @@ +package io.github.eat_ram.fuream.util; + +import java.util.Objects; +import java.util.UUID; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.jetbrains.annotations.NotNull; + +public final class FurnacePos { + public final UUID worldId; + public final @NotNull String worldName; + public final int x; + public final int y; + public final int z; + + public FurnacePos(@NotNull String worldName, int x, int y, int z) { + this(null, worldName, x, y, z); + } + + public FurnacePos(UUID worldId, @NotNull String worldName, int x, int y, int z) { + this.worldId = worldId; + this.worldName = worldName; + this.x = x; + this.y = y; + this.z = z; + } + + public FurnacePos(@NotNull Location loc) { + this( + loc.getWorld() == null ? null : loc.getWorld().getUID(), + loc.getWorld() == null ? "" : loc.getWorld().getName(), + loc.getBlockX(), loc.getBlockY(), loc.getBlockZ() + ); + } + + public FurnacePos(@NotNull Block block) { + this(block.getWorld().getUID(), block.getWorld().getName(), block.getX(), block.getY(), block.getZ()); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof FurnacePos)) return false; + FurnacePos that = (FurnacePos) o; + if (this.x != that.x || this.y != that.y || this.z != that.z) return false; + if (this.worldId != null && that.worldId != null) return this.worldId.equals(that.worldId); + return Objects.equals(this.worldName, that.worldName); + } + + @Override + public int hashCode() { + return Objects.hash(this.worldId == null ? this.worldName : this.worldId, this.x, this.y, this.z); + } + + @Override + public String toString() { + return this.worldName + " (" + this.x + ", " + this.y + ", " + this.z + ")"; + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/util/KeyableItemStack.java b/spigot/src/main/java/io/github/eat_ram/fuream/util/KeyableItemStack.java new file mode 100644 index 0000000..929f575 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/util/KeyableItemStack.java @@ -0,0 +1,56 @@ +package io.github.eat_ram.fuream.util; + +import java.util.Objects; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import io.github.eat_ram.fuream.compat.ItemCompat; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class KeyableItemStack { + public final @NotNull ItemStack stack; + + public KeyableItemStack(@Nullable ItemStack stack) { + if (ItemCompat.isEmpty(stack)) { + this.stack = new ItemStack(Material.AIR); + } else { + this.stack = stack.clone(); + this.stack.setAmount(1); + } + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof KeyableItemStack)) { + return false; + } + KeyableItemStack that = (KeyableItemStack) obj; + if (ItemCompat.isEmpty(this.stack)) { + return ItemCompat.isEmpty(that.stack); + } + if (ItemCompat.isEmpty(that.stack)) { + return false; + } + return this.stack.getType() == that.stack.getType() && + this.stack.getDurability() == that.stack.getDurability() && + this.stack.getAmount() == that.stack.getAmount() && + Objects.equals(this.stack.getItemMeta(), that.stack.getItemMeta()); + } + + @Override + public int hashCode() { + if (ItemCompat.isEmpty(this.stack)) { + return 0; + } + ItemMeta meta = this.stack.getItemMeta(); + return Objects.hash( + this.stack.getType(), this.stack.getDurability(), this.stack.getAmount(), + meta != null ? meta.hashCode() : 0 + ); + } +} diff --git a/spigot/src/main/java/io/github/eat_ram/fuream/util/NbtUtil.java b/spigot/src/main/java/io/github/eat_ram/fuream/util/NbtUtil.java new file mode 100644 index 0000000..f032478 --- /dev/null +++ b/spigot/src/main/java/io/github/eat_ram/fuream/util/NbtUtil.java @@ -0,0 +1,111 @@ +package io.github.eat_ram.fuream.util; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import io.github.eat_ram.fuream.compat.ItemCompat; +import io.github.eat_ram.fuream.nbt.NativeItemNbt; +import io.github.eat_ram.fuream.nbt.NativeNbtCompound; +import io.github.eat_ram.fuream.nbt.NativeNbtList; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class NbtUtil { + public static final int MAX_LANE_SLOTS = 576; + private static final Map, Field> MAP_FIELDS = new ConcurrentHashMap<>(); + public static @NotNull ItemStack fromNbt(@Nullable NativeNbtCompound compound) { + return compound == null ? ItemCompat.empty() : NativeItemNbt.read(compound); + } + + public static void writeItemToCompound(@NotNull NativeNbtCompound target, @Nullable ItemStack stack) { + if (!ItemCompat.isEmpty(stack)) copyTags(NativeItemNbt.write(stack), target); + } + + public static void writeStacks( + @NotNull NativeNbtCompound parent, @NotNull String key, + @NotNull Iterable<@NotNull ItemStack> stacks + ) { + NativeNbtList list = resetCompoundList(parent, key); + int slot = 0; + for (ItemStack stack : stacks) { + if (slot >= MAX_LANE_SLOTS) { + throw new IllegalArgumentException( + "Lane " + key + " exceeds " + MAX_LANE_SLOTS + " slots" + ); + } + if (!ItemCompat.isEmpty(stack)) { + NativeNbtCompound sub = NativeItemNbt.write(stack); + sub.setInt("Slot", slot); + list.add(sub); + } + slot++; + } + } + + public static @NotNull NativeNbtList resetCompoundList( + @NotNull NativeNbtCompound parent, @NotNull String key + ) { + return parent.resetList(key); + } + + public static void readStacks( + @NotNull NativeNbtCompound parent, @NotNull String key, + @NotNull List target + ) { + NativeNbtList list = parent.getList(key); + if (list == null) return; + if (list.size() > MAX_LANE_SLOTS) { + throw new IllegalArgumentException("NBT list " + key + " exceeds " + MAX_LANE_SLOTS + " entries"); + } + for (int i = 0; i < list.size(); i++) { + NativeNbtCompound sub = list.getCompound(i); + if (sub == null || !sub.has("Slot")) continue; + int slot = sub.getInt("Slot", -1); + if (slot < 0 || slot >= MAX_LANE_SLOTS) { + throw new IllegalArgumentException( + "NBT slot " + slot + " is outside 0.." + (MAX_LANE_SLOTS - 1) + ); + } + ItemStack stack = fromNbt(sub); + if (ItemCompat.isEmpty(stack)) continue; + while (target.size() <= slot) target.add(ItemCompat.empty()); + target.set(slot, stack); + } + } + + private static void copyTags(NativeNbtCompound source, NativeNbtCompound target) { + try { + Field sourceField = findMapField(source.raw().getClass()); + Field targetField = findMapField(target.raw().getClass()); + @SuppressWarnings("unchecked") + Map from = (Map) sourceField.get(source.raw()); + @SuppressWarnings("unchecked") + Map to = (Map) targetField.get(target.raw()); + to.putAll(from); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Unable to copy native item NBT", e); + } + } + + private static Field findMapField(Class type) { + Field cached = MAP_FIELDS.get(type); + if (cached != null) return cached; + for (Class current = type; current != null; current = current.getSuperclass()) { + for (Field field : current.getDeclaredFields()) { + if (!Modifier.isStatic(field.getModifiers()) && Map.class.isAssignableFrom(field.getType())) { + field.setAccessible(true); + MAP_FIELDS.put(type, field); + return field; + } + } + } + throw new IllegalStateException("NBT map field missing on " + type.getName()); + } + + private NbtUtil() { + } +} diff --git a/spigot/src/main/resources/plugin.yml b/spigot/src/main/resources/plugin.yml new file mode 100644 index 0000000..910e4e0 --- /dev/null +++ b/spigot/src/main/resources/plugin.yml @@ -0,0 +1,15 @@ +name: Fuream +version: ${version} +main: io.github.eat_ram.fuream.FureamMain +api-version: '1.13' +commands: + fuream: + description: Fuream plugin management + usage: / [reload|status] +permissions: + fuream.reload: + description: Allows reloading Fuream configs + default: op + fuream.status: + description: Allows viewing Fuream status + default: op diff --git a/spigot/src/test/java/io/github/eat_ram/fuream/ConfigTest.java b/spigot/src/test/java/io/github/eat_ram/fuream/ConfigTest.java new file mode 100644 index 0000000..91fa541 --- /dev/null +++ b/spigot/src/test/java/io/github/eat_ram/fuream/ConfigTest.java @@ -0,0 +1,91 @@ +package io.github.eat_ram.fuream; + +import io.github.eat_ram.fuream.api.FurnaceType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.nio.file.Files; +import java.nio.file.Path; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import static org.junit.jupiter.api.Assertions.*; + +public class ConfigTest { + @TempDir + Path tempDir; + + @Test + public void testDefaultConfig() { + FureamWorldConfigImpl config = new FureamWorldConfigImpl(); + for (FurnaceType type : FurnaceType.values()) { + assertEquals(9, config.getInputSlotCount().get(type)); + assertEquals(9, config.getFuelSlotCount().get(type)); + assertEquals(9, config.getOutputSlotCount().get(type)); + assertNotNull(config.getGuiTitle().get(type)); + assertEquals("GUI Border", config.getGuiBorderItemTitle().get(type)); + assertEquals("Fuel", config.getGuiFuelItemTitle().get(type)); + assertEquals("Progress", config.getGuiProgressItemTitle().get(type)); + assertEquals("Previous Recipe", config.getGuiPrevRecipeItemTitle().get(type)); + assertEquals("Next Recipe", config.getGuiNextRecipeItemTitle().get(type)); + assertEquals("XP", config.getGuiXpIndicatorItemTitle().get(type)); + assertEquals("XP (click to gain)", config.getGuiXpIndicatorGainableItemTitle().get(type)); + assertEquals("Next Functional Area", config.getGuiNextFunctionalAreaItemTitle().get(type)); + assertEquals("Previous Page", config.getGuiPrevPageItemTitle().get(type)); + assertEquals("Next Page", config.getGuiNextPageItemTitle().get(type)); + } + } + + @Test + public void writerEmitsAllFieldsAndPreservesUnknownKeys() throws Exception { + Path file = tempDir.resolve("fuream.json"); + Files.write(file, "{\"third_party_key\":42}".getBytes("UTF-8")); + FureamWorldConfigImpl.writeWorldConfig(new FureamWorldConfigImpl(), file.toFile()); + + JsonObject root = new JsonParser().parse(new String(Files.readAllBytes(file), "UTF-8")).getAsJsonObject(); + assertEquals(42, root.get("third_party_key").getAsInt()); + assertTrue(root.has("gui_prev_recipe_item_id")); + assertTrue(root.has("gui_xp_indicator_item_id")); + assertTrue(root.has("gui_next_page_item_tooltip")); + assertTrue(root.has("gui_title")); + assertEquals(39, root.entrySet().size()); + } + + @Test + public void localizedGuiTitleAcceptsSingleAndPerTypeValues() throws Exception { + Path singleFile = tempDir.resolve("single.json"); + Files.write(singleFile, "{\"gui_title\":\"Localized Furnace\"}".getBytes("UTF-8")); + FureamWorldConfigImpl single = new FureamWorldConfigImpl(); + FureamWorldConfigImpl.readWorldConfig(single, singleFile.toFile()); + for (FurnaceType type : FurnaceType.values()) { + assertEquals("Localized Furnace", single.getGuiTitle().get(type)); + } + + Path mappedFile = tempDir.resolve("mapped.json"); + Files.write(mappedFile, "{\"gui_title\":{\"FURNACE\":\"Localized Oven\"}}".getBytes("UTF-8")); + FureamWorldConfigImpl mapped = new FureamWorldConfigImpl(); + FureamWorldConfigImpl.readWorldConfig(mapped, mappedFile.toFile()); + assertEquals("Localized Oven", mapped.getGuiTitle().get(FurnaceType.FURNACE)); + assertEquals("Smoker", mapped.getGuiTitle().get(FurnaceType.SMOKER)); + } + + @Test + public void writerRefusesToOverwriteMalformedExistingConfig() throws Exception { + Path file = tempDir.resolve("broken.json"); + byte[] original = "{broken".getBytes("UTF-8"); + Files.write(file, original); + + assertThrows(Exception.class, () -> + FureamWorldConfigImpl.writeWorldConfig(new FureamWorldConfigImpl(), file.toFile()) + ); + assertArrayEquals(original, Files.readAllBytes(file)); + } + + @Test + public void oversizedLaneCountIsRejected() throws Exception { + Path file = tempDir.resolve("oversized.json"); + Files.write(file, "{\"input_slot_count\":{\"FURNACE\":577}}".getBytes("UTF-8")); + + assertThrows(Exception.class, () -> + FureamWorldConfigImpl.readWorldConfig(new FureamWorldConfigImpl(), file.toFile()) + ); + } +} diff --git a/spigot/src/test/java/io/github/eat_ram/fuream/HopperAndDataTest.java b/spigot/src/test/java/io/github/eat_ram/fuream/HopperAndDataTest.java new file mode 100644 index 0000000..027aff3 --- /dev/null +++ b/spigot/src/test/java/io/github/eat_ram/fuream/HopperAndDataTest.java @@ -0,0 +1,80 @@ +package io.github.eat_ram.fuream; + +import java.util.List; + +import io.github.eat_ram.fuream.data.FureamFurnaceData; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class HopperAndDataTest { + + private boolean isSameItem(ItemStack a, ItemStack b) { + if (a == null || b == null) return false; + if (a.getType().isAir() || b.getType().isAir()) return false; + return a.getType() == b.getType(); + } + + private boolean insertIntoList(List list, ItemStack item, int maxSlots) { + while (list.size() < maxSlots) { + list.add(new ItemStack(Material.AIR)); + } + + // 1. Try stacking with existing matching items + for (int i = 0; i < maxSlots && i < list.size(); i++) { + ItemStack s = list.get(i); + if (s != null && isSameItem(s, item) && s.getAmount() < s.getMaxStackSize()) { + s.setAmount(s.getAmount() + 1); + return true; + } + } + + // 2. Try empty slot + for (int i = 0; i < maxSlots && i < list.size(); i++) { + ItemStack s = list.get(i); + if (s == null || s.getType().isAir()) { + ItemStack single = item.clone(); + single.setAmount(1); + list.set(i, single); + return true; + } + } + + return false; + } + + @Test + public void test64ItemsInsertion() { + FureamFurnaceData data = new FureamFurnaceData(); + int maxSlots = 18; + + ItemStack rawIron = new ItemStack(Material.RAW_IRON, 1); + for (int i = 0; i < 64; i++) { + boolean inserted = insertIntoList(data.inputs, rawIron, maxSlots); + assertTrue(inserted, "Should insert item " + (i + 1)); + } + + assertEquals(Material.RAW_IRON, data.inputs.get(0).getType()); + assertEquals(64, data.inputs.get(0).getAmount()); + } + + @Test + public void testOver64ItemsInsertion() { + FureamFurnaceData data = new FureamFurnaceData(); + int maxSlots = 18; + + ItemStack rawIron = new ItemStack(Material.RAW_IRON, 1); + for (int i = 0; i < 100; i++) { + boolean inserted = insertIntoList(data.inputs, rawIron, maxSlots); + assertTrue(inserted, "Should insert item " + (i + 1)); + } + + assertEquals(Material.RAW_IRON, data.inputs.get(0).getType()); + assertEquals(64, data.inputs.get(0).getAmount()); + assertEquals(Material.RAW_IRON, data.inputs.get(1).getType()); + assertEquals(36, data.inputs.get(1).getAmount()); + } +} diff --git a/spigot/src/test/java/io/github/eat_ram/fuream/compat/ServerVersionTest.java b/spigot/src/test/java/io/github/eat_ram/fuream/compat/ServerVersionTest.java new file mode 100644 index 0000000..d4ccb73 --- /dev/null +++ b/spigot/src/test/java/io/github/eat_ram/fuream/compat/ServerVersionTest.java @@ -0,0 +1,24 @@ +package io.github.eat_ram.fuream.compat; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class ServerVersionTest { + @Test + public void parsesClassicAndDateBasedVersions() { + ServerVersion classic = ServerVersion.parse("1.20.6-R0.1-SNAPSHOT"); + assertEquals(1, classic.major); + assertEquals(20, classic.minor); + assertEquals(6, classic.patch); + assertTrue(classic.atLeast(1, 20, 5)); + + ServerVersion dateBased = ServerVersion.parse("26.2-R0.1-SNAPSHOT"); + assertEquals(26, dateBased.major); + assertEquals(2, dateBased.minor); + assertEquals("components", dateBased.adapterFamily()); + assertFalse(dateBased.atMost(1, 12)); + } +} diff --git a/spigot/src/test/java/io/github/eat_ram/fuream/compat/VersionAdaptersTest.java b/spigot/src/test/java/io/github/eat_ram/fuream/compat/VersionAdaptersTest.java new file mode 100644 index 0000000..56a348a --- /dev/null +++ b/spigot/src/test/java/io/github/eat_ram/fuream/compat/VersionAdaptersTest.java @@ -0,0 +1,28 @@ +package io.github.eat_ram.fuream.compat; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class VersionAdaptersTest { + @Test + public void selectsAllFourFamiliesAtTheirBoundaries() { + assertEquals("legacy-numeric", VersionAdapters.select(ServerVersion.parse("1.12.2")).id()); + assertEquals("flattening-obfuscated", VersionAdapters.select(ServerVersion.parse("1.13.2")).id()); + assertEquals("mojang-named", VersionAdapters.select(ServerVersion.parse("1.20.4")).id()); + assertEquals("components", VersionAdapters.select(ServerVersion.parse("1.20.5")).id()); + } + + @Test + public void exposesEraCapabilities() { + VersionAdapter legacy = VersionAdapters.select(ServerVersion.parse("1.7.10")); + VersionAdapter components = VersionAdapters.select(ServerVersion.parse("26.2")); + assertFalse(legacy.supportsSpecialFurnaces()); + assertFalse(legacy.supportsRecipeOverrides()); + assertTrue(components.supportsSpecialFurnaces()); + assertTrue(components.supportsRecipeOverrides()); + assertTrue(components.usesDataComponents()); + } +} diff --git a/spigot/src/test/java/io/github/eat_ram/fuream/logic/FureamFurnaceEngineTest.java b/spigot/src/test/java/io/github/eat_ram/fuream/logic/FureamFurnaceEngineTest.java new file mode 100644 index 0000000..68b38c5 --- /dev/null +++ b/spigot/src/test/java/io/github/eat_ram/fuream/logic/FureamFurnaceEngineTest.java @@ -0,0 +1,252 @@ +package io.github.eat_ram.fuream.logic; + +import java.util.ArrayList; +import java.util.List; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.junit.jupiter.api.Test; + +import io.github.eat_ram.fuream.compat.ComparatorCompat; +import io.github.eat_ram.fuream.compat.RecipeHandle; +import io.github.eat_ram.fuream.api.FurnaceType; +import io.github.eat_ram.fuream.data.FureamFurnaceData; +import io.github.eat_ram.fuream.hook.FurnaceManager.FurnaceContext; +import io.github.eat_ram.fuream.util.FurnacePos; +import io.github.eat_ram.fuream.util.KeyableItemStack; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class FureamFurnaceEngineTest { + @Test + public void emptyVanillaResultDoesNotRemoveFirstVirtualOutput() { + List outputs = outputSlots(stack(Material.IRON_INGOT, 1)); + + ItemStack remainder = FureamFurnaceEngine.recoverVanillaOutput(outputs, null, 1); + + assertNull(remainder); + assertEquals(Material.IRON_INGOT, outputs.get(0).getType()); + assertEquals(1, outputs.get(0).getAmount()); + } + + @Test + public void strandedVanillaResultMovesBackIntoVirtualOutputs() { + List outputs = outputSlots(stack(Material.AIR, 1)); + + ItemStack remainder = FureamFurnaceEngine.recoverVanillaOutput( + outputs, stack(Material.IRON_INGOT, 1), 1 + ); + + assertNull(remainder); + assertEquals(Material.IRON_INGOT, outputs.get(0).getType()); + assertEquals(1, outputs.get(0).getAmount()); + } + + @Test + public void fullVirtualOutputsKeepVanillaRemainder() { + List outputs = outputSlots(stack(Material.IRON_INGOT, 64)); + + ItemStack remainder = FureamFurnaceEngine.recoverVanillaOutput( + outputs, stack(Material.IRON_INGOT, 2), 1 + ); + + assertEquals(64, outputs.get(0).getAmount()); + assertNotNull(remainder); + assertEquals(Material.IRON_INGOT, remainder.getType()); + assertEquals(2, remainder.getAmount()); + } + + @Test + public void partiallyFullVirtualOutputsOnlyLeaveUninsertedRemainder() { + List outputs = outputSlots(stack(Material.IRON_INGOT, 63)); + + ItemStack remainder = FureamFurnaceEngine.recoverVanillaOutput( + outputs, stack(Material.IRON_INGOT, 2), 1 + ); + + assertEquals(64, outputs.get(0).getAmount()); + assertNotNull(remainder); + assertEquals(Material.IRON_INGOT, remainder.getType()); + assertEquals(1, remainder.getAmount()); + } + + @Test + public void resultSpreadsAcrossCompatibleOutputSlots() { + List outputs = new ArrayList<>(); + outputs.add(stack(Material.IRON_INGOT, 63)); + outputs.add(stack(Material.AIR, 1)); + + ItemStack remainder = FureamFurnaceEngine.insertStackIntoList( + outputs, stack(Material.IRON_INGOT, 3), 2 + ); + + assertEquals(64, outputs.get(0).getAmount()); + assertEquals(2, outputs.get(1).getAmount()); + assertEquals(Material.AIR, remainder.getType()); + } + + @Test + public void wetSpongeUsesFreedSingleBucketSlotWhenFuelRegionIsFull() { + List fuels = new ArrayList<>(); + fuels.add(stack(Material.BUCKET, 1)); + fuels.add(stack(Material.COAL, 1)); + + FureamFurnaceEngine.handleWetSponge(stack(Material.WET_SPONGE, 1), fuels, 2); + + assertEquals(Material.WATER_BUCKET, fuels.get(0).getType()); + assertEquals(1, fuels.get(0).getAmount()); + assertEquals(Material.COAL, fuels.get(1).getType()); + } + + @Test + public void wetSpongeConvertsBucketWhenFuelRegionHasAnEmptySlot() { + List fuels = new ArrayList<>(); + fuels.add(stack(Material.BUCKET, 1)); + fuels.add(stack(Material.AIR, 1)); + + FureamFurnaceEngine.handleWetSponge(stack(Material.WET_SPONGE, 1), fuels, 2); + + assertEquals(Material.WATER_BUCKET, fuels.get(0).getType()); + assertEquals(Material.AIR, fuels.get(1).getType()); + assertEquals(2, fuels.size()); + } + + @Test + public void wetSpongeDoesNotConsumeFromMultiBucketStackWithoutSpace() { + List fuels = new ArrayList<>(); + fuels.add(stack(Material.BUCKET, 2)); + fuels.add(stack(Material.COAL, 1)); + + FureamFurnaceEngine.handleWetSponge(stack(Material.WET_SPONGE, 1), fuels, 2); + + assertEquals(Material.BUCKET, fuels.get(0).getType()); + assertEquals(2, fuels.get(0).getAmount()); + } + + @Test + public void specialFurnacesKeepTheFullVanillaFuelDuration() { + assertEquals(1600, FureamFurnaceEngine.effectiveFuelTime(1600, FurnaceType.FURNACE)); + assertEquals(1600, FureamFurnaceEngine.effectiveFuelTime(1600, FurnaceType.SMOKER)); + assertEquals(1600, FureamFurnaceEngine.effectiveFuelTime(1600, FurnaceType.BLAST_FURNACE)); + } + + @Test + public void comparatorUsesOnlyTheThreeMaskedStacks() { + FureamFurnaceData data = new FureamFurnaceData(); + data.inputs.set(0, stack(Material.IRON_ORE, 64)); + data.inputs.add(stack(Material.GOLD_ORE, 64)); + data.fuels.set(0, stack(Material.COAL, 64)); + data.outputs.set(0, stack(Material.IRON_INGOT, 64)); + + assertEquals(15, ComparatorCompat.calculate(data)); + } + + @Test + public void legacyDurabilityParticipatesInRecipeOverrideIdentity() { + ItemStack first = new TestItemStack(Material.LEGACY_WOOL, 1, (short) 1); + ItemStack second = new TestItemStack(Material.LEGACY_WOOL, 1, (short) 2); + + assertNotEquals(new KeyableItemStack(first), new KeyableItemStack(second)); + } + + @Test + public void changingPreferredSlotOrItemResetsRecipeProgress() { + FurnaceContext context = new FurnaceContext( + new FurnacePos("test", 0, 64, 0), FurnaceType.FURNACE + ); + ItemStack iron = stack(Material.IRON_ORE, 8); + assertTrue(FureamFurnaceEngine.updateInputIdentity(context, 0, iron)); + + context.cookTime = 41; + context.data.runningRecipe = "minecraft:iron_ingot"; + context.startSmeltPending = false; + assertFalse(FureamFurnaceEngine.updateInputIdentity(context, 0, iron.clone())); + assertEquals(41, context.cookTime); + + assertTrue(FureamFurnaceEngine.updateInputIdentity(context, 1, iron.clone())); + assertEquals(0, context.cookTime); + assertNull(context.data.runningRecipe); + assertTrue(context.startSmeltPending); + + context.cookTime = 12; + context.data.runningRecipe = "minecraft:iron_ingot"; + assertTrue(FureamFurnaceEngine.updateInputIdentity( + context, 1, stack(Material.GOLD_ORE, 1) + )); + assertEquals(0, context.cookTime); + assertNull(context.data.runningRecipe); + } + + @Test + public void recipeExperienceAccumulatesWithoutRounding() { + FureamFurnaceData data = new FureamFurnaceData(); + data.experience = 1.25f; + RecipeHandle recipe = new RecipeHandle( + null, "test:recipe", stack(Material.IRON_INGOT, 1), 200, 0.7f + ); + + FureamFurnaceLogic.stashExperience(recipe, data); + + assertEquals(1.95f, data.experience, 0.0001f); + } + + private static List outputSlots(ItemStack first) { + List outputs = new ArrayList<>(); + outputs.add(first); + return outputs; + } + + private static ItemStack stack(Material material, int amount) { + return new TestItemStack(material, amount); + } + + /** Avoids the Bukkit ItemFactory dependency in this pure unit test. */ + private static final class TestItemStack extends ItemStack { + private short testDurability; + + private TestItemStack(Material material, int amount) { + super(material, amount); + } + + private TestItemStack(Material material, int amount, short durability) { + super(material, amount, durability); + this.testDurability = durability; + } + + @Override + public boolean isSimilar(ItemStack stack) { + return stack != null && this.getType() == stack.getType(); + } + + @Override + public int getMaxStackSize() { + return 64; + } + + @Override + public ItemMeta getItemMeta() { + return null; + } + + @Override + public short getDurability() { + return this.testDurability; + } + + @Override + public void setDurability(short durability) { + this.testDurability = durability; + } + + @Override + public ItemStack clone() { + return new TestItemStack(this.getType(), this.getAmount(), this.getDurability()); + } + } +} diff --git a/spigot/src/test/java/io/github/eat_ram/fuream/logic/NativeLaneProjectionTest.java b/spigot/src/test/java/io/github/eat_ram/fuream/logic/NativeLaneProjectionTest.java new file mode 100644 index 0000000..070a90f --- /dev/null +++ b/spigot/src/test/java/io/github/eat_ram/fuream/logic/NativeLaneProjectionTest.java @@ -0,0 +1,97 @@ +package io.github.eat_ram.fuream.logic; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class NativeLaneProjectionTest { + @Test + public void publishingKeepsCanonicalVirtualStack() { + List lane = lane(stack(Material.IRON_INGOT, 12)); + NativeLaneProjection.State state = new NativeLaneProjection.State(); + + ItemStack published = NativeLaneProjection.publish( + lane, null, NativeLaneProjection.SelectionMode.FIRST_PRESENT, state + ); + + assertEquals(12, published.getAmount()); + assertEquals(12, lane.get(0).getAmount()); + assertEquals(0, state.getSourceSlot()); + } + + @Test + public void hopperRemovalReplacesCanonicalAmountWithoutDuplication() { + List lane = lane(stack(Material.IRON_INGOT, 12)); + NativeLaneProjection.State state = new NativeLaneProjection.State(); + ItemStack published = NativeLaneProjection.publish( + lane, null, NativeLaneProjection.SelectionMode.FIRST_PRESENT, state + ); + published.setAmount(7); + + assertNull(NativeLaneProjection.reconcile(lane, published, 1, state)); + assertEquals(7, lane.get(0).getAmount()); + } + + @Test + public void completeHopperRemovalClearsOnlyProjectedSource() { + List lane = lane(stack(Material.IRON_INGOT, 12), stack(Material.GOLD_INGOT, 4)); + NativeLaneProjection.State state = new NativeLaneProjection.State(); + NativeLaneProjection.publish(lane, null, NativeLaneProjection.SelectionMode.FIRST_PRESENT, state); + + assertNull(NativeLaneProjection.reconcile(lane, null, 2, state)); + assertEquals(Material.AIR, lane.get(0).getType()); + assertEquals(4, lane.get(1).getAmount()); + } + + @Test + public void fuelProjectionPublishesOnlyBucketRemainders() { + List lane = lane(stack(Material.COAL, 8), stack(Material.BUCKET, 1)); + ItemStack published = NativeLaneProjection.publish( + lane, null, NativeLaneProjection.SelectionMode.BUCKET_REMAINDER, + new NativeLaneProjection.State() + ); + assertEquals(Material.BUCKET, published.getType()); + } + + private static List lane(ItemStack... stacks) { + return new ArrayList<>(Arrays.asList(stacks)); + } + + private static ItemStack stack(Material material, int amount) { + return new TestItemStack(material, amount); + } + + private static final class TestItemStack extends ItemStack { + private TestItemStack(Material material, int amount) { + super(material, amount); + } + + @Override + public boolean isSimilar(ItemStack stack) { + return stack != null && this.getType() == stack.getType(); + } + + @Override + public int getMaxStackSize() { + return 64; + } + + @Override + public ItemMeta getItemMeta() { + return null; + } + + @Override + public ItemStack clone() { + return new TestItemStack(this.getType(), this.getAmount()); + } + } +} diff --git a/spigot/src/test/java/io/github/eat_ram/fuream/logic/PassiveLaneProjectionTest.java b/spigot/src/test/java/io/github/eat_ram/fuream/logic/PassiveLaneProjectionTest.java new file mode 100644 index 0000000..81e879b --- /dev/null +++ b/spigot/src/test/java/io/github/eat_ram/fuream/logic/PassiveLaneProjectionTest.java @@ -0,0 +1,171 @@ +package io.github.eat_ram.fuream.logic; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import io.github.eat_ram.fuream.logic.PassiveLaneProjection.SelectionMode; +import io.github.eat_ram.fuream.logic.PassiveLaneProjection.State; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class PassiveLaneProjectionTest { + @Test + public void inputAndOutputProjectOnlyFirstPresentStack() { + List lane = lane( + air(), stack(Material.DIAMOND, 32), stack(Material.DIAMOND, 16) + ); + State state = new State(); + + ItemStack projected = PassiveLaneProjection.begin( + lane, null, SelectionMode.FIRST_PRESENT, state + ); + + assertEquals(Material.DIAMOND, projected.getType()); + assertEquals(32, projected.getAmount()); + assertEquals(1, state.getSourceSlot()); + assertEquals(Material.AIR, lane.get(1).getType()); + assertEquals(Material.DIAMOND, lane.get(2).getType()); + assertEquals(16, lane.get(2).getAmount()); + } + + @Test + public void removingProjectedStackDoesNotTouchHiddenStack() { + List lane = lane( + stack(Material.DIAMOND, 32), stack(Material.DIAMOND, 16) + ); + State state = new State(); + + PassiveLaneProjection.begin(lane, null, SelectionMode.FIRST_PRESENT, state); + ItemStack nativeAfterPlayerRemoval = null; + + assertNull(nativeAfterPlayerRemoval); + assertEquals(Material.DIAMOND, lane.get(1).getType()); + assertEquals(16, lane.get(1).getAmount()); + assertEquals(0, state.getSourceSlot()); + } + + @Test + public void fuelPrefersBurnableStackOverEarlierNonFuel() { + List lane = lane( + stack(Material.BUCKET, 1), stack(Material.COAL, 8), stack(Material.STICK, 3) + ); + + ItemStack projected = PassiveLaneProjection.begin( + lane, null, SelectionMode.FUEL_THEN_PRESENT, new State() + ); + + assertEquals(Material.COAL, projected.getType()); + assertEquals(Material.BUCKET, lane.get(0).getType()); + assertEquals(Material.AIR, lane.get(1).getType()); + } + + @Test + public void fuelFallsBackToFirstPresentStack() { + List lane = lane(air(), stack(Material.BUCKET, 1), stack(Material.IRON_INGOT, 2)); + State state = new State(); + + ItemStack projected = PassiveLaneProjection.begin( + lane, null, SelectionMode.FUEL_THEN_PRESENT, state + ); + + assertEquals(Material.BUCKET, projected.getType()); + assertEquals(1, state.getSourceSlot()); + } + + @Test + public void restoreReturnsModifiedProjectionToItsOriginalSlot() { + List lane = lane(air(), stack(Material.COAL, 8), air()); + State state = new State(); + ItemStack projected = PassiveLaneProjection.begin( + lane, null, SelectionMode.FUEL_THEN_PRESENT, state + ); + projected.setAmount(3); + + ItemStack remainder = PassiveLaneProjection.restore(lane, projected, state); + + assertNull(remainder); + assertEquals(Material.COAL, lane.get(1).getType()); + assertEquals(3, lane.get(1).getAmount()); + assertEquals(-1, state.getSourceSlot()); + } + + @Test + public void existingNativeStackDoesNotDeleteIdenticalHiddenStacks() { + List lane = lane(stack(Material.DIAMOND, 32), stack(Material.DIAMOND, 16)); + State state = new State(); + ItemStack nativeStack = stack(Material.DIAMOND, 32); + + ItemStack projected = PassiveLaneProjection.begin( + lane, nativeStack, SelectionMode.FIRST_PRESENT, state + ); + + assertEquals(nativeStack, projected); + assertEquals(32, lane.get(0).getAmount()); + assertEquals(16, lane.get(1).getAmount()); + assertEquals(-1, state.getSourceSlot()); + } + + @Test + public void repeatedDisableEnableRoundTripDoesNotDuplicate() { + List lane = lane(stack(Material.IRON_ORE, 12), air()); + State state = new State(); + + ItemStack firstProjection = PassiveLaneProjection.begin( + lane, null, SelectionMode.FIRST_PRESENT, state + ); + assertNull(PassiveLaneProjection.restore(lane, firstProjection, state)); + ItemStack secondProjection = PassiveLaneProjection.begin( + lane, null, SelectionMode.FIRST_PRESENT, state + ); + assertNull(PassiveLaneProjection.restore(lane, secondProjection, state)); + + assertEquals(Material.IRON_ORE, lane.get(0).getType()); + assertEquals(12, lane.get(0).getAmount()); + assertEquals(Material.AIR, lane.get(1).getType()); + } + + private static List lane(ItemStack... stacks) { + return new ArrayList<>(Arrays.asList(stacks)); + } + + private static ItemStack air() { + return stack(Material.AIR, 1); + } + + private static ItemStack stack(Material material, int amount) { + return new TestItemStack(material, amount); + } + + /** Avoids the Bukkit ItemFactory dependency in this pure unit test. */ + private static final class TestItemStack extends ItemStack { + private TestItemStack(Material material, int amount) { + super(material, amount); + } + + @Override + public boolean isSimilar(ItemStack stack) { + return stack != null && this.getType() == stack.getType(); + } + + @Override + public int getMaxStackSize() { + return 64; + } + + @Override + public ItemMeta getItemMeta() { + return null; + } + + @Override + public ItemStack clone() { + return new TestItemStack(this.getType(), this.getAmount()); + } + } +} diff --git a/spigot/src/test/java/io/github/eat_ram/fuream/nbt/FurnaceRootNbtBridgeTest.java b/spigot/src/test/java/io/github/eat_ram/fuream/nbt/FurnaceRootNbtBridgeTest.java new file mode 100644 index 0000000..d6843f8 --- /dev/null +++ b/spigot/src/test/java/io/github/eat_ram/fuream/nbt/FurnaceRootNbtBridgeTest.java @@ -0,0 +1,24 @@ +package io.github.eat_ram.fuream.nbt; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class FurnaceRootNbtBridgeTest { + @Test + public void passiveProjectionKeepsEmptyFureamDataTag() { + assertTrue(FurnaceRootNbtBridge.shouldKeepDataTag(true, true)); + } + + @Test + public void populatedFureamDataTagIsAlwaysKept() { + assertTrue(FurnaceRootNbtBridge.shouldKeepDataTag(false, false)); + assertTrue(FurnaceRootNbtBridge.shouldKeepDataTag(false, true)); + } + + @Test + public void emptyActiveFureamDataTagMayBeRemoved() { + assertFalse(FurnaceRootNbtBridge.shouldKeepDataTag(true, false)); + } +} diff --git a/spigot/src/variants/components/plugin.yml b/spigot/src/variants/components/plugin.yml new file mode 100644 index 0000000..536a252 --- /dev/null +++ b/spigot/src/variants/components/plugin.yml @@ -0,0 +1,15 @@ +name: Fuream +version: ${version} +main: io.github.eat_ram.fuream.FureamMain +api-version: '1.20.5' +commands: + fuream: + description: Fuream plugin management + usage: / [reload|status] +permissions: + fuream.reload: + description: Allows reloading Fuream configs + default: op + fuream.status: + description: Allows viewing Fuream status + default: op diff --git a/spigot/src/variants/flattening/plugin.yml b/spigot/src/variants/flattening/plugin.yml new file mode 100644 index 0000000..910e4e0 --- /dev/null +++ b/spigot/src/variants/flattening/plugin.yml @@ -0,0 +1,15 @@ +name: Fuream +version: ${version} +main: io.github.eat_ram.fuream.FureamMain +api-version: '1.13' +commands: + fuream: + description: Fuream plugin management + usage: / [reload|status] +permissions: + fuream.reload: + description: Allows reloading Fuream configs + default: op + fuream.status: + description: Allows viewing Fuream status + default: op diff --git a/spigot/src/variants/legacy/plugin.yml b/spigot/src/variants/legacy/plugin.yml new file mode 100644 index 0000000..ba3e142 --- /dev/null +++ b/spigot/src/variants/legacy/plugin.yml @@ -0,0 +1,14 @@ +name: Fuream +version: ${version} +main: io.github.eat_ram.fuream.FureamMain +commands: + fuream: + description: Fuream plugin management + usage: / [reload|status] +permissions: + fuream.reload: + description: Allows reloading Fuream configs + default: op + fuream.status: + description: Allows viewing Fuream status + default: op