Files
claudeandClaude Opus 5.5 398301f0be Cobblemon Survival Pack: phases 0-2 and Phase 3 zones and hazards
Free NeoForge 1.21.1 modpack on Cobblemon 1.8.4 built with packwiz, plus
the Kotlin companion mod (cobblesurvival) and its built-in data pack.

- Phase 0: packwiz pack with Modrinth/CurseForge cross-links, build and
  export tooling, Prism instance sync, test server with smoke checks.
- Phase 1: Poke Ball shaped island (WorldGen Editor preset, seed isle-26),
  Adrian's biome list, no natural ore, structures without ore or vanilla
  mobs, no Nether portals, world template builder and world report.
- Phase 2: permadeath (CobbleHardcoreMon plus the companion mod's gap
  fixes), Revival Charm, expensive Healing Machine, Lootr refreshing
  loot, in-game self-tests on Minecraft's GameTest framework.
- Phase 3 so far: 14 zones with their own spawn pools and level bands
  (tools/zone_layout.py, tools/zone_spawns.py, "zone" spawn condition),
  Cold Sweat heat and cold as gates with boss-drop insulation recipes,
  spawn survey and hazard self-tests in tools/quick_check.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 19:01:21 +01:00

169 lines
7.3 KiB
Python

#!/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/<ns>/<path>.json every recipe after the scripts ran; the ones a script added or
changed carry "_kubejs_changed_marker": {source, line}
removed_recipes/<ns>/<path>.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()