Files
claudeandClaude Opus 5.5 4083791944 Phase 9: reproducible builds, balance from the server, spark check, play feedback
Build: every tool pinned (JDK, packwiz, Gradle plugins, Python packages), exports
normalised, tools/check_reproducible.py proves two clean builds are byte-identical.

Server-side balance: a server can load config/cobblesurvival/datapack.zip in place of
the jar's data, /reload applies it, tools/server_update.py packages it and refuses
when a client change is needed. JEI drop pages are sent by the server. Code-held
balance numbers moved to tuning/server.json.

Performance: spark in the pack (server only), tools/perf_test.py with fake players
whose chunks really are active, docs/performance.md.

Adrian's feedback: default keys R, U, J, §; drop pages link to Cobblemon Info and show
real chances; bosses only hurt in battles; battle XP halved; channels between the
islands low level; temperature ×3 with insulation doubled; hazards start at once; no
item dots on the map; a wild attack starts a battle; the starter only faints; quest
book on join with a basics page and an HMs page; regional forms obtainable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 16:26:30 +01:00

156 lines
7.6 KiB
Python
Executable File

#!/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-<pack version>.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()