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>
This commit is contained in:
1 parent
0741c84077
commit
4083791944
133 files changed
+8350
-3273
No files matched your search
Executable
+155
@@ -0,0 +1,155 @@
|
||||
#!/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()
|
||||
Reference in new issue
Block a user