#!/usr/bin/env python3 """Package a balance change for a running server, so players keep the client they have (Adrian, 2026-11-09). Everything the server decides goes in: the core data pack as one file (config/cobblesurvival/datapack.zip, which the companion mod loads instead of the copy in its jar), the mods' configs, KubeJS's server scripts and the quest book. The server sends what clients need of these when they join. Writes build/server-update-.zip; unzip it into the server's folder. --install puts the files straight into a server folder on this machine and removes what the pack no longer has. What can't be done this way is any change clients run or draw: mods, textures, lang, startup scripts, new items or blocks, the companion mod's client code. This compares those files with the last client pack handed out (tools/client-baseline.json) and refuses when they differ; hand out a new pack, then record it with --mark-client-release. Usage: tools/server_update.py [--install SERVER_DIR] [--level WORLD_FOLDER] [--with-jar] [--force] tools/server_update.py --mark-client-release """ import argparse import fnmatch import json import re import shutil import subprocess import sys import tomllib import zipfile from io import BytesIO from pathlib import Path import lock ROOT = lock.ROOT PACK = ROOT / "pack" BASELINE = ROOT / "tools/client-baseline.json" # Fixed so the same data gives the same zip. ZIP_DATE = (1980, 1, 1, 0, 0, 0) # What the server owns under pack/; the rest of the pack only matters on a client. SERVER_FILES = ["config/**", "kubejs/server_scripts/**"] CLIENT_ONLY = ["config/defaultoptions/**", "config/xaero/**"] # What a client runs or draws. CLIENT_INPUTS = ["pack/shaderpacks/*", "pack/config/defaultoptions/**", "pack/config/xaero/**", "pack/kubejs/assets/**", "pack/kubejs/startup_scripts/**", "pack/kubejs/client_scripts/**", "mod/src/main/resources/assets/**", "mod/src/main/resources/META-INF/**", "mod/src/main/resources/*.mixins.json", "mod/gradle.properties"] # Companion mod sources that run on clients or define what both sides must agree on (registries, packets). CLIENT_CODE = re.compile(r"net\.minecraft\.client|mezz\.jei|ItemTooltipEvent|animateTick|DeferredRegister|Registry\.register|" r"RegisterPayloadHandlersEvent|StreamCodec|@Mixin") def tree(root, pattern): return sorted(p for p in root.glob(pattern) if p.is_file()) def client_files(): files = [p for p in tree(ROOT, "pack/mods/*.pw.toml") if tomllib.loads(p.read_text()).get("side") != "server"] for pattern in CLIENT_INPUTS: files += tree(ROOT, pattern) for source in [*tree(ROOT, "mod/src/main/kotlin/**/*.kt"), *tree(ROOT, "mod/src/main/java/**/*.java")]: if "/selftest/" not in source.as_posix() and CLIENT_CODE.search(source.read_text()): files.append(source) return {p.relative_to(ROOT).as_posix(): lock.digest(p) for p in files} def client_changes(): old = json.loads(BASELINE.read_text()) if BASELINE.exists() else {} new = client_files() return sorted(name for name in old.keys() | new.keys() if old.get(name) != new.get(name)) def zip_bytes(files): """{name in zip: bytes} as a zip that only depends on its contents.""" out = BytesIO() with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z: for name in sorted(files): info = zipfile.ZipInfo(name, ZIP_DATE) info.compress_type = zipfile.ZIP_DEFLATED info.external_attr = 0o644 << 16 z.writestr(info, files[name]) return out.getvalue() def update_files(level, with_jar): core = ROOT / "datapacks/core" data = {p.relative_to(core).as_posix(): p.read_bytes() for p in tree(core, "**/*")} files = {"config/cobblesurvival/datapack.zip": zip_bytes(data)} for pattern in SERVER_FILES: for path in tree(PACK, pattern): name = path.relative_to(PACK).as_posix() if not any(fnmatch.fnmatch(name, skip) for skip in CLIENT_ONLY): files[name] = path.read_bytes() # defaultconfigs only reach new worlds; a running server reads its world's serverconfig. for path in tree(PACK / "defaultconfigs", "**/*"): relative = path.relative_to(PACK / "defaultconfigs").as_posix() files[f"defaultconfigs/{relative}"] = path.read_bytes() files[f"{level}/serverconfig/{relative}"] = path.read_bytes() if with_jar: props = dict(line.split("=", 1) for line in (ROOT / "mod/gradle.properties").read_text().splitlines() if "=" in line and not line.startswith("#")) jar = ROOT / "mod/build/libs" / f"{props['mod_id']}-{props['mod_version']}.jar" if not jar.exists(): sys.exit(f"{jar} is missing: run tools/build.py first") files[f"mods/{jar.name}"] = jar.read_bytes() return files def install(files, server): if not (server / "mods").is_dir(): sys.exit(f"{server} has no mods folder, so it doesn't look like the server") # The folders this owns entirely, so what left the pack leaves the server too. shutil.rmtree(server / "kubejs/server_scripts", ignore_errors=True) shutil.rmtree(server / "config/ftbquests/quests", ignore_errors=True) for name, content in files.items(): target = server / name target.parent.mkdir(parents=True, exist_ok=True) part = target.with_name(target.name + ".part") part.write_bytes(content) part.replace(target) # the running server can be reading the old file def main(): lock.enter_venv() parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--install", type=Path, help="put the update into this server folder") parser.add_argument("--level", default="world", help="the server's world folder (level-name), default world") parser.add_argument("--with-jar", action="store_true", help="also update the server's companion mod (code that only the server runs)") parser.add_argument("--force", action="store_true", help="build it although clients would need a new pack too") parser.add_argument("--mark-client-release", action="store_true", help="record the current client files as handed out to players") args = parser.parse_args() if args.mark_client_release: BASELINE.write_text(json.dumps(client_files(), indent=1, sort_keys=True) + "\n") print(f"Recorded {len(client_files())} client files in {BASELINE.relative_to(ROOT)}") return changed = client_changes() if changed and not args.force: print("Players would need a new client pack for these changes, so a server update isn't enough:", file=sys.stderr) print("".join(f" {name}\n" for name in changed), file=sys.stderr, end="") sys.exit("Hand out a new pack and run --mark-client-release, or pass --force if you know these are harmless.") subprocess.run([sys.executable, str(ROOT / "tools/jei_sources.py")], check=True) subprocess.run([sys.executable, str(ROOT / "tools/quests.py")], check=True) files = update_files(args.level, args.with_jar) version = tomllib.loads((PACK / "pack.toml").read_text())["version"] out = ROOT / f"build/server-update-{version}.zip" out.parent.mkdir(exist_ok=True) out.write_bytes(zip_bytes(files)) print(f"{len(files)} files in {out.relative_to(ROOT)}") if args.install: install(files, args.install) print(f"Installed into {args.install}") print("Then on the server: /reload (data pack, recipes), /ftbquests reload (quest book); changed mod configs need a restart.") print("/cobblesurvival data shows what is live.") if __name__ == "__main__": main()