#!/usr/bin/env python3 """The pack's own recipes, as KubeJS really applies them: read from the running test server. pack/kubejs/server_scripts are code (event.remove with filters, event.shaped, replaceInput, ...), so the guide does not parse them. Instead this boots the test server (build/server) once on a throwaway void world and runs `/kubejs export debug` (KubeJS 2101), which reloads the data and writes local/kubejs/export/: recipes//.json every recipe after the scripts ran; the ones a script added or changed carry "_kubejs_changed_marker": {source, line} removed_recipes//.json every recipe a script removed (from mod jars and data packs alike) registries/item.json the item registry, including items KubeJS startup scripts register The changed recipes, the removed ids and the item ids are copied to build/fieldguide-cache/kubejs-export/ with a hash of the inputs (pack/kubejs scripts and data, pack/mods, datapacks/core). tools/fieldguide/build.py runs this by itself when that hash no longer matches the pack. Usage: tools/fieldguide/kubejs_export.py [--accept-eula] (run tools/build.py first) """ import argparse import hashlib import json import os import shutil import sys import time from pathlib import Path sys.dont_write_bytecode = True sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) import mcserver # noqa: E402 ROOT = mcserver.ROOT SERVER = mcserver.SERVER EXPORT = SERVER / "local/kubejs/export" CACHE = ROOT / "build/fieldguide-cache/kubejs-export" LEVEL = "fieldguide-export" MARKER = "_kubejs_changed_marker" # Inputs that decide the recipe set KubeJS ends up with. Assets (textures, lang) do not. INPUTS = [ROOT / "pack/kubejs/server_scripts", ROOT / "pack/kubejs/startup_scripts", ROOT / "pack/kubejs/data", ROOT / "pack/mods", ROOT / "datapacks/core"] def input_hash(): h = hashlib.sha256() for base in INPUTS: for p in sorted(base.rglob("*")) if base.exists() else []: if p.is_file(): h.update(p.relative_to(ROOT).as_posix().encode() + b"\0" + p.read_bytes() + b"\0") return h.hexdigest() def load(): """The cached export, or None: {"recipes": {id: json}, "removed": [id], "items": [id], "stale": bool, ...}.""" path = CACHE / "export.json" if not path.exists(): return None data = json.loads(path.read_text()) data["stale"] = data.pop("inputs") != input_hash() return data def server_busy(): """True if some process is already running in build/server (another tool's test server).""" for proc in Path("/proc").glob("[0-9]*"): try: if Path(os.readlink(proc / "cwd")) == SERVER.resolve(): return True except OSError: continue return False def check_scripts(): """The server runs build/stage/full's copy of pack/kubejs; refuse to export an older one.""" differ = [] for sub in ("server_scripts", "startup_scripts", "data"): base = ROOT / "pack/kubejs" / sub for p in sorted(base.rglob("*")) if base.exists() else []: if p.is_file(): rel = p.relative_to(ROOT / "pack") served = SERVER / rel if not served.is_file() or served.read_bytes() != p.read_bytes(): differ.append(rel.as_posix()) if differ: raise SystemExit("build/server has an older copy of " + ", ".join(differ) + "; run tools/build.py first") def export(log=print): """Boot the test server, run the KubeJS export, keep the recipe part in the cache. Returns load().""" if not mcserver.eula_accepted(): raise SystemExit("build/server/eula.txt does not accept the Minecraft EULA yet " "(tools/selftest.py --accept-eula writes it if you accept it)") if server_busy(): raise SystemExit("a test server is already running in build/server; try again when it has stopped") inputs = input_hash() args_file = mcserver.prepare() check_scripts() shutil.rmtree(SERVER / LEVEL, ignore_errors=True) shutil.rmtree(EXPORT, ignore_errors=True) flat = '{"layers":[{"block":"minecraft:bedrock","height":1}],"biome":"minecraft:the_void"}' props = {"level-name": LEVEL, "level-type": "minecraft:flat", "generator-settings": flat, "generate-structures": "false", "spawn-monsters": "false", "spawn-animals": "false"} started = time.time() with mcserver.Server(args_file, props, stdout_name="fieldguide-export-stdout.log") as server: log(f"test server started in {server.wait_ready():.0f}s") reply = server.command("kubejs export debug").strip() if "exporting" not in reply.lower(): raise SystemExit(f"/kubejs export debug answered {reply!r}") _wait_for_export(server) server.stop() shutil.rmtree(SERVER / LEVEL, ignore_errors=True) recipes = {} for p in sorted((EXPORT / "recipes").rglob("*.json")): d = json.loads(p.read_text()) if MARKER in d: recipes[_id(p, "recipes")] = d removed = sorted(_id(p, "removed_recipes") for p in (EXPORT / "removed_recipes").rglob("*.json")) items = sorted(json.loads((EXPORT / "registries/item.json").read_text())) kubejs = next((p.name for p in sorted((SERVER / "mods").glob("kubejs-*.jar"))), None) CACHE.mkdir(parents=True, exist_ok=True) (CACHE / "export.json").write_text(json.dumps({ "inputs": inputs, "kubejs": kubejs, "exported": time.strftime("%Y-%m-%d %H:%M"), "removed": removed, "recipes": recipes, "items": items}, indent=1) + "\n") log(f"KubeJS recipes: {len(recipes)} added or changed, {len(removed)} removed ({time.time() - started:.0f}s)") return load() def _id(path, sub): """recipes/cobblemon/foo/bar.json -> cobblemon:foo/bar""" return path.relative_to(EXPORT / sub).as_posix()[:-5].replace("/", ":", 1) def _wait_for_export(server, timeout=300): """The export reloads first and writes the files afterwards; wait for index.json and every file it lists (the index names them by id, cobblemon:foo.json, and the file is cobblemon/foo.json).""" deadline = time.time() + timeout while time.time() < deadline: try: listed = json.loads((EXPORT / "index.json").read_text()) if all((EXPORT / f.replace(":", "/")).exists() for f in listed): return except (OSError, ValueError): pass if server.proc.poll() is not None: raise SystemExit(f"the test server stopped during the export; see {server.log}") time.sleep(1) raise SystemExit(f"/kubejs export debug wrote nothing in {timeout}s; see {server.log}") def main(): ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) ap.add_argument("--accept-eula", action="store_true", help="write eula=true for the test server") args = ap.parse_args() if args.accept_eula: (SERVER / "eula.txt").parent.mkdir(parents=True, exist_ok=True) (SERVER / "eula.txt").write_text("eula=true\n") data = export() for rid in data["removed"]: print(f" - {rid}") for rid, d in sorted(data["recipes"].items()): print(f" + {rid} ({d[MARKER].get('source')}:{d[MARKER].get('line')})") print(f"Wrote {CACHE / 'export.json'}") if __name__ == "__main__": main()