diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 941eb73..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,81 +0,0 @@ -name: CI - -on: - push: - branches: [main, master] - pull_request: - workflow_dispatch: - -jobs: - test: - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest] - python-version: ["3.9", "3.12"] - - steps: - - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install - run: pip install -e . - - - name: Write minimal config - run: | - cat > menu.json << 'JSON' - { - "namespace": "demo", - "menu_id": "shop", - "pages": [ - { - "index": 0, - "name": "Main", - "widgets": [ - { - "kind": "button", - "slot": 13, - "item": "minecraft:diamond", - "name": { "text": "Get Diamond", "color": "aqua" }, - "commands": ["give @s minecraft:diamond 1"] - }, - { "kind": "close", "slot": 26 } - ] - } - ] - } - JSON - - - name: Generate folder - run: | - guigenmc generate menu.json -f - test -f shop_datapack/pack.mcmeta - test -f shop_datapack/data/demo/function/core/load.mcfunction - - - name: Generate zip + validate + tree - run: | - guigenmc generate menu.json -z -f -o shop.zip - guigenmc validate menu.json - guigenmc tree menu.json - - - name: UI static present - run: test -f $(python -c "import guigenmc, pathlib; print(pathlib.Path(guigenmc.__file__).parent / 'static' / 'index.html')") - - - - name: Build sdist + wheel - run: | - pip install build twine - python -m build - twine check dist/* - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: datapack-${{ matrix.os }}-py${{ matrix.python-version }} - path: shop.zip - retention-days: 3 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 3fced28..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Publish to PyPI - -on: - release: - types: [published] - workflow_dispatch: - inputs: - test_pypi: - description: "Publish to TestPyPI instead of PyPI" - type: boolean - default: false - -permissions: - contents: read - id-token: write # Trusted Publishing (OIDC) — no API token needed - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install build tools - run: pip install --upgrade pip build twine - - - name: Build sdist + wheel - run: python -m build - - - name: Check package - run: twine check dist/* - - - name: Upload dist artifact - uses: actions/upload-artifact@v4 - with: - name: python-package-distributions - path: dist/ - - publish: - needs: build - runs-on: ubuntu-latest - environment: - name: pypi - url: https://pypi.org/p/guigenmc - steps: - - name: Download dist - uses: actions/download-artifact@v4 - with: - name: python-package-distributions - path: dist/ - - - name: Publish to TestPyPI - if: github.event_name == 'workflow_dispatch' && inputs.test_pypi - uses: pypa/gh-action-pypi-publish@release/v1 - with: - repository-url: https://test.pypi.org/legacy/ - - - name: Publish to PyPI - if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && !inputs.test_pypi) - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/README.md b/README.md index 44bfe4a..b7eade4 100644 --- a/README.md +++ b/README.md @@ -133,8 +133,59 @@ Minimal `config.json`: | `confirm` | Go to a confirm page | | `close` | Close the menu | | `random` | Weighted random rewards | +| `link` | Clickable external URL (tellraw `open_url`) | +| `cycle` | Cycle through options (score-based selector) | -Also supports: cost, cooldown, conditions, sounds, multi-page menus, hopper/chest minecart containers. +Also supports: cost, cooldown, conditions (`item_count_*`, `score`, `has_tag`, `gamemode`, **`has_advancement`**), sounds, multi-page menus, hopper/chest minecart containers. + +### New widget examples + +**Link** + +```json +{ + "kind": "link", + "slot": 11, + "item": "minecraft:writable_book", + "name": { "text": "Discord", "color": "aqua" }, + "url": "https://discord.gg/example", + "link_text": { "text": "Join our Discord", "color": "aqua" } +} +``` + +**Cycle** + +```json +{ + "kind": "cycle", + "slot": 13, + "cycle": { + "score": "difficulty", + "options": [ + { "item": "minecraft:wooden_sword", "name": { "text": "Easy", "color": "green" } }, + { "item": "minecraft:iron_sword", "name": { "text": "Normal", "color": "yellow" } }, + { "item": "minecraft:diamond_sword", "name": { "text": "Hard", "color": "red" } } + ] + } +} +``` + +**has_advancement condition** + +```json +{ + "kind": "button", + "slot": 15, + "item": "minecraft:nether_star", + "name": { "text": "Claim Reward" }, + "commands": ["give @s minecraft:diamond 5"], + "condition": { + "type": "has_advancement", + "advancement": "minecraft:story/mine_diamond", + "fail_message": { "text": "You need the Mine Diamond advancement!", "color": "red" } + } +} +``` --- diff --git a/guigenmc/generators/fill.py b/guigenmc/generators/fill.py index 7b86208..93c549c 100644 --- a/guigenmc/generators/fill.py +++ b/guigenmc/generators/fill.py @@ -6,6 +6,7 @@ from ..components import item_replace_command, mk_item_components from ..models import ( + components_for_cycle, components_for_toggle, container_entity_id, container_slot_count, @@ -50,6 +51,17 @@ def emit_toggle(menu: dict[str, Any], w: dict[str, Any]) -> list[str]: return lines +def emit_cycle(menu: dict[str, Any], w: dict[str, Any]) -> list[str]: + lines: list[str] = [] + opts = w["cycle"]["options"] + score = w["cycle"]["score"] + for i, _ in enumerate(opts): + item, comps = components_for_cycle(w, i) + cmd = item_replace_command(cart_selector(menu), w["slot"], item, comps) + lines.append(f"execute if score @s {score} matches {i} run {cmd}") + return lines + + def emit_progress(menu: dict[str, Any], w: dict[str, Any]) -> list[str]: lines: list[str] = [] width = w["progress_width"] @@ -97,6 +109,8 @@ def generate_page_fills(menu: dict[str, Any], out: dict[str, str]) -> None: lines.append(f"# slot {w['slot']}: {resolved_action_id(w)} ({w['kind']})") if w["kind"] == "toggle": lines.extend(emit_toggle(menu, w)) + elif w["kind"] == "cycle": + lines.extend(emit_cycle(menu, w)) elif w["kind"] == "progress": lines.extend(emit_progress(menu, w)) else: diff --git a/guigenmc/generators/handlers.py b/guigenmc/generators/handlers.py index 46df31f..dfc1754 100644 --- a/guigenmc/generators/handlers.py +++ b/guigenmc/generators/handlers.py @@ -223,6 +223,64 @@ def handler_for(menu: dict[str, Any], w: dict[str, Any]) -> list[str]: ) return lines + if w["kind"] == "link": + lines.extend(playsound(w.get("sound"))) + url = w.get("url") or "" + link_text = w.get("link_text") or { + "text": "Click here to open", + "italic": False, + "color": "aqua", + "bold": None, + "underlined": True, + } + # Minecraft 1.21.5+: clickEvent → click_event, value → url + body = ( + f'{{"text":"{escape_json(link_text.get("text") or "Open link")}",' + f'"italic":false,' + f'"color":"{link_text.get("color") or "aqua"}",' + f'"underlined":true,' + f'"click_event":{{"action":"open_url","url":"{escape_json(url)}"}}' + f'}}' + ) + lines.append( + f'tellraw @s [{{"text":"[GUI-GENERATOR] ","color":"gray"}},{body}]' + ) + if w.get("success_message"): + lines.append(tellraw_line(w["success_message"])) + # Keep menu open + lines.append(f"function {menu_function_prefix(menu)}/fill") + return lines + + if w["kind"] == "cycle": + c = w["cycle"] + score = c["score"] + n = len(c["options"]) + lines.extend(playsound(w.get("sound"))) + lines.append(f"scoreboard players add @s {score} 1") + if c.get("wrap", True): + lines.append( + f"execute if score @s {score} matches {n}.. run scoreboard players set @s {score} 0" + ) + else: + lines.append( + f"execute if score @s {score} matches {n}.. run scoreboard players set @s {score} {n - 1}" + ) + # Run option-specific commands + for i, opt in enumerate(c["options"]): + guard = f"execute if score @s {score} matches {i} run " + for cmd in opt.get("commands") or []: + lines.append(f"{guard}{cmd}") + for fn in opt.get("functions") or []: + lines.append(f"{guard}function {fn}") + # Feedback with current option name + for i, opt in enumerate(c["options"]): + name = opt.get("name") or {"text": f"Option {i}", "color": "yellow"} + lines.append( + f"execute if score @s {score} matches {i} run {tellraw_line(name)}" + ) + lines.append(f"function {menu_function_prefix(menu)}/fill") + return lines + if w.get("condition") is not None: cond = w["condition"] fail = cond.get("fail_message") or { @@ -271,6 +329,13 @@ def success_block(prefix: str) -> list[str]: lines.append( f"execute unless entity @s[gamemode={cond['gamemode']}] run {tellraw_line(fail)}" ) + elif cond["type"] == "has_advancement": + adv = cond.get("advancement") or "" + ok = f"execute if entity @s[advancements={{{adv}=true}}] run " + lines.extend(success_block(ok)) + lines.append( + f"execute unless entity @s[advancements={{{adv}=true}}] run {tellraw_line(fail)}" + ) else: lines.extend(emit_actions(w)) if w.get("success_message"): diff --git a/guigenmc/generators/lifecycle.py b/guigenmc/generators/lifecycle.py index 4839291..f5b6e13 100644 --- a/guigenmc/generators/lifecycle.py +++ b/guigenmc/generators/lifecycle.py @@ -80,6 +80,8 @@ def generate_open(menu: dict[str, Any], out: dict[str, str]) -> None: scores_to_init.append(w["counter_score"]) if w.get("progress_score"): scores_to_init.append(w["progress_score"]) + if w.get("cycle"): + scores_to_init.append(w["cycle"]["score"]) for sc in scores_to_init: if sc in inited: continue diff --git a/guigenmc/models.py b/guigenmc/models.py index ea499ef..f77d9d5 100644 --- a/guigenmc/models.py +++ b/guigenmc/models.py @@ -18,6 +18,8 @@ "close", "confirm", "random", + "link", + "cycle", ] WIDGET_KIND_ALIASES = { @@ -30,6 +32,10 @@ "page": "nav", "bar": "progress", "stepper": "counter", + "url": "link", + "hyperlink": "link", + "selector": "cycle", + "carousel": "cycle", } VALID_CONDITION_TYPES = [ @@ -38,6 +44,7 @@ "score", "has_tag", "gamemode", + "has_advancement", ] CONTAINER_TYPES: dict[str, tuple[str, int]] = { @@ -137,6 +144,24 @@ def components_for_toggle(w: dict[str, Any], state: int) -> tuple[str, dict[str, ) +def components_for_cycle(w: dict[str, Any], index: int) -> tuple[str, dict[str, Any]]: + from .components import mk_item_components + + opts = w["cycle"]["options"] + opt = opts[index % len(opts)] + data = gui_custom_data(w) + return ( + opt["item"], + mk_item_components( + custom_name=opt["name"], + lore=list(opt.get("lore") or []), + custom_data=data, + enchanted=bool(opt.get("enchanted", w.get("enchanted"))), + custom_model_data=opt.get("custom_model_data", w.get("custom_model_data")), + ), + ) + + def is_interactive(w: dict[str, Any]) -> bool: return w["kind"] not in ("label", "separator", "progress") @@ -187,6 +212,9 @@ def mk_widget(**fields: Any) -> dict[str, Any]: "progress_full_item": "minecraft:lime_stained_glass_pane", "progress_empty_item": "minecraft:gray_stained_glass_pane", "random": None, + "url": None, + "link_text": None, + "cycle": None, "clickable": True, "enchanted": False, "custom_model_data": None, @@ -311,6 +339,8 @@ def collect_scores(m: dict[str, Any]) -> list[str]: scores.append(w["counter_score"]) if w.get("progress_score") and w["progress_score"] not in scores: scores.append(w["progress_score"]) + if w.get("cycle") and w["cycle"]["score"] not in scores: + scores.append(w["cycle"]["score"]) return scores @@ -385,6 +415,7 @@ def loader_condition(raw: Any) -> dict[str, Any] | None: "matches": raw.get("matches"), "tag": raw.get("tag"), "gamemode": raw.get("gamemode"), + "advancement": raw.get("advancement"), "fail_message": loader_text(raw.get("fail_message")), } @@ -452,6 +483,39 @@ def loader_random(raw: Any) -> dict[str, Any]: return {"rewards": [loader_random_reward(r, i) for i, r in enumerate(rewards_raw)]} +def loader_cycle_option(raw: Any, index: int) -> dict[str, Any]: + if not isinstance(raw, dict): + raise TypeError(f"cycle.options[{index}] must be an object") + if "item" not in raw: + raise ValueError(f"cycle.options[{index}] requires 'item'") + if "name" not in raw: + raise ValueError(f"cycle.options[{index}] requires 'name'") + return { + "item": str(raw["item"]), + "name": loader_text(raw["name"]), + "lore": loader_text_list(raw.get("lore")), + "commands": list(raw.get("commands") or []), + "functions": list(raw.get("functions") or []), + "enchanted": bool(raw.get("enchanted", False)), + "custom_model_data": int(raw["custom_model_data"]) if raw.get("custom_model_data") is not None else None, + } + + +def loader_cycle(raw: Any) -> dict[str, Any]: + if not isinstance(raw, dict): + raise TypeError("cycle must be an object") + if "score" not in raw: + raise ValueError("cycle requires 'score'") + options_raw = raw.get("options") or [] + if not isinstance(options_raw, list) or len(options_raw) < 2: + raise ValueError("cycle requires at least 2 options") + return { + "score": str(raw["score"]), + "options": [loader_cycle_option(o, i) for i, o in enumerate(options_raw)], + "wrap": bool(raw.get("wrap", True)), + } + + def loader_widget(raw: Any) -> dict[str, Any]: if not isinstance(raw, dict): raise TypeError("widget must be an object") @@ -538,6 +602,41 @@ def loader_widget(raw: Any) -> dict[str, Any]: raise ValueError("random widget requires 'random' object") w["random"] = loader_random(raw["random"]) + if kind == "link": + url = raw.get("url") or raw.get("link") + if not url: + raise ValueError("link widget requires 'url'") + w["url"] = str(url) + w["link_text"] = loader_text(raw.get("link_text") or raw.get("message")) + if w["name"] is None: + w["name"] = mk_text("Open Link", color="aqua") + if not w["lore"]: + w["lore"] = [mk_text("Click to open URL", color="gray")] + if w["item"] == "minecraft:stone": + w["item"] = "minecraft:writable_book" + if not w["action_id"]: + w["action_id"] = f"link_{slot}" + + if kind == "cycle": + cycle_raw = raw.get("cycle") + if cycle_raw is None and ("options" in raw or "score" in raw): + # allow flat form: { kind: "cycle", score: "...", options: [...] } + cycle_raw = { + "score": raw.get("score") or raw.get("cycle_score"), + "options": raw.get("options"), + "wrap": raw.get("wrap", True), + } + if not cycle_raw: + raise ValueError("cycle widget requires 'cycle' object (or score + options)") + w["cycle"] = loader_cycle(cycle_raw) + first = w["cycle"]["options"][0] + if not w["item"] or w["item"] == "minecraft:stone": + w["item"] = first["item"] + if w["name"] is None: + w["name"] = first["name"] + if not w["action_id"]: + w["action_id"] = w["cycle"]["score"] + if kind == "nav" and w["target_page"] is None: raise ValueError("nav widget requires target_page") if kind == "confirm" and w["confirm_page"] is None: diff --git a/guigenmc/static/index.html b/guigenmc/static/index.html index cac8ba1..d81b0f7 100644 --- a/guigenmc/static/index.html +++ b/guigenmc/static/index.html @@ -583,6 +583,19 @@ return [t.on_item, mkItemComponents({ custom_name: t.on_name, lore: t.on_lore.slice(), custom_data: data })]; } +function componentsForCycle(w, index) { + const opts = w.cycle.options; + const opt = opts[index % opts.length]; + const data = guiCustomData(w); + return [opt.item, mkItemComponents({ + custom_name: opt.name, + lore: (opt.lore || []).slice(), + custom_data: data, + enchanted: !!opt.enchanted, + custom_model_data: opt.custom_model_data, + })]; +} + function isInteractive(w) { return !["label", "separator", "progress"].includes(w.kind); } @@ -695,6 +708,7 @@ if (w.toggle && !scores.includes(w.toggle.score)) scores.push(w.toggle.score); if (w.counter_score && !scores.includes(w.counter_score)) scores.push(w.counter_score); if (w.progress_score && !scores.includes(w.progress_score)) scores.push(w.progress_score); + if (w.cycle && !scores.includes(w.cycle.score)) scores.push(w.cycle.score); } return scores; } @@ -726,7 +740,7 @@ return out; } -const VALID_CONDITION_TYPES = ["item_count_lt", "item_count_gte", "score", "has_tag", "gamemode"]; +const VALID_CONDITION_TYPES = ["item_count_lt", "item_count_gte", "score", "has_tag", "gamemode", "has_advancement"]; function loaderCondition(raw) { if (raw == null) return null; @@ -742,6 +756,7 @@ matches: raw.matches ?? null, tag: raw.tag ?? null, gamemode: raw.gamemode ?? null, + advancement: raw.advancement ?? null, fail_message: loaderText(raw.fail_message), }; } @@ -790,10 +805,15 @@ page: "nav", bar: "progress", stepper: "counter", + url: "link", + hyperlink: "link", + selector: "cycle", + carousel: "cycle", }; const VALID_WIDGET_KINDS = [ "button", "label", "separator", "toggle", "counter", "nav", "progress", "close", "confirm", "random", + "link", "cycle", ]; function loaderRandomReward(raw, index) { @@ -877,6 +897,50 @@ w.random = loaderRandom(raw.random); } + if (kind === "link") { + const url = raw.url || raw.link; + if (!url) throw new Error("link widget requires 'url'"); + w.url = String(url); + w.link_text = loaderText(raw.link_text || raw.message); + if (w.name == null) w.name = mkText("Open Link", { color: "aqua" }); + if (!w.lore.length) w.lore = [mkText("Click to open URL", { color: "gray" })]; + if (w.item === "minecraft:stone") w.item = "minecraft:writable_book"; + if (!w.action_id) w.action_id = `link_${slot}`; + } + + if (kind === "cycle") { + let cycleRaw = raw.cycle; + if (cycleRaw == null && (raw.options || raw.score)) { + cycleRaw = { score: raw.score || raw.cycle_score, options: raw.options, wrap: raw.wrap !== false }; + } + if (!cycleRaw) throw new Error("cycle widget requires 'cycle' object (or score + options)"); + if (!cycleRaw.score) throw new Error("cycle requires 'score'"); + const opts = cycleRaw.options || []; + if (!Array.isArray(opts) || opts.length < 2) throw new Error("cycle requires at least 2 options"); + w.cycle = { + score: String(cycleRaw.score), + wrap: cycleRaw.wrap !== false, + options: opts.map((o, i) => { + if (!o || typeof o !== "object") throw new TypeError(`cycle.options[${i}] must be an object`); + if (!o.item) throw new Error(`cycle.options[${i}] requires 'item'`); + if (!o.name) throw new Error(`cycle.options[${i}] requires 'name'`); + return { + item: String(o.item), + name: loaderText(o.name), + lore: loaderTextList(o.lore), + commands: (o.commands || []).slice(), + functions: (o.functions || []).slice(), + enchanted: !!o.enchanted, + custom_model_data: o.custom_model_data != null ? parseInt(o.custom_model_data, 10) : null, + }; + }), + }; + const first = w.cycle.options[0]; + if (!w.item || w.item === "minecraft:stone") w.item = first.item; + if (w.name == null) w.name = first.name; + if (!w.action_id) w.action_id = w.cycle.score; + } + if (kind === "nav" && w.target_page == null) throw new Error("nav widget requires target_page"); if (kind === "confirm" && w.confirm_page == null) throw new Error("confirm widget requires confirm_page"); if (kind === "counter" && !w.counter_score) throw new Error("counter widget requires score / counter_score"); @@ -986,6 +1050,18 @@ return lines; } +function emitCycle(menu, w) { + const lines = []; + const opts = w.cycle.options; + const score = w.cycle.score; + for (let i = 0; i < opts.length; i++) { + const [item, comps] = componentsForCycle(w, i); + const cmd = itemReplaceCommand(cartSelector(menu), w.slot, item, comps); + lines.push(`execute if score @s ${score} matches ${i} run ${cmd}`); + } + return lines; +} + function emitProgress(menu, w) { const lines = []; const width = w.progress_width; @@ -1026,6 +1102,7 @@ for (const w of page.widgets) { lines.push(`# slot ${w.slot}: ${resolvedActionId(w)} (${w.kind})`); if (w.kind === "toggle") lines.push(...emitToggle(menu, w)); + else if (w.kind === "cycle") lines.push(...emitCycle(menu, w)); else if (w.kind === "progress") lines.push(...emitProgress(menu, w)); else lines.push(...emitStatic(menu, w)); lines.push(""); @@ -1231,6 +1308,43 @@ return lines; } + if (w.kind === "link") { + lines.push(...playsound(w.sound)); + const url = w.url || ""; + const lt = w.link_text || mkText("Click here to open", { color: "aqua" }); + // Minecraft 1.21.5+: clickEvent → click_event, value → url + const body = `{"text":"${escapeJson(lt.text || "Open link")}","italic":false,"color":"${lt.color || "aqua"}","underlined":true,"click_event":{"action":"open_url","url":"${escapeJson(url)}"}}`; + lines.push(`tellraw @s [{"text":"[GUI-GENERATOR] ","color":"gray"},${body}]`); + if (w.success_message) lines.push(tellrawLine(w.success_message)); + lines.push(`function ${menuFunctionPrefix(menu)}/fill`); + return lines; + } + + if (w.kind === "cycle") { + const c = w.cycle; + const score = c.score; + const n = c.options.length; + lines.push(...playsound(w.sound)); + lines.push(`scoreboard players add @s ${score} 1`); + if (c.wrap !== false) { + lines.push(`execute if score @s ${score} matches ${n}.. run scoreboard players set @s ${score} 0`); + } else { + lines.push(`execute if score @s ${score} matches ${n}.. run scoreboard players set @s ${score} ${n - 1}`); + } + for (let i = 0; i < c.options.length; i++) { + const opt = c.options[i]; + const guard = `execute if score @s ${score} matches ${i} run `; + for (const cmd of opt.commands || []) lines.push(`${guard}${cmd}`); + for (const fn of opt.functions || []) lines.push(`${guard}function ${fn}`); + } + for (let i = 0; i < c.options.length; i++) { + const name = c.options[i].name || mkText(`Option ${i}`, { color: "yellow" }); + lines.push(`execute if score @s ${score} matches ${i} run ${tellrawLine(name)}`); + } + lines.push(`function ${menuFunctionPrefix(menu)}/fill`); + return lines; + } + if (w.condition != null) { const cond = w.condition; const fail = cond.fail_message || mkText("Condition failed.", { color: "red" }); @@ -1264,6 +1378,11 @@ const ok = `execute if entity @s[gamemode=${cond.gamemode}] run `; lines.push(...successBlock(ok)); lines.push(`execute unless entity @s[gamemode=${cond.gamemode}] run ${tellrawLine(fail)}`); + } else if (cond.type === "has_advancement") { + const adv = cond.advancement || ""; + const ok = `execute if entity @s[advancements={${adv}=true}] run `; + lines.push(...successBlock(ok)); + lines.push(`execute unless entity @s[advancements={${adv}=true}] run ${tellrawLine(fail)}`); } else { lines.push(...emitActions(w)); if (w.success_message) lines.push(tellrawLine(w.success_message)); @@ -1330,6 +1449,7 @@ if (w.toggle) scoresToInit.push(w.toggle.score); if (w.counter_score) scoresToInit.push(w.counter_score); if (w.progress_score) scoresToInit.push(w.progress_score); + if (w.cycle) scoresToInit.push(w.cycle.score); for (const sc of scoresToInit) { if (inited.has(sc)) continue; inited.add(sc); diff --git a/guigenmc/validate.py b/guigenmc/validate.py index d2b14fb..2fd552a 100644 --- a/guigenmc/validate.py +++ b/guigenmc/validate.py @@ -78,6 +78,16 @@ def validate_menu(menu: dict[str, Any]) -> list[str]: warnings.append( f'Widget "{resolved_action_id(w)}" has count={w.get("count")} (>64).' ) + if w["kind"] == "link" and not w.get("url"): + warnings.append( + f'Widget "{resolved_action_id(w)}" (link) has no url — click will do nothing useful.' + ) + if w["kind"] == "cycle": + opts = (w.get("cycle") or {}).get("options") or [] + if len(opts) < 2: + warnings.append( + f'Widget "{resolved_action_id(w)}" (cycle) needs at least 2 options.' + ) seen_action_ids: dict[str, list[dict[str, Any]]] = {} for w in interactive_widgets(menu): diff --git a/pyproject.toml b/pyproject.toml index 9625502..8ccf39e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "guigenmc" -version = "1.0.0" +version = "1.1.0" description = "JSON → Minecraft GUI datapack generator (CLI + web UI)" readme = "README.md" requires-python = ">=3.9"