From dbaef2ef59f4f7c0260adf6a748089aa346c65a2 Mon Sep 17 00:00:00 2001 From: Legends11 <235496468+tickwarden@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:41:15 +0300 Subject: [PATCH 1/4] Add files via upload --- README.md | 54 ++++++++ .../example/gui/gates_and_logic_demo.json | 131 ++++++++++++++++++ .../toolkitmc/guiapi/command/GuiCommand.java | 15 ++ .../guiapi/gui/BarrelGuiHandler.java | 127 ++++++++++++----- .../toolkitmc/guiapi/gui/ConditionLogic.java | 40 ++++++ .../toolkitmc/guiapi/gui/GuiDefinition.java | 110 +++++++++++---- .../dev/toolkitmc/guiapi/gui/ItemSpec.java | 31 +++++ .../toolkitmc/guiapi/gui/PlaceholderUtil.java | 49 +++++++ 8 files changed, 494 insertions(+), 63 deletions(-) create mode 100644 example-datapack/data/example/gui/gates_and_logic_demo.json create mode 100644 src/main/java/dev/toolkitmc/guiapi/gui/ConditionLogic.java create mode 100644 src/main/java/dev/toolkitmc/guiapi/gui/ItemSpec.java create mode 100644 src/main/java/dev/toolkitmc/guiapi/gui/PlaceholderUtil.java diff --git a/README.md b/README.md index 5649abe..12e658b 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ The GUI ID used in commands is `:` — matching the file path u | `filler` | object | — | Background filler configuration (see below). | | `on_open` | action[] | `[]` | Actions executed when the GUI is opened. | | `on_close` | action[] | `[]` | Actions executed when the GUI is closed (any reason). | +| `open_condition` | condition | — | Player must meet this condition to open the GUI (see [Open gate](#open-gate)). | +| `on_deny` | action[] | `[]` | Actions executed instead of opening when `open_condition` is false. Empty = short action-bar notice. | | `buttons` | button[] | `[]` | List of button definitions. | #### Filler fields @@ -108,6 +110,14 @@ Supported in `title`, button `name`, `lore`, `message` values, and `run_command` | `{pages}` | Total page count | | `{score:objective}` | Player's score in the given scoreboard objective | | `{var:key}` | Player's runtime variable `key` (empty string if unset) | +| `{xp}` | Player's experience level | +| `{input}` | Last text entered through an `anvil_input` action | +| `{health}` / `{max_health}` | Current / maximum health in half-hearts, rounded up | +| `{food}` | Hunger level (0–20) | +| `{online}` | Number of players currently online | +| `{pos_x}` `{pos_y}` `{pos_z}` | Player's block coordinates | + +Text inserted by `{var:key}` and `{input}` is treated as plain text — it is never scanned for further placeholders. --- @@ -115,6 +125,12 @@ Supported in `title`, button `name`, `lore`, `message` values, and `run_command` Any action can be delayed by adding `"delay": int` (in ticks) to its JSON block. +Any action can also carry a `"condition"` (same format as button conditions, including `all` / `any` / `not`). It is checked right when the action is about to run — after its delay — and a false condition skips just that action while the rest of the chain continues: + +```json +{ "type": "message", "value": "§6VIP bonus applied!", "condition": { "type": "has_tag", "value": "vip" } } +``` + | Type | `value` format | `run_with` | Description | |------|--------------|------------|-------------| | `run_command` | Command string | `player` · `console` | Run a command. Default: player. Supports placeholders. | @@ -136,6 +152,9 @@ Any action can be delayed by adding `"delay": int` (in ticks) to its JSON block. | `sub_var` | Integer to subtract | — | Subtract an integer from a runtime variable. Requires `"var": "key"`. | | `reset_var` | — | — | Delete a single runtime variable. Requires `"var": "key"`. | | `clear_vars` | — | — | Delete all runtime variables for this player. | +| `add_tag` | Tag name | — | Add a scoreboard tag to the player (no `run_with: console` needed). Supports placeholders. | +| `remove_tag` | Tag name | — | Remove a scoreboard tag from the player. | +| `broadcast` | Text string | — | Send a chat message to every online player. Supports placeholders. | | `next_page` | — | — | Go to the next page. | | `prev_page` | — | — | Go to the previous page. | | `goto_page` | Page index (string) | — | Jump to a specific page. | @@ -165,6 +184,41 @@ Conditions control button **visibility**. Hidden buttons cannot be clicked. | `health_lt` | `value` | Player's current health < value | | `food_gt` | `value` | Player's hunger level > value | | `food_lt` | `value` | Player's hunger level < value | +| `all` | `"conditions": [ … ]` | **Every** listed condition is true (empty list = true) | +| `any` | `"conditions": [ … ]` | **At least one** listed condition is true (empty list = false) | +| `not` | `"condition": { … }` | The nested condition is **not** true (no nested condition = false) | + +Composite conditions can be nested (up to 8 levels) and work everywhere a condition does — buttons, displays, actions and `open_condition`: + +```json +"condition": { + "type": "all", + "conditions": [ + { "type": "has_tag", "value": "vip" }, + { "type": "not", "condition": { "type": "has_tag", "value": "banned" } }, + { "type": "any", "conditions": [ + { "type": "level_gt", "value": "10" }, + { "type": "score_gt", "value": "coins:100" } + ] } + ] +} +``` + +### Open gate + +`open_condition` restricts who can open a GUI — through `/guiapi open`, an `open_gui` action, page navigation or an item with the `guiapi:open_gui` component. When it is false the GUI does not open and `on_deny` runs instead: + +```json +{ + "title": "VIP Lounge", + "open_condition": { "type": "has_tag", "value": "vip" }, + "on_deny": [ + { "type": "message", "value": "§cVIP only!" }, + { "type": "sound", "value": "minecraft:entity.villager.no" } + ], + "buttons": [ ... ] +} +``` --- diff --git a/example-datapack/data/example/gui/gates_and_logic_demo.json b/example-datapack/data/example/gui/gates_and_logic_demo.json new file mode 100644 index 0000000..043a473 --- /dev/null +++ b/example-datapack/data/example/gui/gates_and_logic_demo.json @@ -0,0 +1,131 @@ +{ + "title": "§6Gates & Logic Demo", + "rows": 3, + "tick_rate": 20, + + "open_condition": { + "type": "not", + "condition": { "type": "has_tag", "value": "guiapi_demo_locked" } + }, + "on_deny": [ + { "type": "message", "value": "§cThis demo is locked for you. Unlock with: /tag @s remove guiapi_demo_locked" }, + { "type": "sound", "value": "minecraft:entity.villager.no" } + ], + + "filler": { + "item": "minecraft:black_stained_glass_pane", + "name": " ", + "hide_tooltip": true + }, + + "buttons": [ + { + "slot": 10, + "item": "minecraft:emerald", + "name": "§a1. any / all conditions", + "lore": [ + "§7Unlocked for VIPs (tag §fvip§7) or anyone", + "§7with an XP level above 9 — and never for", + "§7players tagged §fbanned§7.", + "§8Try: /tag @s add vip" + ], + "condition": { + "type": "all", + "conditions": [ + { "type": "not", "condition": { "type": "has_tag", "value": "banned" } }, + { "type": "any", "conditions": [ + { "type": "has_tag", "value": "vip" }, + { "type": "level_gt", "value": "9" } + ] } + ] + }, + "actions": [ + { "type": "sound", "value": "minecraft:entity.player.levelup" }, + { "type": "message", "value": "§aAccess granted, {player}!" } + ], + "else_item": { + "item": "minecraft:barrier", + "name": "§c1. any / all conditions §7(Locked)", + "lore": [ "§7Needs the §fvip §7tag or XP level 10+." ] + } + }, + + { + "slot": 12, + "item": "minecraft:name_tag", + "name": "§e2. Conditional actions + tags", + "lore": [ + "§7One click runs a chain where each step", + "§7can have its own condition:", + "§7 • §fadd_tag §7/ §fremove_tag §7need no console", + "§7 • §fbroadcast §7talks to the whole server", + "§7 • the VIP line only shows for §fvip §7players" + ], + "actions": [ + { "type": "add_tag", "value": "guiapi_demo_seen" }, + { "type": "message", "value": "§7You clicked the demo button." }, + { + "type": "message", + "value": "§6VIP bonus: thanks for supporting the server!", + "condition": { "type": "has_tag", "value": "vip" } + }, + { + "type": "broadcast", + "value": "§e{player} §7({health}/{max_health} HP) is playing with GUI API — {online} online.", + "condition": { "type": "has_tag", "value": "vip" } + }, + { "type": "refresh" } + ] + }, + + { + "slot": 14, + "toggle": { + "tag": "guiapi_demo_seen", + "item_on": "minecraft:lime_dye", + "item_off": "minecraft:gray_dye", + "name_on": "§a3. Tag guiapi_demo_seen: ON", + "name_off": "§73. Tag guiapi_demo_seen: OFF", + "lore_on": [ "§7Click to switch it off." ], + "lore_off": [ "§7Click to switch it on." ] + } + }, + + { + "slot": 16, + "item": "minecraft:iron_door", + "name": "§c4. Lock me out (open gate)", + "lore": [ + "§7Tags you with §fguiapi_demo_locked§7.", + "§7Reopening this GUI is then denied by", + "§7its §fopen_condition§7 and §fon_deny §7runs.", + "§8Undo: /tag @s remove guiapi_demo_locked" + ], + "actions": [ + { "type": "add_tag", "value": "guiapi_demo_locked" }, + { "type": "close" } + ] + }, + + { + "slot": 22, + "item": "minecraft:barrier", + "name": "§cClose", + "actions": [ { "type": "close" } ] + } + ], + + "displays": [ + { + "slot": 4, + "item": "minecraft:player_head", + "name": "§b5. New placeholders", + "lore": [ + "§7Health: §c{health}§7/§c{max_health}", + "§7Food: §6{food}", + "§7Block: §f{pos_x} {pos_y} {pos_z}", + "§7Players online: §a{online}" + ] + } + ] +} diff --git a/src/main/java/dev/toolkitmc/guiapi/command/GuiCommand.java b/src/main/java/dev/toolkitmc/guiapi/command/GuiCommand.java index ef4459c..91366be 100644 --- a/src/main/java/dev/toolkitmc/guiapi/command/GuiCommand.java +++ b/src/main/java/dev/toolkitmc/guiapi/command/GuiCommand.java @@ -193,6 +193,8 @@ private static int showHelp(CommandContext ctx) { "Variable placeholder: {var:key}\n" + "Input placeholder: {input} (last anvil input)\n" + "XP placeholder: {xp} (player experience level)\n" + + "State placeholders: {health} {max_health} {food} {online}\n" + + " {pos_x} {pos_y} {pos_z} (block position)\n" + "\n" + "Macro functions: define reusable action blocks in JSON with \"macros\": {}\n" + " actions: run_function:\n" + @@ -207,6 +209,8 @@ private static int showHelp(CommandContext ctx) { " take_item:: - remove item(s) from inventory\n" + " add_xp: - add n XP points\n" + " add_xp:L - add n XP levels (prefix with L)\n" + + " add_tag: | remove_tag: - change a scoreboard tag (no console needed)\n" + + " broadcast: - chat message to every online player\n" + "\n" + "Button JSON fields:\n" + " slot, page, item, name, lore, glint\n" + @@ -216,6 +220,7 @@ private static int showHelp(CommandContext ctx) { " has_item | not_item | level_gt | level_lt\n" + " health_gt | health_lt | food_gt | food_lt\n" + " permission:<0-4> (checks player's command permission level)\n" + + " all | any | not (combine conditions, see below)\n" + " actions: run_command | close | open_gui | message | sound | action_bar\n" + " next_page | prev_page | goto_page | run_function\n" + " run_random_function | give_item | take_item | add_xp\n" + @@ -224,6 +229,16 @@ private static int showHelp(CommandContext ctx) { " add_effect | remove_effect | clear_effects\n" + " anvil_input\n" + "\n" + + "Combining conditions:\n" + + " {\"type\":\"all\", \"conditions\":[ ... ]} every condition must be true\n" + + " {\"type\":\"any\", \"conditions\":[ ... ]} at least one must be true\n" + + " {\"type\":\"not\", \"condition\":{ ... }} negates one condition\n" + + " Actions accept an optional \"condition\" too; a false one skips just that action.\n" + + "\n" + + "Open gate (top-level GUI JSON fields):\n" + + " open_condition: { ... } player must meet it to open the GUI\n" + + " on_deny: [ actions ] run instead of opening (default: action bar notice)\n" + + "\n" + "Conditional item display: add \"else_item\" (same fields as a button)\n" + " alongside \"condition\" to show an alternate item instead of hiding\n" + " the button when the condition is false. Button stays inert while\n" + diff --git a/src/main/java/dev/toolkitmc/guiapi/gui/BarrelGuiHandler.java b/src/main/java/dev/toolkitmc/guiapi/gui/BarrelGuiHandler.java index bbd6884..a1749be 100644 --- a/src/main/java/dev/toolkitmc/guiapi/gui/BarrelGuiHandler.java +++ b/src/main/java/dev/toolkitmc/guiapi/gui/BarrelGuiHandler.java @@ -103,7 +103,33 @@ private static SimpleContainer populateChestMinecart(ServerPlayer player, GuiDef return inv; } + /** + * Depth of on_deny chains currently executing. An on_deny that (directly or via + * another GUI) re-opens a GUI whose gate is still closed would recurse forever. + */ + private static int denyDepth = 0; + public static void open(ServerPlayer player, GuiDefinition def, int page) { + // open_condition gate — checked before any state is registered or any inventory is built. + if (def.getOpenCondition().isPresent() && !evaluateCondition(player, def.getOpenCondition().get())) { + debug("open denied: player={} gui={}", player.getScoreboardName(), def.getId()); + if (denyDepth >= 3) { + GuiApiMod.LOGGER.warn("[GuiAPI] on_deny of {} keeps re-opening a denied GUI — stopping.", def.getId()); + return; + } + if (def.getOnDeny().isEmpty()) { + player.sendSystemMessage(Component.literal("§cYou cannot open this menu."), true); + return; + } + denyDepth++; + try { + executeDelayedActionChain(player, def, page, def.getOnDeny(), 0, false); + } finally { + denyDepth--; + } + return; + } + page = Math.clamp(page, 0, def.getPageCount() - 1); int rows = Math.clamp(def.getRows(), 1, 6); int finalPage = page; @@ -430,13 +456,7 @@ private static void populateWidgets(ServerPlayer player, GuiDefinition def, int } private static boolean evaluateStaticCondition(ServerPlayer player, GuiDefinition.ButtonCondition cond) { - // Reuses the same condition logic as buttons by wrapping into a throwaway - // Button-shaped evaluation — avoids duplicating the switch in evaluateCondition. - GuiDefinition.Button fake = new GuiDefinition.Button( - 0, 0, "", "", List.of(), false, GuiDefinition.ClickType.ANY, - Optional.of(cond), List.of(), Optional.empty(), Optional.empty(), Optional.empty(), - "1", false, false, Optional.empty(), 0); - return evaluateCondition(player, fake); + return evaluateCondition(player, cond); } private static int readWidgetValue(ServerPlayer player, String valueSource) { @@ -700,26 +720,33 @@ static String resolve(String text, ServerPlayer player, text = text.replace("{page1}", String.valueOf(page + 1)); text = text.replace("{pages}", String.valueOf(def.getPageCount())); text = text.replace("{xp}", String.valueOf(player.experienceLevel)); - text = text.replace("{input}", GuiInputStore.INSTANCE.get(player.getUUID())); - - // {score:objective} - int idx; - while ((idx = text.indexOf("{score:")) >= 0) { - int end = text.indexOf('}', idx); - if (end < 0) break; - String obj = text.substring(idx + 7, end); - int score = getScore(player, obj); - text = text.substring(0, idx) + score + text.substring(end + 1); - } - // {var:key} - while ((idx = text.indexOf("{var:")) >= 0) { - int end = text.indexOf('}', idx); - if (end < 0) break; - String key = text.substring(idx + 5, end); - String val = GuiVarStore.INSTANCE.getOrDefault(player.getUUID(), key, ""); - text = text.substring(0, idx) + val + text.substring(end + 1); - } + // Player state placeholders + if (text.contains("{health}")) + text = text.replace("{health}", String.valueOf((int) Math.ceil(player.getHealth()))); + if (text.contains("{max_health}")) + text = text.replace("{max_health}", String.valueOf((int) Math.ceil(player.getMaxHealth()))); + if (text.contains("{food}")) + text = text.replace("{food}", String.valueOf(player.getFoodData().getFoodLevel())); + if (text.contains("{pos_x}")) + text = text.replace("{pos_x}", String.valueOf(player.blockPosition().getX())); + if (text.contains("{pos_y}")) + text = text.replace("{pos_y}", String.valueOf(player.blockPosition().getY())); + if (text.contains("{pos_z}")) + text = text.replace("{pos_z}", String.valueOf(player.blockPosition().getZ())); + if (text.contains("{online}")) + text = text.replace("{online}", String.valueOf(player.level().getServer().getPlayerList().getPlayers().size())); + + // Anvil input is player-typed, so its braces are masked: it must never be + // re-read as {var:..}/{score:..} below (and is restored at the end). + text = text.replace("{input}", PlaceholderUtil.escapeBraces(GuiInputStore.INSTANCE.get(player.getUUID()))); + + // {score:objective} and {var:key} — inserted values are never re-scanned + text = PlaceholderUtil.replaceTokens(text, "{score:", obj -> String.valueOf(getScore(player, obj))); + text = PlaceholderUtil.replaceTokens(text, "{var:", + key -> GuiVarStore.INSTANCE.getOrDefault(player.getUUID(), key, "")); + + text = PlaceholderUtil.unescapeBraces(text); debug("resolve: \"{}\" → \"{}\"", text.length() > 60 ? text.substring(0, 60) + "..." : text, text); return text; @@ -743,9 +770,18 @@ static boolean shouldRenderButton(ServerPlayer player, GuiDefinition.Button btn) static boolean evaluateCondition(ServerPlayer player, GuiDefinition.Button btn) { if (btn.condition().isEmpty()) return true; + return evaluateCondition(player, btn.condition().get()); + } - GuiDefinition.ButtonCondition cond = btn.condition().get(); + /** Evaluates a condition tree — all / any / not are composed by {@link ConditionLogic}. */ + static boolean evaluateCondition(ServerPlayer player, GuiDefinition.ButtonCondition cond) { + return ConditionLogic.evaluate(cond, leaf -> evaluateLeafCondition(player, leaf)); + } + + private static boolean evaluateLeafCondition(ServerPlayer player, GuiDefinition.ButtonCondition cond) { return switch (cond.type()) { + // Composite types are resolved by ConditionLogic before a leaf is ever evaluated. + case ALL, ANY, NOT -> false; case HAS_TAG -> player.entityTags().contains(cond.value()); case NOT_TAG -> !player.entityTags().contains(cond.value()); case SCORE_GT -> getScore(player, cond.value().split(":", 2), 0) > @@ -909,6 +945,11 @@ static boolean executeAction(ServerPlayer player, GuiDefinition def, MinecraftServer server = player.level().getServer(); debug("action: player={} type={} value=\"{}\"", player.getScoreboardName(), action.type(), action.value()); + // Optional per-action condition: a false condition skips only this action. + if (action.condition().isPresent() && !evaluateCondition(player, action.condition().get())) { + debug("action skipped (condition false): type={}", action.type()); + return false; + } switch (action.type()) { case RUN_COMMAND -> { String cmd = action.value().startsWith("/") @@ -1130,18 +1171,30 @@ static boolean executeAction(ServerPlayer player, GuiDefinition def, case CLEAR_VARS -> GuiVarStore.INSTANCE.clear(player.getUUID()); case REFRESH -> refreshCurrentGui(player); case TAKE_ITEM -> { - String resolved = resolve(action.value(), player, def, currentPage); - String[] parts = resolved.split(":", 2); - String itemId = parts[0]; - int amount = parts.length > 1 ? parseIntSafe(parts[1]) : 1; - takeItemCount(player, itemId, amount); + // "minecraft:gold_nugget:5" — the amount is the part after the LAST colon. + ItemSpec spec = ItemSpec.parse(resolve(action.value(), player, def, currentPage), 1); + takeItemCount(player, spec.itemId(), Math.max(0, spec.amount())); } case GIVE_ITEM -> { - String resolved = resolve(action.value(), player, def, currentPage); - String[] parts = resolved.split(":", 2); - String itemId = parts[0]; - int amount = parts.length > 1 ? Math.max(1, parseIntSafe(parts[1])) : 1; - giveItemCount(player, itemId, amount); + ItemSpec spec = ItemSpec.parse(resolve(action.value(), player, def, currentPage), 1); + giveItemCount(player, spec.itemId(), Math.max(1, spec.amount())); + } + case ADD_TAG -> { + String tag = resolve(action.value(), player, def, currentPage).trim(); + if (!tag.isEmpty()) player.addTag(tag); + } + case REMOVE_TAG -> { + String tag = resolve(action.value(), player, def, currentPage).trim(); + if (!tag.isEmpty()) player.removeTag(tag); + } + case BROADCAST -> { + String text = resolve(action.value(), player, def, currentPage); + if (!text.isEmpty()) { + Component broadcast = Component.literal(text); + for (ServerPlayer recipient : server.getPlayerList().getPlayers()) { + recipient.sendSystemMessage(broadcast, false); + } + } } case ADD_XP -> { String resolved = resolve(action.value(), player, def, currentPage); diff --git a/src/main/java/dev/toolkitmc/guiapi/gui/ConditionLogic.java b/src/main/java/dev/toolkitmc/guiapi/gui/ConditionLogic.java new file mode 100644 index 0000000..c4df70d --- /dev/null +++ b/src/main/java/dev/toolkitmc/guiapi/gui/ConditionLogic.java @@ -0,0 +1,40 @@ +package dev.toolkitmc.guiapi.gui; + +import java.util.function.Predicate; + +/** + * Boolean composition of conditions ({@code all}, {@code any}, {@code not}). + * Kept free of Minecraft types so the logic is independent of how a single + * (leaf) condition is evaluated. + */ +final class ConditionLogic { + + private ConditionLogic() {} + + /** + * @param cond condition tree + * @param leaf evaluates every non-composite condition + * + * Empty {@code all} is true, empty {@code any} is false, and a {@code not} + * without a child is false (fails closed, so a broken gate never opens). + */ + static boolean evaluate(GuiDefinition.ButtonCondition cond, + Predicate leaf) { + return switch (cond.type()) { + case ALL -> { + for (GuiDefinition.ButtonCondition child : cond.children()) { + if (!evaluate(child, leaf)) yield false; + } + yield true; + } + case ANY -> { + for (GuiDefinition.ButtonCondition child : cond.children()) { + if (evaluate(child, leaf)) yield true; + } + yield false; + } + case NOT -> !cond.children().isEmpty() && !evaluate(cond.children().get(0), leaf); + default -> leaf.test(cond); + }; + } +} diff --git a/src/main/java/dev/toolkitmc/guiapi/gui/GuiDefinition.java b/src/main/java/dev/toolkitmc/guiapi/gui/GuiDefinition.java index e8c157c..29bdd20 100644 --- a/src/main/java/dev/toolkitmc/guiapi/gui/GuiDefinition.java +++ b/src/main/java/dev/toolkitmc/guiapi/gui/GuiDefinition.java @@ -52,7 +52,8 @@ public enum ActionType { RUN_COMMAND, CLOSE, OPEN_GUI, MESSAGE, NEXT_PAGE, PREV_PAGE, GOTO_PAGE, SOUND, SET_VAR, ADD_VAR, SUB_VAR, RESET_VAR, CLEAR_VARS, REFRESH, TAKE_ITEM, GIVE_ITEM, SET_SCORE, ADD_SCORE, SUB_SCORE, ACTION_BAR, ADD_XP, - ADD_EFFECT, REMOVE_EFFECT, CLEAR_EFFECTS, NONE, ANVIL_INPUT, RUN_FUNCTION, RUN_RANDOM_FUNCTION, SET_GAMEMODE; + ADD_EFFECT, REMOVE_EFFECT, CLEAR_EFFECTS, NONE, ANVIL_INPUT, RUN_FUNCTION, RUN_RANDOM_FUNCTION, SET_GAMEMODE, + ADD_TAG, REMOVE_TAG, BROADCAST; public static ActionType fromString(String s) { return switch (s.toLowerCase()) { @@ -85,6 +86,9 @@ public static ActionType fromString(String s) { case "run_function" -> RUN_FUNCTION; case "run_random_function" -> RUN_RANDOM_FUNCTION; case "set_gamemode" -> SET_GAMEMODE; + case "add_tag" -> ADD_TAG; + case "remove_tag" -> REMOVE_TAG; + case "broadcast" -> BROADCAST; default -> NONE; }; } @@ -101,7 +105,8 @@ public enum ConditionType { HAS_TAG, NOT_TAG, SCORE_GT, SCORE_LT, SCORE_EQ, VAR_EQ, VAR_GT, VAR_LT, VAR_SET, HAS_ITEM, NOT_ITEM, LEVEL_GT, LEVEL_LT, HEALTH_GT, HEALTH_LT, FOOD_GT, FOOD_LT, - PERMISSION, GAMEMODE, IN_DIMENSION; + PERMISSION, GAMEMODE, IN_DIMENSION, + ALL, ANY, NOT; public static ConditionType fromString(String s) { return switch (s.toLowerCase()) { @@ -125,6 +130,9 @@ public static ConditionType fromString(String s) { case "permission" -> PERMISSION; case "gamemode" -> GAMEMODE; case "in_dimension" -> IN_DIMENSION; + case "all" -> ALL; + case "any" -> ANY; + case "not" -> NOT; default -> HAS_TAG; }; } @@ -133,13 +141,20 @@ public static ConditionType fromString(String s) { // ── Records ────────────────────────────────────────────────────────────── /** - * @param type Action type - * @param value Primary value (command, message, sound id, var value, page index…) - * @param runWith Execution context for run_command - * @param var Variable key for set_var / add_var / sub_var / reset_var actions - * @param delay Action execution delay in ticks + * @param type Action type + * @param value Primary value (command, message, sound id, var value, page index…) + * @param runWith Execution context for run_command + * @param var Variable key for set_var / add_var / sub_var / reset_var actions + * @param delay Action execution delay in ticks + * @param condition Optional condition, checked when the action is about to run + * (after any delay). If false the action is skipped and the + * rest of the chain continues. */ - public record ButtonAction(ActionType type, String value, RunWith runWith, String var, int delay) { + public record ButtonAction(ActionType type, String value, RunWith runWith, String var, int delay, + Optional condition) { + public ButtonAction(ActionType type, String value, RunWith runWith, String var, int delay) { + this(type, value, runWith, var, delay, Optional.empty()); + } public ButtonAction(ActionType type, String value) { this(type, value, RunWith.PLAYER, "", 0); } @@ -148,7 +163,19 @@ public ButtonAction(ActionType type, String value, RunWith runWith) { } } - public record ButtonCondition(ConditionType type, String value) {} + /** + * A condition. Leaf types use {@code value}; the composite types + * {@code ALL} / {@code ANY} / {@code NOT} use {@code children} instead + * ({@code NOT} looks only at its first child). + */ + public record ButtonCondition(ConditionType type, String value, List children) { + public ButtonCondition { + children = children == null ? List.of() : List.copyOf(children); + } + public ButtonCondition(ConditionType type, String value) { + this(type, value, List.of()); + } + } /** * PROGRESS_BAR widget — a horizontal run of slots that visually fills based on @@ -294,6 +321,8 @@ public record Button( // so none of the existing GuiDefinition(...) / create(...) overloads break. private List progressBars = List.of(); private List displays = List.of(); + private Optional openCondition = Optional.empty(); + private List onDeny = List.of(); // ── Constructor ────────────────────────────────────────────────────────── @@ -439,6 +468,9 @@ public static GuiDefinition parse(Identifier id, JsonObject obj) { } def.displays = displays; + def.openCondition = parseConditionField(obj, "open_condition"); + def.onDeny = parseActionList(obj, "on_deny"); + return def; } @@ -464,18 +496,46 @@ private static StaticDisplayWidget parseStaticDisplay(JsonObject d) { boolean glint = d.has("glint") && d.get("glint").getAsBoolean(); String amount = d.has("amount") ? d.get("amount").getAsString() : "1"; - Optional condition = Optional.empty(); - if (d.has("condition") && d.get("condition").isJsonObject()) { - JsonObject c = d.getAsJsonObject("condition"); - ConditionType ct = ConditionType.fromString( - c.has("type") ? c.get("type").getAsString() : "has_tag"); - String cv = c.has("value") ? c.get("value").getAsString() : ""; - condition = Optional.of(new ButtonCondition(ct, cv)); - } + Optional condition = parseConditionField(d, "condition"); return new StaticDisplayWidget(slot, page, item, name, lore, glint, condition, amount); } + /** Deepest allowed nesting of all/any/not — guards against runaway recursion. */ + private static final int MAX_CONDITION_DEPTH = 8; + + private static Optional parseConditionField(JsonObject parent, String key) { + if (parent.has(key) && parent.get(key).isJsonObject()) { + return Optional.of(parseCondition(parent.getAsJsonObject(key), 0)); + } + return Optional.empty(); + } + + /** + * Leaf: {@code {"type": "has_tag", "value": "vip"}}. + * Composite: {@code {"type": "all"|"any", "conditions": [ ... ]}} and + * {@code {"type": "not", "condition": { ... }}}. + */ + private static ButtonCondition parseCondition(JsonObject c, int depth) { + if (depth > MAX_CONDITION_DEPTH) { + throw new IllegalArgumentException("Condition nesting is deeper than " + MAX_CONDITION_DEPTH); + } + ConditionType ct = ConditionType.fromString( + c.has("type") ? c.get("type").getAsString() : "has_tag"); + String cv = c.has("value") ? c.get("value").getAsString() : ""; + + List children = new ArrayList<>(); + if (ct == ConditionType.NOT && c.has("condition") && c.get("condition").isJsonObject()) { + children.add(parseCondition(c.getAsJsonObject("condition"), depth + 1)); + } else if ((ct == ConditionType.ALL || ct == ConditionType.ANY || ct == ConditionType.NOT) + && c.has("conditions") && c.get("conditions").isJsonArray()) { + for (JsonElement el : c.getAsJsonArray("conditions")) { + if (el.isJsonObject()) children.add(parseCondition(el.getAsJsonObject(), depth + 1)); + } + } + return new ButtonCondition(ct, cv, children); + } + private static List parseActionList(JsonObject obj, String key) { List list = new ArrayList<>(); if (obj.has(key) && obj.get(key).isJsonArray()) { @@ -493,14 +553,7 @@ private static Button parseButton(JsonObject b) { ? ClickType.fromString(b.get("click_type").getAsString()) : ClickType.ANY; - Optional condition = Optional.empty(); - if (b.has("condition") && b.get("condition").isJsonObject()) { - JsonObject c = b.getAsJsonObject("condition"); - ConditionType ct = ConditionType.fromString( - c.has("type") ? c.get("type").getAsString() : "has_tag"); - String cv = c.has("value") ? c.get("value").getAsString() : ""; - condition = Optional.of(new ButtonCondition(ct, cv)); - } + Optional condition = parseConditionField(b, "condition"); int cooldown = b.has("cooldown") ? Math.max(0, b.get("cooldown").getAsInt()) : 0; @@ -684,7 +737,8 @@ private static ButtonAction parseAction(JsonObject a) { ? RunWith.fromString(a.get("run_with").getAsString()) : RunWith.PLAYER; int delay = a.has("delay") ? Math.max(0, a.get("delay").getAsInt()) : 0; - return new ButtonAction(type, value, runWith, var, delay); + Optional condition = parseConditionField(a, "condition"); + return new ButtonAction(type, value, runWith, var, delay, condition); } // ── Getters ────────────────────────────────────────────────────────────── @@ -704,6 +758,10 @@ private static ButtonAction parseAction(JsonObject a) { public java.util.Map> getMacros() { return macros; } public List getProgressBars() { return progressBars; } public List getDisplays() { return displays; } + /** Condition the player must meet to open this GUI, if any ({@code open_condition}). */ + public Optional getOpenCondition() { return openCondition; } + /** Actions run instead of opening when {@link #getOpenCondition()} is false ({@code on_deny}). */ + public List getOnDeny() { return onDeny; } public List getProgressBarsForPage(int page) { return progressBars.stream().filter(p -> p.page() == page).toList(); diff --git a/src/main/java/dev/toolkitmc/guiapi/gui/ItemSpec.java b/src/main/java/dev/toolkitmc/guiapi/gui/ItemSpec.java new file mode 100644 index 0000000..c9e41a2 --- /dev/null +++ b/src/main/java/dev/toolkitmc/guiapi/gui/ItemSpec.java @@ -0,0 +1,31 @@ +package dev.toolkitmc.guiapi.gui; + +/** + * Parsed form of the {@code "itemId[:amount]"} strings used by {@code give_item} + * and {@code take_item}. + * + * Item ids normally contain a colon themselves ({@code minecraft:gold_nugget}), + * so the amount can only be the segment after the last colon, and only + * when that segment is an integer. Splitting on the first colon would turn + * {@code minecraft:gold_nugget:5} into item {@code minecraft} / amount + * {@code gold_nugget:5}, which silently matches nothing. + * + * @param itemId item id, e.g. {@code minecraft:gold_nugget} or {@code gold_nugget} + * @param amount parsed amount, or the caller's default when none was given + */ +public record ItemSpec(String itemId, int amount) { + + public static ItemSpec parse(String raw, int defaultAmount) { + String value = raw == null ? "" : raw.trim(); + int last = value.lastIndexOf(':'); + if (last > 0 && last < value.length() - 1) { + try { + int amount = Integer.parseInt(value.substring(last + 1)); + return new ItemSpec(value.substring(0, last), amount); + } catch (NumberFormatException ignored) { + // Last segment is part of the id (e.g. "minecraft:diamond" with no amount). + } + } + return new ItemSpec(value, defaultAmount); + } +} diff --git a/src/main/java/dev/toolkitmc/guiapi/gui/PlaceholderUtil.java b/src/main/java/dev/toolkitmc/guiapi/gui/PlaceholderUtil.java new file mode 100644 index 0000000..61b23a8 --- /dev/null +++ b/src/main/java/dev/toolkitmc/guiapi/gui/PlaceholderUtil.java @@ -0,0 +1,49 @@ +package dev.toolkitmc.guiapi.gui; + +import java.util.function.Function; + +/** + * Pure-Java helpers for placeholder substitution (no Minecraft dependencies). + */ +final class PlaceholderUtil { + + // Private-use code points used to hide braces of untrusted text (see escapeBraces). + private static final char ESC_OPEN = '\uE000'; + private static final char ESC_CLOSE = '\uE001'; + + private PlaceholderUtil() {} + + /** + * Replaces every token that starts with {@code prefix} and ends at the next + * closing brace (e.g. {@code {var:coins}}) with the result of {@code lookup}. + * + * Scanning resumes after each inserted value. Re-scanning inserted text + * would loop forever whenever a value contains its own token — for example a + * variable set from anvil input to the literal text {@code {var:input}}. + */ + static String replaceTokens(String text, String prefix, Function lookup) { + int from = 0; + int idx; + while ((idx = text.indexOf(prefix, from)) >= 0) { + int end = text.indexOf('}', idx); + if (end < 0) break; + String key = text.substring(idx + prefix.length(), end); + String value = lookup.apply(key); + if (value == null) value = ""; + text = text.substring(0, idx) + value + text.substring(end + 1); + from = idx + value.length(); + } + return text; + } + + /** Masks braces so untrusted text (anvil input) is never re-read as a placeholder. */ + static String escapeBraces(String s) { + if (s == null || s.isEmpty()) return ""; + return s.replace('{', ESC_OPEN).replace('}', ESC_CLOSE); + } + + /** Restores braces masked by {@link #escapeBraces(String)}. */ + static String unescapeBraces(String s) { + return s.replace(ESC_OPEN, '{').replace(ESC_CLOSE, '}'); + } +} From e50781eb05d0619e58c0335bae2c9b46abe847f7 Mon Sep 17 00:00:00 2001 From: Legends11 <235496468+tickwarden@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:55:06 +0300 Subject: [PATCH 2/4] Delete NOTES.md --- NOTES.md | 40 ---------------------------------------- 1 file changed, 40 deletions(-) delete mode 100644 NOTES.md diff --git a/NOTES.md b/NOTES.md deleted file mode 100644 index db541ca..0000000 --- a/NOTES.md +++ /dev/null @@ -1,40 +0,0 @@ -# guiAPI — Notlar - -## Bu mod nadir güncelleme alıyor -Değişiklik yapmadan önce mevcut davranışı bozmamaya özellikle dikkat et — güncelleme -sıklığı düşük olduğu için hatalar uzun süre fark edilmeden kalabilir. - -## Yakında: Gate sistemi -Gate desteği planlanıyor ancak henüz eklenmedi. Bu bölüm, gate'ler eklendiğinde -güncellenecek. - -## tickwarden-patch-1 dalında eklenenler - -### 1. Buton başına cooldown / rate-limit -- `GuiDefinition.Button` record'una `int cooldown` alanı eklendi (tick cinsinden). -- JSON'da `"cooldown": 100` şeklinde tanımlanır (0 = cooldown yok, varsayılan). -- `BarrelGuiHandler` içinde `BUTTON_COOLDOWNS: Map>` ile - oyuncu başına, `guiId:slot` anahtarıyla son tıklama tick'i tutulur. -- Kontrol, aksiyon zinciri tetiklenmeden önce yapılır; kayıt, tetiklendikten - hemen sonra yapılır (delayed action chain'lerle çakışmaz). -- Cooldown, GUI kapatılıp tekrar açılarak bypass edilemez — bilinçli tasarım. -- Oyuncu disconnect olduğunda `ServerPlayConnectionEvents.DISCONNECT` ile - cooldown state'i temizlenir (memory leak önlemi). -- Toggle butonları da `Button.cooldown()`'ı miras aldığı için `"cooldown"` alanı - toggle tanımlı butonlarda da ek kod gerekmeden çalışır. - -### 2. Dinamik slot item (placeholder çözümü) -- `buildStack()` içinde `itemId` artık `resolve()`'dan geçiyor — `{score:...}` - ve `{var:...}` placeholder'ları item ID'sinde de çözülüyor. -- **Önemli kısıtlama:** placeholder'ın çözüm sonucu geçerli bir Minecraft item - ID'si olmalı (örn. `minecraft:diamond`). Bir skor/değer sayısı item ID'si - olarak kullanılamaz — bu tarz "tier'e göre farklı item" ihtiyacı için doğru - yöntem, aynı slotta birden fazla `condition`'lı buton tanımı kullanmaktır - (bkz. örnek datapack'teki slot 10 kalıbı: score_lt / score_gt ile ayrılmış - iki ayrı buton tanımı). -- Toggle butonlarında da otomatik çalışır (ortak `itemId` değişkeni üzerinden). - -## Bilinmeyen / doğrulanmamış -- Gerçek `./gradlew build` CI'da başarılı geçti (bkz. build log — BUILD SUCCESSFUL). -- Yerel ortamda Fabric/Minecraft bağımlılıkları indirilemediği için değişiklikler - sadece elle/statik olarak doğrulanabildi, CI onayı asıl doğrulama oldu. From b386eb19f8b2e51134f7833027e34033ee05efa4 Mon Sep 17 00:00:00 2001 From: Legends11 <235496468+tickwarden@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:04:08 +0300 Subject: [PATCH 3/4] Add files via upload --- README.md | 62 ++++- .../data/example/gui/paid_room.json | 22 ++ .../guiapi/modmenu/GuiApiModMenuEntry.java | 8 +- .../toolkitmc/guiapi/command/GuiCommand.java | 7 +- .../toolkitmc/guiapi/config/GuiApiConfig.java | 8 + .../guiapi/gui/BarrelGuiHandler.java | 76 +++++- .../toolkitmc/guiapi/gui/DimensionUtil.java | 34 +++ .../toolkitmc/guiapi/gui/GuiDefinition.java | 5 + .../toolkitmc/guiapi/gui/GuiSerializer.java | 235 ++++++++++++++++++ .../toolkitmc/guiapi/loader/GuiRegistry.java | 100 +------- .../resources/assets/guiapi/lang/tr_tr.json | 4 + .../guiapi/gui/DimensionUtilTest.java | 15 ++ .../gui/GuiSerializerRoundTripTest.java | 81 ++++++ 13 files changed, 535 insertions(+), 122 deletions(-) create mode 100644 example-datapack/data/example/gui/paid_room.json create mode 100644 src/main/java/dev/toolkitmc/guiapi/gui/DimensionUtil.java create mode 100644 src/main/java/dev/toolkitmc/guiapi/gui/GuiSerializer.java create mode 100644 src/main/resources/assets/guiapi/lang/tr_tr.json create mode 100644 src/test/java/dev/toolkitmc/guiapi/gui/DimensionUtilTest.java create mode 100644 src/test/java/dev/toolkitmc/guiapi/gui/GuiSerializerRoundTripTest.java diff --git a/README.md b/README.md index 12e658b..5b45d01 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # GUI API — Fabric 26.2 A Fabric mod that lets datapacks define and open chest GUIs via JSON files. -No client mod required. No macros. No external dependencies beyond Fabric API. +No client mod required. No external dependencies beyond Fabric API. --- @@ -53,6 +53,7 @@ The GUI ID used in commands is `:` — matching the file path u |-------|------|---------|-------------| | `title` | string | `"GUI"` | Inventory title. Supports `§` color codes and placeholders. | | `rows` | int 1–6 | `3` | Number of rows (9 slots each). | +| `container_type` | string | `"barrel"` | `barrel` · `chest` · `player` · `ender_chest` · `chest_minecart`. `ender_chest`/`chest_minecart` force 3 rows, `player` forces 4. | | `tick_rate` | int | `0` | Auto-refresh interval in ticks (e.g., `20` = 1s). Set `0` to disable. | | `close_on_move` | boolean | `false` | If true, closes screen if player walks away (> 1.5 blocks). | | `filler` | object | — | Background filler configuration (see below). | @@ -60,6 +61,10 @@ The GUI ID used in commands is `:` — matching the file path u | `on_close` | action[] | `[]` | Actions executed when the GUI is closed (any reason). | | `open_condition` | condition | — | Player must meet this condition to open the GUI (see [Open gate](#open-gate)). | | `on_deny` | action[] | `[]` | Actions executed instead of opening when `open_condition` is false. Empty = short action-bar notice. | +| `open_cost` | string | — | Entrance fee as `"itemId:amount"` (e.g. `"minecraft:gold_ingot:5"`). Charged once per open from outside the GUI; page navigation is free. If unaffordable, `on_deny` runs. | +| `macros` | object | `{}` | Named, reusable action lists — run them with `run_function` / `run_random_function`. | +| `progress_bars` | object[] | `[]` | Progress-bar widgets (see [Widgets](#widgets)). | +| `displays` | object[] | `[]` | Read-only info items (see [Widgets](#widgets)). | | `buttons` | button[] | `[]` | List of button definitions. | #### Filler fields @@ -92,7 +97,10 @@ Any empty slot in the inventory is automatically populated with this background | `item_model` | string | — | Custom item model component ID (1.21.2+). | | `click_type` | string | `"any"` | Which click triggers actions: `any` · `left` · `right` · `shift` | | `condition` | object | — | Visibility condition (see below). | +| `else_item` | object | — | Alternate appearance shown when `condition` is false (button stays visible but inert). Takes the same visual fields as a button. | +| `cooldown` | int | `0` | Per-player click cooldown in ticks. Survives closing/reopening the GUI. Also applies to toggle buttons. | | `actions` | action[] | `[close]` | Actions executed in order on click. Supports `"delay": int` (ticks). | +| `action` | action | — | Shorthand for a single action; used only when `actions` is absent. | | `toggle` | object | — | Toggle definition — replaces `item`/`actions` (see below). | --- @@ -144,6 +152,13 @@ Any action can also carry a `"condition"` (same format as button conditions, inc | `add_score` | `objective:value` | — | Add score to player's scoreboard objective directly. | | `sub_score` | `objective:value` | — | Subtract score from player's scoreboard objective directly. | | `take_item` | `itemId:amount` | — | Deduct a specified amount of an item from the player's inventory. | +| `give_item` | `itemId:amount` | — | Give item(s); overflow that doesn't fit is dropped at the player's feet. | +| `add_xp` | `n` or `Ln` | — | Add `n` XP points, or `n` levels with the `L` prefix (e.g. `L2`). | +| `run_function` | macro name | — | Run a named action list from `macros`. | +| `run_random_function` | `name[*weight],…` | — | Run one macro chosen at random, e.g. `common*70,rare*25,legendary*5`. Weight defaults to 1. | +| `set_gamemode` | `survival` · `creative` · `adventure` · `spectator` | — | Change the player's game mode. Can be disabled in config (`allow_gamemode_change`). | +| `anvil_input` | `Title\|Default` | — | Open an anvil text prompt; the result is stored in `"var"` (default `input`) and `{input}`. | +| `none` | — | — | Stop the action chain here without doing anything. | | `add_effect` | `effect_id:duration:amplifier:particles` | — | Give player status effect (duration in seconds, particles true/false). | | `remove_effect` | `effect_id` | — | Remove a specific status effect from the player. | | `clear_effects` | — | — | Clear all status effects from the player. | @@ -184,6 +199,9 @@ Conditions control button **visibility**. Hidden buttons cannot be clicked. | `health_lt` | `value` | Player's current health < value | | `food_gt` | `value` | Player's hunger level > value | | `food_lt` | `value` | Player's hunger level < value | +| `permission` | `0`–`4` | Player's command permission level is at least that value | +| `gamemode` | `survival` · `creative` · `adventure` · `spectator` | Player is in that game mode | +| `in_dimension` | dimension id (`minecraft:the_nether`, or bare `the_nether`) | Player is in that dimension | | `all` | `"conditions": [ … ]` | **Every** listed condition is true (empty list = true) | | `any` | `"conditions": [ … ]` | **At least one** listed condition is true (empty list = false) | | `not` | `"condition": { … }` | The nested condition is **not** true (no nested condition = false) | @@ -222,6 +240,26 @@ Composite conditions can be nested (up to 8 levels) and work everywhere a condit --- +## Widgets + +Non-button elements, defined as top-level arrays. They are read-only: clicks on their slots are ignored. + +**`progress_bars`** — a horizontal run of slots that fills according to a runtime value, recalculated on every open/refresh (use `tick_rate` for live updates). + +| Field | Default | Description | +|-------|---------|-------------| +| `start_slot` | `0` | First slot of the bar. | +| `length` | `9` | Number of slots. | +| `page` | `0` | Page the bar appears on. | +| `value_source` | `"var:progress"` | `"score:"` or `"var:"`. | +| `max_value` | `100` | Value at which the bar is full. | +| `filled_item` / `empty_item` | lime / gray glass pane | Items for filled and empty slots. | +| `name` / `lore` | — | Optional text; supports placeholders. | + +**`displays`** — one read-only info item. Fields: `slot`, `page`, `item`, `name`, `lore`, `glint`, `amount`, `condition`. + +--- + ## Toggle buttons A toggle button shows different item/name/lore/actions depending on a scoreboard tag on the player. Replace the `item` and `actions` fields with a `toggle` object. @@ -240,6 +278,21 @@ Toggle actions also fully support the multi-action engine (separated by `;` in t --- +## Configuration + +`config/guiapi.json` (editable through Mod Menu). Notable options: + +| Key | Default | Description | +|-----|---------|-------------| +| `chat_prefix_enabled` | `false` | Prepend `chat_prefix` to `message` (in `CHAT` mode), `broadcast` and error chat messages. Action-bar text is never prefixed. | +| `chat_prefix` | `§8[§6GuiAPI§8] §f` | The prefix text. | +| `allow_console_run_with` | `true` | Allow `run_with: console`. | +| `allow_gamemode_change` | `true` | Allow `set_gamemode`. | +| `allow_status_effects` | `true` | Allow effect actions. | +| `permission_level` | `2` | Permission level for `/guiapi`. | + +--- + ## Client-Side features (Optional) Installing this mod on the client-side unlocks powerful, highly-polished user experience features: @@ -252,14 +305,9 @@ Installing this mod on the client-side unlocks powerful, highly-polished user ex * Click **Apply & Back** to open the **Gui Save Loading Screen** which finds the target datapack folder on the server, safely writes the JSON to disk, and reloads the API definitions. No edits are ever lost on rejoin! ### 2. Native Keybindings -Integrates natively with Minecraft's official controls menu (**Options > Controls > Key Binds > GUI API**): +Integrates with Minecraft's official controls menu (**Options > Controls > Key Binds > GUI API**): * **Accept Rules (Open GUI):** Opens the default welcome GUI (Defaults to **`G`**). -* **Toggle Search in GUI:** Activates the interactive slot search (Defaults to **`L`**). -### 3. Interactive Slot Search (`L`) -* Press **`L`** inside any GUI (or any chest/barrel container enventories!) to toggle the Search bar. -* Type alphanumeric characters to search. Matching items are highlighted with a gorgeous HSB glowing neon color-cycling gradient, while non-matching slots are dimmed. -* Minecraft closing/dropping hotkeys (like `E` and `Q`) are safely blocked while search is focused to ensure a pristine typing experience. Press `ESC` or `L` again to close. --- diff --git a/example-datapack/data/example/gui/paid_room.json b/example-datapack/data/example/gui/paid_room.json new file mode 100644 index 0000000..575dd89 --- /dev/null +++ b/example-datapack/data/example/gui/paid_room.json @@ -0,0 +1,22 @@ +{ + "title": "§6Paid Room §7(5 gold)", + "rows": 3, + + "open_cost": "minecraft:gold_ingot:5", + "on_deny": [ + { "type": "message", "value": "§cThe door costs 5 gold ingots." }, + { "type": "sound", "value": "minecraft:entity.villager.no" } + ], + + "filler": { "item": "minecraft:black_stained_glass_pane", "name": " ", "hide_tooltip": true }, + + "buttons": [ + { + "slot": 13, + "item": "minecraft:emerald", + "name": "§aYou paid, {player}!", + "lore": [ "§7Paging inside this GUI never charges again.", "§7Closing and re-opening it does." ], + "actions": [ { "type": "close" } ] + } + ] +} diff --git a/src/client/java/dev/toolkitmc/guiapi/modmenu/GuiApiModMenuEntry.java b/src/client/java/dev/toolkitmc/guiapi/modmenu/GuiApiModMenuEntry.java index 510bdba..0ed331a 100644 --- a/src/client/java/dev/toolkitmc/guiapi/modmenu/GuiApiModMenuEntry.java +++ b/src/client/java/dev/toolkitmc/guiapi/modmenu/GuiApiModMenuEntry.java @@ -115,10 +115,16 @@ public ConfigScreenFactory getModConfigScreenFactory() { .setTooltip(Component.literal("Play a clean chest close sound when closing virtual GUIs.")) .build()); + otherCategory.addEntry(entryBuilder.startBooleanToggle(Component.literal("Enable Chat Prefix"), cfg.isChatPrefixEnabled()) + .setDefaultValue(false) + .setSaveConsumer(cfg::setChatPrefixEnabled) + .setTooltip(Component.literal("Prepend the chat prefix to message/broadcast actions. Off by default so existing datapacks are unchanged.")) + .build()); + otherCategory.addEntry(entryBuilder.startTextField(Component.literal("Chat Prefix"), cfg.getChatPrefix()) .setDefaultValue("§8[§6GuiAPI§8] §f") .setSaveConsumer(cfg::setChatPrefix) - .setTooltip(Component.literal("Custom prefix for all chat messages sent by GuiAPI.")) + .setTooltip(Component.literal("Prefix text used when 'Enable Chat Prefix' is on. Applies to chat messages, not the action bar.")) .build()); otherCategory.addEntry(entryBuilder.startIntSlider(Component.literal("Sound Volume (%)"), cfg.getSoundVolume(), 0, 100) diff --git a/src/main/java/dev/toolkitmc/guiapi/command/GuiCommand.java b/src/main/java/dev/toolkitmc/guiapi/command/GuiCommand.java index 91366be..4a50de0 100644 --- a/src/main/java/dev/toolkitmc/guiapi/command/GuiCommand.java +++ b/src/main/java/dev/toolkitmc/guiapi/command/GuiCommand.java @@ -213,12 +213,13 @@ private static int showHelp(CommandContext ctx) { " broadcast: - chat message to every online player\n" + "\n" + "Button JSON fields:\n" + - " slot, page, item, name, lore, glint\n" + + " slot, page, item, name, lore, glint, cooldown (ticks)\n" + " click_type: any | left | right | shift\n" + " condition: has_tag | not_tag | score_gt | score_lt | score_eq\n" + " var_eq | var_gt | var_lt | var_set\n" + " has_item | not_item | level_gt | level_lt\n" + " health_gt | health_lt | food_gt | food_lt\n" + + " gamemode: | in_dimension:\n" + " permission:<0-4> (checks player's command permission level)\n" + " all | any | not (combine conditions, see below)\n" + " actions: run_command | close | open_gui | message | sound | action_bar\n" + @@ -227,7 +228,8 @@ private static int showHelp(CommandContext ctx) { " set_var | add_var | sub_var | reset_var | clear_vars\n" + " set_score | add_score | sub_score\n" + " add_effect | remove_effect | clear_effects\n" + - " anvil_input\n" + + " add_tag | remove_tag | broadcast | set_gamemode\n" + + " anvil_input | none\n" + "\n" + "Combining conditions:\n" + " {\"type\":\"all\", \"conditions\":[ ... ]} every condition must be true\n" + @@ -237,6 +239,7 @@ private static int showHelp(CommandContext ctx) { "\n" + "Open gate (top-level GUI JSON fields):\n" + " open_condition: { ... } player must meet it to open the GUI\n" + + " open_cost: \"item:amount\" entrance fee, charged once per open (pages are free)\n" + " on_deny: [ actions ] run instead of opening (default: action bar notice)\n" + "\n" + "Conditional item display: add \"else_item\" (same fields as a button)\n" + diff --git a/src/main/java/dev/toolkitmc/guiapi/config/GuiApiConfig.java b/src/main/java/dev/toolkitmc/guiapi/config/GuiApiConfig.java index d12c66a..be64435 100644 --- a/src/main/java/dev/toolkitmc/guiapi/config/GuiApiConfig.java +++ b/src/main/java/dev/toolkitmc/guiapi/config/GuiApiConfig.java @@ -41,6 +41,8 @@ public final class GuiApiConfig { private boolean enableCloseSound = true; // 7. New Config private String chatPrefix = "§8[§6GuiAPI§8] §f"; // 8. New Config + // Off by default so existing datapacks keep their exact message output. + private boolean chatPrefixEnabled = false; private int soundVolume = 100; // 9. New Config private String commandExecuteMode = "CHAT"; // 10. New Config private boolean allowGamemodeChange = true; // 11. New Config @@ -89,6 +91,8 @@ public void load() { enableCloseSound = obj.get("enable_close_sound").getAsBoolean(); if (obj.has("chat_prefix")) chatPrefix = obj.get("chat_prefix").getAsString(); + if (obj.has("chat_prefix_enabled")) + chatPrefixEnabled = obj.get("chat_prefix_enabled").getAsBoolean(); if (obj.has("sound_volume")) soundVolume = Math.clamp(obj.get("sound_volume").getAsInt(), 0, 100); if (obj.has("command_execute_mode")) @@ -118,6 +122,7 @@ public void save() { obj.addProperty("mute_click_errors", muteClickErrors); obj.addProperty("enable_close_sound", enableCloseSound); obj.addProperty("chat_prefix", chatPrefix); + obj.addProperty("chat_prefix_enabled", chatPrefixEnabled); obj.addProperty("sound_volume", soundVolume); obj.addProperty("command_execute_mode", commandExecuteMode); obj.addProperty("allow_gamemode_change", allowGamemodeChange); @@ -170,6 +175,9 @@ public void save() { public boolean isEnableCloseSound() { return enableCloseSound; } public void setEnableCloseSound(boolean v) { enableCloseSound = v; } + public boolean isChatPrefixEnabled() { return chatPrefixEnabled; } + public void setChatPrefixEnabled(boolean v) { chatPrefixEnabled = v; } + public String getChatPrefix() { return chatPrefix; } public void setChatPrefix(String v) { chatPrefix = v; } diff --git a/src/main/java/dev/toolkitmc/guiapi/gui/BarrelGuiHandler.java b/src/main/java/dev/toolkitmc/guiapi/gui/BarrelGuiHandler.java index a1749be..7154fc2 100644 --- a/src/main/java/dev/toolkitmc/guiapi/gui/BarrelGuiHandler.java +++ b/src/main/java/dev/toolkitmc/guiapi/gui/BarrelGuiHandler.java @@ -88,6 +88,14 @@ private static void debug(String msg, Object... args) { GuiApiMod.LOGGER.info("[GuiAPI|Debug] " + msg, args); } + /** Applies the configured chat prefix to a chat (non-action-bar) message. */ + private static Component prefixed(String text) { + var cfg = dev.toolkitmc.guiapi.config.GuiApiConfig.INSTANCE; + if (!cfg.isChatPrefixEnabled()) return Component.literal(text); + String prefix = cfg.getChatPrefix(); + return Component.literal((prefix == null ? "" : prefix) + text); + } + // ── Public API ─────────────────────────────────────────────────────────── public static void open(ServerPlayer player, GuiDefinition def) { @@ -109,16 +117,54 @@ private static SimpleContainer populateChestMinecart(ServerPlayer player, GuiDef */ private static int denyDepth = 0; + /** + * Opens a GUI from outside it (command, item, open_gui action). Enforces the + * open_condition gate and charges open_cost. + */ public static void open(ServerPlayer player, GuiDefinition def, int page) { + openInternal(player, def, page, true); + } + + /** + * Same GUI, different page (next/prev/goto, anvil return). Re-opens the + * container but must NOT charge open_cost again — the player already paid + * to be here. The open_condition gate is still re-checked. + */ + private static void reopen(ServerPlayer player, GuiDefinition def, int page) { + openInternal(player, def, page, false); + } + + /** "minecraft:gold_ingot:5" → "5x gold_ingot" for player-facing text. */ + private static String describeCost(String rawCost) { + ItemSpec spec = ItemSpec.parse(rawCost, 1); + String id = spec.itemId(); + int colon = id.indexOf(':'); + return Math.max(1, spec.amount()) + "x " + (colon >= 0 ? id.substring(colon + 1) : id); + } + + /** Whether {@code def.open_cost} can be paid right now (true when free). */ + private static boolean canAffordOpenCost(ServerPlayer player, GuiDefinition def) { + if (def.getOpenCost().isEmpty()) return true; + ItemSpec spec = ItemSpec.parse(def.getOpenCost(), 1); + return hasItemCount(player, spec.itemId(), Math.max(1, spec.amount())); + } + + private static void openInternal(ServerPlayer player, GuiDefinition def, int page, boolean chargeCost) { + boolean gateClosed = def.getOpenCondition().isPresent() + && !evaluateCondition(player, def.getOpenCondition().get()); + boolean cannotPay = !gateClosed && chargeCost && !canAffordOpenCost(player, def); // open_condition gate — checked before any state is registered or any inventory is built. - if (def.getOpenCondition().isPresent() && !evaluateCondition(player, def.getOpenCondition().get())) { + if (gateClosed || cannotPay) { debug("open denied: player={} gui={}", player.getScoreboardName(), def.getId()); if (denyDepth >= 3) { GuiApiMod.LOGGER.warn("[GuiAPI] on_deny of {} keeps re-opening a denied GUI — stopping.", def.getId()); return; } if (def.getOnDeny().isEmpty()) { - player.sendSystemMessage(Component.literal("§cYou cannot open this menu."), true); + String msg = cannotPay + ? "§cYou need " + describeCost(def.getOpenCost()) + " to open this menu." + : "§cYou cannot open this menu."; + player.sendSystemMessage(Component.literal(msg), true); return; } denyDepth++; @@ -130,6 +176,12 @@ public static void open(ServerPlayer player, GuiDefinition def, int page) { return; } + if (chargeCost && !def.getOpenCost().isEmpty()) { + ItemSpec spec = ItemSpec.parse(def.getOpenCost(), 1); + takeItemCount(player, spec.itemId(), Math.max(1, spec.amount())); + debug("open_cost charged: player={} gui={} cost={}", player.getScoreboardName(), def.getId(), def.getOpenCost()); + } + page = Math.clamp(page, 0, def.getPageCount() - 1); int rows = Math.clamp(def.getRows(), 1, 6); int finalPage = page; @@ -846,10 +898,8 @@ private static boolean evaluateLeafCondition(ServerPlayer player, GuiDefinition. yield current.getName().equalsIgnoreCase(cond.value().trim()); } // IN_DIMENSION — value is a dimension id, e.g. minecraft:the_nether - case IN_DIMENSION -> { - Identifier dimId = Identifier.tryParse(cond.value().trim()); - yield dimId != null && player.level().dimension().toString().equals(dimId.toString()); - } + case IN_DIMENSION -> + DimensionUtil.matches(player.level().dimension().toString(), cond.value()); }; } @@ -994,7 +1044,7 @@ static boolean executeAction(ServerPlayer player, GuiDefinition def, GuiVarStore.INSTANCE.set(sp.getUUID(), varKey, text); GuiInputStore.INSTANCE.set(sp.getUUID(), text); dev.toolkitmc.guiapi.loader.GuiRegistry.INSTANCE.get(previousGuiId) - .ifPresent(target -> open(sp, target, previousPage)); + .ifPresent(target -> { if (target == def) reopen(sp, target, previousPage); else open(sp, target, previousPage); }); }); } case RUN_FUNCTION -> { @@ -1069,7 +1119,7 @@ static boolean executeAction(ServerPlayer player, GuiDefinition def, .ifPresentOrElse( target -> open(player, target), () -> player.sendSystemMessage( - Component.literal("[GuiAPI] GUI not found: " + targetId), false)); + prefixed("[GuiAPI] GUI not found: " + targetId), false)); } return true; } @@ -1077,7 +1127,7 @@ static boolean executeAction(ServerPlayer player, GuiDefinition def, String msgVal = resolve(action.value(), player, def, currentPage); String mode = dev.toolkitmc.guiapi.config.GuiApiConfig.INSTANCE.getCommandExecuteMode(); if ("CHAT".equalsIgnoreCase(mode)) { - player.sendSystemMessage(Component.literal(msgVal), false); + player.sendSystemMessage(prefixed(msgVal), false); } else if ("SYSTEM".equalsIgnoreCase(mode)) { player.sendSystemMessage(Component.literal(msgVal), true); } @@ -1087,7 +1137,7 @@ static boolean executeAction(ServerPlayer player, GuiDefinition def, if (next < def.getPageCount()) { navigateAway(player); player.closeContainer(); - open(player, def, next); + reopen(player, def, next); } return true; } @@ -1096,7 +1146,7 @@ static boolean executeAction(ServerPlayer player, GuiDefinition def, if (prev >= 0) { navigateAway(player); player.closeContainer(); - open(player, def, prev); + reopen(player, def, prev); } return true; } @@ -1136,7 +1186,7 @@ static boolean executeAction(ServerPlayer player, GuiDefinition def, if (target >= 0 && target < def.getPageCount()) { navigateAway(player); player.closeContainer(); - open(player, def, target); + reopen(player, def, target); } } catch (NumberFormatException ignored) {} return true; @@ -1190,7 +1240,7 @@ static boolean executeAction(ServerPlayer player, GuiDefinition def, case BROADCAST -> { String text = resolve(action.value(), player, def, currentPage); if (!text.isEmpty()) { - Component broadcast = Component.literal(text); + Component broadcast = prefixed(text); for (ServerPlayer recipient : server.getPlayerList().getPlayers()) { recipient.sendSystemMessage(broadcast, false); } diff --git a/src/main/java/dev/toolkitmc/guiapi/gui/DimensionUtil.java b/src/main/java/dev/toolkitmc/guiapi/gui/DimensionUtil.java new file mode 100644 index 0000000..26eec72 --- /dev/null +++ b/src/main/java/dev/toolkitmc/guiapi/gui/DimensionUtil.java @@ -0,0 +1,34 @@ +package dev.toolkitmc.guiapi.gui; + +/** + * Pure-Java helper for comparing dimension ids without depending on the exact + * {@code ResourceKey} accessor name of a given Minecraft version. + * + * {@code ResourceKey.toString()} renders as + * {@code ResourceKey[minecraft:dimension / minecraft:the_nether]}; the old code + * compared that whole string against a bare id, so {@code in_dimension} could + * never be true. + */ +final class DimensionUtil { + + private DimensionUtil() {} + + /** Extracts the value id ({@code minecraft:the_nether}) from a ResourceKey string. */ + static String extractId(String resourceKeyString) { + if (resourceKeyString == null) return ""; + int slash = resourceKeyString.lastIndexOf(" / "); + int close = resourceKeyString.lastIndexOf(']'); + if (slash >= 0 && close > slash) { + return resourceKeyString.substring(slash + 3, close).trim(); + } + return resourceKeyString.trim(); + } + + /** True when the key string denotes the same id as {@code wanted}. */ + static boolean matches(String resourceKeyString, String wanted) { + if (wanted == null || wanted.isBlank()) return false; + String w = wanted.trim(); + if (!w.contains(":")) w = "minecraft:" + w; + return extractId(resourceKeyString).equals(w); + } +} diff --git a/src/main/java/dev/toolkitmc/guiapi/gui/GuiDefinition.java b/src/main/java/dev/toolkitmc/guiapi/gui/GuiDefinition.java index 29bdd20..2188c3d 100644 --- a/src/main/java/dev/toolkitmc/guiapi/gui/GuiDefinition.java +++ b/src/main/java/dev/toolkitmc/guiapi/gui/GuiDefinition.java @@ -323,6 +323,8 @@ public record Button( private List displays = List.of(); private Optional openCondition = Optional.empty(); private List onDeny = List.of(); + /** Raw "itemId:amount" entrance fee, or empty for none ({@code open_cost}). */ + private String openCost = ""; // ── Constructor ────────────────────────────────────────────────────────── @@ -470,6 +472,7 @@ public static GuiDefinition parse(Identifier id, JsonObject obj) { def.openCondition = parseConditionField(obj, "open_condition"); def.onDeny = parseActionList(obj, "on_deny"); + def.openCost = obj.has("open_cost") ? obj.get("open_cost").getAsString().trim() : ""; return def; } @@ -760,6 +763,8 @@ private static ButtonAction parseAction(JsonObject a) { public List getDisplays() { return displays; } /** Condition the player must meet to open this GUI, if any ({@code open_condition}). */ public Optional getOpenCondition() { return openCondition; } + /** Entrance fee as {@code "itemId:amount"}; empty string means free ({@code open_cost}). */ + public String getOpenCost() { return openCost; } /** Actions run instead of opening when {@link #getOpenCondition()} is false ({@code on_deny}). */ public List getOnDeny() { return onDeny; } diff --git a/src/main/java/dev/toolkitmc/guiapi/gui/GuiSerializer.java b/src/main/java/dev/toolkitmc/guiapi/gui/GuiSerializer.java new file mode 100644 index 0000000..f92a2c8 --- /dev/null +++ b/src/main/java/dev/toolkitmc/guiapi/gui/GuiSerializer.java @@ -0,0 +1,235 @@ +package dev.toolkitmc.guiapi.gui; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Lossless GuiDefinition → JSON. Mirrors every field {@link GuiDefinition#parse} + * reads, so parse(serialize(def)) reproduces the definition. The previous + * serializer in GuiRegistry silently dropped cooldown, else_item, macros, + * widgets, open gate, on_open/on_close, custom model data, action delay/condition + * and composite conditions whenever the in-game editor saved a GUI. + */ +public final class GuiSerializer { + + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + private GuiSerializer() {} + + public static String toJsonString(GuiDefinition def) { + return GSON.toJson(toJson(def)); + } + + public static JsonObject toJson(GuiDefinition def) { + JsonObject o = new JsonObject(); + o.addProperty("title", def.getTitle()); + o.addProperty("rows", def.getRows()); + o.addProperty("container_type", def.getContainerType().name().toLowerCase()); + o.addProperty("tick_rate", def.getTickRate()); + o.addProperty("close_on_move", def.isCloseOnMove()); + + def.getFiller().ifPresent(f -> { + JsonObject fo = new JsonObject(); + fo.addProperty("item", f.item()); + fo.addProperty("name", f.name()); + fo.addProperty("glint", f.glint()); + fo.addProperty("hide_tooltip", f.hideTooltip()); + o.add("filler", fo); + }); + + def.getOpenCondition().ifPresent(c -> o.add("open_condition", condition(c))); + if (!def.getOpenCost().isEmpty()) o.addProperty("open_cost", def.getOpenCost()); + putActions(o, "on_deny", def.getOnDeny()); + putActions(o, "on_open", def.getOnOpen()); + putActions(o, "on_close", def.getOnClose()); + + if (!def.getMacros().isEmpty()) { + JsonObject mo = new JsonObject(); + for (Map.Entry> e : def.getMacros().entrySet()) { + JsonArray arr = new JsonArray(); + for (GuiDefinition.ButtonAction a : e.getValue()) arr.add(action(a)); + mo.add(e.getKey(), arr); + } + o.add("macros", mo); + } + + JsonArray buttons = new JsonArray(); + for (GuiDefinition.Button b : def.getButtons()) buttons.add(button(b)); + o.add("buttons", buttons); + + if (!def.getProgressBars().isEmpty()) { + JsonArray arr = new JsonArray(); + for (GuiDefinition.ProgressBarWidget p : def.getProgressBars()) { + JsonObject po = new JsonObject(); + po.addProperty("start_slot", p.startSlot()); + po.addProperty("length", p.length()); + po.addProperty("page", p.page()); + po.addProperty("value_source", p.valueSource()); + po.addProperty("max_value", p.maxValue()); + po.addProperty("filled_item", p.filledItem()); + po.addProperty("empty_item", p.emptyItem()); + po.addProperty("name", p.name()); + putStrings(po, "lore", p.lore()); + arr.add(po); + } + o.add("progress_bars", arr); + } + + if (!def.getDisplays().isEmpty()) { + JsonArray arr = new JsonArray(); + for (GuiDefinition.StaticDisplayWidget d : def.getDisplays()) { + JsonObject dobj = new JsonObject(); + dobj.addProperty("slot", d.slot()); + dobj.addProperty("page", d.page()); + dobj.addProperty("item", d.item()); + dobj.addProperty("name", d.name()); + putStrings(dobj, "lore", d.lore()); + dobj.addProperty("glint", d.glint()); + dobj.addProperty("amount", d.amount()); + d.condition().ifPresent(c -> dobj.add("condition", condition(c))); + arr.add(dobj); + } + o.add("displays", arr); + } + return o; + } + + // ── Buttons ────────────────────────────────────────────────────────────── + + private static JsonObject button(GuiDefinition.Button b) { + JsonObject o = new JsonObject(); + o.addProperty("slot", b.slot()); + o.addProperty("page", b.page()); + o.addProperty("click_type", b.clickType().name().toLowerCase()); + if (b.cooldown() > 0) o.addProperty("cooldown", b.cooldown()); + b.condition().ifPresent(c -> o.add("condition", condition(c))); + b.elseDisplay().ifPresent(e -> o.add("else_item", elseDisplay(e))); + + if (b.toggle().isPresent()) { + o.add("toggle", toggle(b.toggle().get())); + return o; + } + o.addProperty("item", b.item()); + o.addProperty("name", b.name()); + putStrings(o, "lore", b.lore()); + o.addProperty("glint", b.glint()); + o.addProperty("amount", b.amount()); + o.addProperty("hide_tooltip", b.hideTooltip()); + o.addProperty("hide_additional_tooltip", b.hideAdditionalTooltip()); + putCmd(o, "custom_model_data", b.customModelData()); + b.itemModel().ifPresent(m -> o.addProperty("item_model", m)); + JsonArray actions = new JsonArray(); + for (GuiDefinition.ButtonAction a : b.actions()) actions.add(action(a)); + o.add("actions", actions); + return o; + } + + private static JsonObject elseDisplay(GuiDefinition.ConditionalDisplay e) { + JsonObject o = new JsonObject(); + o.addProperty("item", e.item()); + o.addProperty("name", e.name()); + putStrings(o, "lore", e.lore()); + o.addProperty("glint", e.glint()); + o.addProperty("amount", e.amount()); + o.addProperty("hide_tooltip", e.hideTooltip()); + o.addProperty("hide_additional_tooltip", e.hideAdditionalTooltip()); + putCmd(o, "custom_model_data", e.customModelData()); + e.itemModel().ifPresent(m -> o.addProperty("item_model", m)); + return o; + } + + private static JsonObject toggle(GuiDefinition.ToggleDefinition t) { + JsonObject o = new JsonObject(); + o.addProperty("tag", t.tag()); + o.addProperty("item_on", t.itemOn()); + o.addProperty("item_off", t.itemOff()); + o.addProperty("name_on", t.nameOn()); + o.addProperty("name_off", t.nameOff()); + putStrings(o, "lore_on", t.loreOn()); + putStrings(o, "lore_off", t.loreOff()); + o.addProperty("glint_on", t.glintOn()); + o.addProperty("glint_off", t.glintOff()); + o.addProperty("amount_on", t.amountOn()); + o.addProperty("amount_off", t.amountOff()); + o.addProperty("hide_tooltip_on", t.hideTooltipOn()); + o.addProperty("hide_tooltip_off", t.hideTooltipOff()); + o.addProperty("hide_additional_tooltip_on", t.hideAdditionalTooltipOn()); + o.addProperty("hide_additional_tooltip_off", t.hideAdditionalTooltipOff()); + putCmd(o, "custom_model_data_on", t.customModelDataOn()); + putCmd(o, "custom_model_data_off", t.customModelDataOff()); + t.itemModelOn().ifPresent(m -> o.addProperty("item_model_on", m)); + t.itemModelOff().ifPresent(m -> o.addProperty("item_model_off", m)); + JsonArray on = new JsonArray(); + for (GuiDefinition.ButtonAction a : t.actionsOn()) on.add(action(a)); + o.add("actions_on", on); + JsonArray off = new JsonArray(); + for (GuiDefinition.ButtonAction a : t.actionsOff()) off.add(action(a)); + o.add("actions_off", off); + return o; + } + + // ── Shared pieces ──────────────────────────────────────────────────────── + + static JsonObject action(GuiDefinition.ButtonAction a) { + JsonObject o = new JsonObject(); + o.addProperty("type", a.type().name().toLowerCase()); + o.addProperty("value", a.value()); + if (!a.var().isEmpty()) o.addProperty("var", a.var()); + if (a.runWith() == GuiDefinition.RunWith.CONSOLE) o.addProperty("run_with", "console"); + if (a.delay() > 0) o.addProperty("delay", a.delay()); + a.condition().ifPresent(c -> o.add("condition", condition(c))); + return o; + } + + static JsonObject condition(GuiDefinition.ButtonCondition c) { + JsonObject o = new JsonObject(); + o.addProperty("type", c.type().name().toLowerCase()); + switch (c.type()) { + case NOT -> { + if (!c.children().isEmpty()) o.add("condition", condition(c.children().get(0))); + } + case ALL, ANY -> { + JsonArray arr = new JsonArray(); + for (GuiDefinition.ButtonCondition ch : c.children()) arr.add(condition(ch)); + o.add("conditions", arr); + } + default -> o.addProperty("value", c.value()); + } + return o; + } + + private static void putActions(JsonObject o, String key, List actions) { + if (actions.isEmpty()) return; + JsonArray arr = new JsonArray(); + for (GuiDefinition.ButtonAction a : actions) arr.add(action(a)); + o.add(key, arr); + } + + private static void putStrings(JsonObject o, String key, List list) { + if (list.isEmpty()) return; + JsonArray arr = new JsonArray(); + for (String s : list) arr.add(s); + o.add(key, arr); + } + + private static void putCmd(JsonObject o, String key, Optional cmd) { + if (cmd.isEmpty()) return; + GuiDefinition.CustomModelDataConfig c = cmd.get(); + JsonObject co = new JsonObject(); + JsonArray floats = new JsonArray(); c.floats().forEach(floats::add); + JsonArray flags = new JsonArray(); c.flags().forEach(flags::add); + JsonArray strings = new JsonArray(); c.strings().forEach(strings::add); + JsonArray colors = new JsonArray(); c.colors().forEach(colors::add); + co.add("floats", floats); + co.add("flags", flags); + co.add("strings", strings); + co.add("colors", colors); + o.add(key, co); + } +} diff --git a/src/main/java/dev/toolkitmc/guiapi/loader/GuiRegistry.java b/src/main/java/dev/toolkitmc/guiapi/loader/GuiRegistry.java index 03fe7f9..429a4fe 100644 --- a/src/main/java/dev/toolkitmc/guiapi/loader/GuiRegistry.java +++ b/src/main/java/dev/toolkitmc/guiapi/loader/GuiRegistry.java @@ -135,105 +135,7 @@ public boolean saveToDisk(MinecraftServer server, Identifier id, GuiDefinition d } private static String serializeDefinition(GuiDefinition def) { - JsonObject obj = new JsonObject(); - obj.addProperty("title", def.getTitle()); - obj.addProperty("rows", def.getRows()); - obj.addProperty("tick_rate", def.getTickRate()); - obj.addProperty("close_on_move", def.isCloseOnMove()); - - if (def.getFiller().isPresent()) { - GuiDefinition.FillerConfig fill = def.getFiller().get(); - JsonObject fObj = new JsonObject(); - fObj.addProperty("item", fill.item()); - fObj.addProperty("name", fill.name()); - fObj.addProperty("glint", fill.glint()); - fObj.addProperty("hide_tooltip", fill.hideTooltip()); - obj.add("filler", fObj); - } - - com.google.gson.JsonArray btnsArray = new com.google.gson.JsonArray(); - for (GuiDefinition.Button b : def.getButtons()) { - JsonObject bObj = new JsonObject(); - bObj.addProperty("slot", b.slot()); - bObj.addProperty("page", b.page()); - bObj.addProperty("item", b.item()); - bObj.addProperty("name", b.name()); - bObj.addProperty("amount", b.amount()); - bObj.addProperty("glint", b.glint()); - bObj.addProperty("click_type", b.clickType().name().toLowerCase()); - bObj.addProperty("hide_tooltip", b.hideTooltip()); - bObj.addProperty("hide_additional_tooltip", b.hideAdditionalTooltip()); - - // Serialize Lore lines (Fixed: no longer omitted during save!) - com.google.gson.JsonArray loreArr = new com.google.gson.JsonArray(); - for (String l : b.lore()) { - loreArr.add(l); - } - bObj.add("lore", loreArr); - - if (b.condition().isPresent()) { - GuiDefinition.ButtonCondition cond = b.condition().get(); - JsonObject cObj = new JsonObject(); - cObj.addProperty("type", cond.type().name().toLowerCase()); - cObj.addProperty("value", cond.value()); - bObj.add("condition", cObj); - } - - if (b.toggle().isPresent()) { - GuiDefinition.ToggleDefinition tgl = b.toggle().get(); - JsonObject tObj = new JsonObject(); - tObj.addProperty("tag", tgl.tag()); - tObj.addProperty("item_on", tgl.itemOn()); - tObj.addProperty("item_off", tgl.itemOff()); - tObj.addProperty("name_on", tgl.nameOn()); - tObj.addProperty("name_off", tgl.nameOff()); - tObj.addProperty("glint_on", tgl.glintOn()); - tObj.addProperty("glint_off", tgl.glintOff()); - tObj.addProperty("amount_on", tgl.amountOn()); - tObj.addProperty("amount_off", tgl.amountOff()); - tObj.addProperty("hide_tooltip_on", tgl.hideTooltipOn()); - tObj.addProperty("hide_tooltip_off", tgl.hideTooltipOff()); - tObj.addProperty("hide_additional_tooltip_on", tgl.hideAdditionalTooltipOn()); - tObj.addProperty("hide_additional_tooltip_off", tgl.hideAdditionalTooltipOff()); - - com.google.gson.JsonArray actionsOnArr = new com.google.gson.JsonArray(); - for (GuiDefinition.ButtonAction act : tgl.actionsOn()) { - JsonObject aObj = new JsonObject(); - aObj.addProperty("type", act.type().name().toLowerCase()); - aObj.addProperty("value", act.value()); - if (!act.var().isEmpty()) aObj.addProperty("var", act.var()); - actionsOnArr.add(aObj); - } - tObj.add("actions_on", actionsOnArr); - - com.google.gson.JsonArray actionsOffArr = new com.google.gson.JsonArray(); - for (GuiDefinition.ButtonAction act : tgl.actionsOff()) { - JsonObject aObj = new JsonObject(); - aObj.addProperty("type", act.type().name().toLowerCase()); - aObj.addProperty("value", act.value()); - if (!act.var().isEmpty()) aObj.addProperty("var", act.var()); - actionsOffArr.add(aObj); - } - tObj.add("actions_off", actionsOffArr); - - bObj.add("toggle", tObj); - } else { - com.google.gson.JsonArray actionsArr = new com.google.gson.JsonArray(); - for (GuiDefinition.ButtonAction act : b.actions()) { - JsonObject aObj = new JsonObject(); - aObj.addProperty("type", act.type().name().toLowerCase()); - aObj.addProperty("value", act.value()); - if (!act.var().isEmpty()) aObj.addProperty("var", act.var()); - actionsArr.add(aObj); - } - bObj.add("actions", actionsArr); - } - - btnsArray.add(bObj); - } - obj.add("buttons", btnsArray); - - return GSON.toJson(obj); + return dev.toolkitmc.guiapi.gui.GuiSerializer.toJsonString(def); } /** Addon API — register a GUI definition from Java code */ diff --git a/src/main/resources/assets/guiapi/lang/tr_tr.json b/src/main/resources/assets/guiapi/lang/tr_tr.json new file mode 100644 index 0000000..8783e9c --- /dev/null +++ b/src/main/resources/assets/guiapi/lang/tr_tr.json @@ -0,0 +1,4 @@ +{ + "key.guiapi.open_menu": "Menü GUI'sini Aç", + "category.guiapi.general": "GUI API" +} diff --git a/src/test/java/dev/toolkitmc/guiapi/gui/DimensionUtilTest.java b/src/test/java/dev/toolkitmc/guiapi/gui/DimensionUtilTest.java new file mode 100644 index 0000000..d388956 --- /dev/null +++ b/src/test/java/dev/toolkitmc/guiapi/gui/DimensionUtilTest.java @@ -0,0 +1,15 @@ +package dev.toolkitmc.guiapi.gui; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class DimensionUtilTest { + private static final String NETHER = "ResourceKey[minecraft:dimension / minecraft:the_nether]"; + + @Test void matchesFullId() { assertTrue(DimensionUtil.matches(NETHER, "minecraft:the_nether")); } + @Test void matchesBareId() { assertTrue(DimensionUtil.matches(NETHER, "the_nether")); } + @Test void rejectsOtherDim() { assertFalse(DimensionUtil.matches(NETHER, "minecraft:overworld")); } + @Test void customNamespace() { assertTrue(DimensionUtil.matches("ResourceKey[minecraft:dimension / pack:mine]", "pack:mine")); } + @Test void blankWantedIsFalse() { assertFalse(DimensionUtil.matches(NETHER, " ")); } + @Test void extractPlainString() { assertEquals("minecraft:overworld", DimensionUtil.extractId("minecraft:overworld")); } +} diff --git a/src/test/java/dev/toolkitmc/guiapi/gui/GuiSerializerRoundTripTest.java b/src/test/java/dev/toolkitmc/guiapi/gui/GuiSerializerRoundTripTest.java new file mode 100644 index 0000000..53bc589 --- /dev/null +++ b/src/test/java/dev/toolkitmc/guiapi/gui/GuiSerializerRoundTripTest.java @@ -0,0 +1,81 @@ +package dev.toolkitmc.guiapi.gui; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.minecraft.resources.Identifier; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * parse → serialize → parse must be lossless. Needs the Minecraft classpath + * (Identifier), so it only runs under Gradle/CI. + */ +class GuiSerializerRoundTripTest { + + private static final String SRC = """ + { + "title": "T", "rows": 4, "tick_rate": 10, "close_on_move": true, + "container_type": "ender_chest", + "filler": {"item": "minecraft:black_stained_glass_pane", "name": " ", "glint": false, "hide_tooltip": true}, + "open_condition": {"type": "all", "conditions": [ + {"type": "has_tag", "value": "vip"}, + {"type": "not", "condition": {"type": "has_tag", "value": "banned"}}]}, + "on_deny": [{"type": "message", "value": "no"}], + "on_open": [{"type": "sound", "value": "minecraft:block.chest.open", "delay": 5}], + "on_close": [{"type": "run_command", "value": "say bye", "run_with": "console"}], + "macros": {"m": [{"type": "message", "value": "hi"}]}, + "buttons": [ + {"slot": 1, "cooldown": 100, "click_type": "left", "item": "minecraft:diamond", + "name": "A", "lore": ["l1"], "amount": "3", + "custom_model_data": {"floats": [1.5], "flags": [true], "strings": ["s"], "colors": [7]}, + "item_model": "pack:m", + "condition": {"type": "any", "conditions": [{"type": "level_gt", "value": "5"}]}, + "else_item": {"item": "minecraft:barrier", "name": "locked"}, + "actions": [{"type": "add_var", "var": "k", "value": "2", "delay": 3, + "condition": {"type": "has_tag", "value": "x"}}]}, + {"slot": 2, "toggle": {"tag": "t", "lore_on": ["on"], "custom_model_data_on": 9}} + ], + "progress_bars": [{"start_slot": 9, "length": 5, "max_value": 50, "value_source": "score:coins"}], + "displays": [{"slot": 0, "item": "minecraft:paper", "name": "d", "amount": "2"}] + } + """; + + @Test + void roundTripIsStable() { + Identifier id = Identifier.fromNamespaceAndPath("t", "x"); + GuiDefinition first = GuiDefinition.parse(id, JsonParser.parseString(SRC).getAsJsonObject()); + + JsonObject out1 = GuiSerializer.toJson(first); + GuiDefinition second = GuiDefinition.parse(id, out1); + JsonObject out2 = GuiSerializer.toJson(second); + + // Serializing the re-parsed definition must give the identical document. + assertEquals(out1, out2); + } + + @Test + void preservesFieldsThatUsedToBeDropped() { + Identifier id = Identifier.fromNamespaceAndPath("t", "x"); + GuiDefinition def = GuiDefinition.parse(id, JsonParser.parseString(SRC).getAsJsonObject()); + JsonObject out = GuiSerializer.toJson(def); + + assertEquals("ender_chest", out.get("container_type").getAsString()); + assertTrue(out.has("open_condition")); + assertTrue(out.has("on_deny")); + assertTrue(out.has("on_open")); + assertTrue(out.has("on_close")); + assertTrue(out.has("macros")); + assertTrue(out.has("progress_bars")); + assertTrue(out.has("displays")); + + JsonObject b = out.getAsJsonArray("buttons").get(0).getAsJsonObject(); + assertEquals(100, b.get("cooldown").getAsInt()); + assertTrue(b.has("else_item")); + assertTrue(b.has("custom_model_data")); + assertEquals("pack:m", b.get("item_model").getAsString()); + JsonObject act = b.getAsJsonArray("actions").get(0).getAsJsonObject(); + assertEquals(3, act.get("delay").getAsInt()); + assertTrue(act.has("condition")); + } +} From 0e04f7d863105e274f5a52dd95459154e7865aff Mon Sep 17 00:00:00 2001 From: Legends11 <235496468+tickwarden@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:07:23 +0300 Subject: [PATCH 4/4] Add files via upload --- build.gradle | 3 +++ .../dev/toolkitmc/guiapi/gui/GuiSerializerRoundTripTest.java | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/build.gradle b/build.gradle index c7cf88e..f84aaa6 100644 --- a/build.gradle +++ b/build.gradle @@ -43,6 +43,9 @@ dependencies { testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.2' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.2' + // Gradle 9+ no longer bundles the launcher; it must be on the test runtime classpath. + // Version must match the Jupiter line above (Jupiter 5.10.x <-> Platform 1.10.x). + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' } test { diff --git a/src/test/java/dev/toolkitmc/guiapi/gui/GuiSerializerRoundTripTest.java b/src/test/java/dev/toolkitmc/guiapi/gui/GuiSerializerRoundTripTest.java index 53bc589..f8684a3 100644 --- a/src/test/java/dev/toolkitmc/guiapi/gui/GuiSerializerRoundTripTest.java +++ b/src/test/java/dev/toolkitmc/guiapi/gui/GuiSerializerRoundTripTest.java @@ -10,6 +10,11 @@ /** * parse → serialize → parse must be lossless. Needs the Minecraft classpath * (Identifier), so it only runs under Gradle/CI. + * + * NOTE: SRC deliberately sets "tick_rate". GuiDefinition.parse() falls back to + * GuiApiConfig.INSTANCE.getDefaultTickRate() only when it is absent, and + * GuiApiConfig's static init calls FabricLoader.getInstance(), which does not + * exist in a plain unit-test JVM. Do not remove "tick_rate" from SRC. */ class GuiSerializerRoundTripTest {