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
@@ -0,0 +1,26 @@
|
||||
# Every tool the pack's build runs, pinned. tools/lock.py checks each download against its hash and refuses a mismatch.
|
||||
# Mods are pinned by hash in pack/mods/*.pw.toml, Python packages in tools/requirements.txt, Gradle's in mod/.
|
||||
python = "3.14"
|
||||
|
||||
[packwiz]
|
||||
module = "github.com/packwiz/packwiz"
|
||||
version = "v0.0.0-20261014010756-170b22d78595"
|
||||
go = "go1.25.1"
|
||||
|
||||
[jdk]
|
||||
version = "21.0.7+6"
|
||||
platform = "linux-x86_64"
|
||||
url = "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.7%2B6/OpenJDK21U-jdk_x64_linux_hotspot_21.0.7_6.tar.gz"
|
||||
sha256 = "974d3acef0b7193f541acb61b76e81670890551366625d4f6ca01b91ac152ce0"
|
||||
|
||||
[downloads."packwiz-installer-0.5.14.jar"]
|
||||
url = "https://github.com/packwiz/packwiz-installer/releases/download/v0.5.14/packwiz-installer.jar"
|
||||
sha256 = "c9f646908d340d84773948a9a7d98bc1dae250d35e1016dc6e2b8459760b5598"
|
||||
|
||||
[downloads."packwiz-installer-bootstrap-0.0.3.jar"]
|
||||
url = "https://github.com/packwiz/packwiz-installer-bootstrap/releases/download/v0.0.3/packwiz-installer-bootstrap.jar"
|
||||
sha256 = "a8fbb24dc604278e97f4688e82d3d91a318b98efc08d5dbfcbcbcab6443d116c"
|
||||
|
||||
[downloads."neoforge-21.1.265-installer.jar"]
|
||||
url = "https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.265/neoforge-21.1.265-installer.jar"
|
||||
sha256 = "a547ccc30a80a675d1c61d04b55d10c45c39a86971e09731482c50a24399d1a2"
|
||||
+63
-16
@@ -6,6 +6,11 @@ Outputs, all under build/:
|
||||
instance and the server smoke test install this
|
||||
<slug>-<ver>.mrpack Modrinth pack
|
||||
<slug>-<ver>-curseforge.zip CurseForge pack
|
||||
SHA256SUMS what the build produced
|
||||
|
||||
Reproducible: the same commit gives the same bytes, whatever the machine has
|
||||
installed and wherever the repo is. Every tool is pinned (tools/lock.py), and
|
||||
tools/check_reproducible.py proves it with two builds from clean copies.
|
||||
|
||||
Each platform export only lists mods that platform hosts. A mod hosted on just
|
||||
one platform is left out of the other export and reported; it is never copied
|
||||
@@ -15,6 +20,7 @@ companion mod is our own work, so it is the one jar allowed in overrides/.
|
||||
Usage: tools/build.py [--skip-mod]
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -23,6 +29,8 @@ import tomllib
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import lock
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PACK = ROOT / "pack"
|
||||
BUILD = ROOT / "build"
|
||||
@@ -36,28 +44,37 @@ def run(cmd, cwd):
|
||||
|
||||
|
||||
def packwiz(*args, cwd):
|
||||
run([shutil.which("packwiz") or "packwiz", *args, "-y"], cwd)
|
||||
run([lock.packwiz(), *args, "-y"], cwd)
|
||||
|
||||
|
||||
def java_home():
|
||||
"""Gradle 8.14 cannot run on Java 25, so prefer an explicit Java 21."""
|
||||
for candidate in (os.environ.get("JAVA21_HOME"), os.environ.get("JAVA_HOME"),
|
||||
Path.home() / ".var/app/org.prismlauncher.PrismLauncher/data/PrismLauncher/java/java-runtime-delta"):
|
||||
if candidate and (Path(candidate) / "bin" / "java").exists():
|
||||
return str(candidate)
|
||||
return None
|
||||
def gradle_properties():
|
||||
return dict(line.split("=", 1) for line in (ROOT / "mod/gradle.properties").read_text().splitlines()
|
||||
if "=" in line and not line.startswith("#"))
|
||||
|
||||
|
||||
def check_versions():
|
||||
"""mod/gradle.properties compiles against the versions the pack installs."""
|
||||
props = gradle_properties()
|
||||
if props["neoforge_version"] != tomllib.loads((PACK / "pack.toml").read_text())["versions"]["neoforge"]:
|
||||
sys.exit("mod/gradle.properties and pack/pack.toml name different NeoForge versions")
|
||||
for slug, prop in (("cobblemon", "cobblemon_version"), ("kotlin-for-forge", "kotlinforforge_version"), ("jei", "jei_version")):
|
||||
filename = tomllib.loads((PACK / f"mods/{slug}.pw.toml").read_text())["filename"]
|
||||
if props[prop] not in filename:
|
||||
sys.exit(f"mod/gradle.properties says {prop}={props[prop]}, the pack installs {filename}")
|
||||
|
||||
|
||||
def build_mod():
|
||||
env = dict(os.environ)
|
||||
if home := java_home():
|
||||
env["JAVA_HOME"] = home
|
||||
subprocess.run(["./gradlew", "build", "--console=plain"], cwd=ROOT / "mod", env=env, check=True)
|
||||
"""From clean, with only the pinned JDK to compile against (an incremental build can differ from a full one)."""
|
||||
jdk = lock.jdk()
|
||||
subprocess.run(["./gradlew", "clean", "build", "--console=plain", "--no-build-cache",
|
||||
f"-Porg.gradle.java.installations.paths={jdk}",
|
||||
"-Porg.gradle.java.installations.auto-detect=false",
|
||||
"-Porg.gradle.java.installations.auto-download=false"],
|
||||
cwd=ROOT / "mod", env=os.environ | {"JAVA_HOME": str(jdk)}, check=True)
|
||||
|
||||
|
||||
def companion_jar():
|
||||
props = dict(line.split("=", 1) for line in (ROOT / "mod/gradle.properties").read_text().splitlines()
|
||||
if "=" in line and not line.startswith("#"))
|
||||
props = gradle_properties()
|
||||
jar = ROOT / "mod/build/libs" / f"{props['mod_id']}-{props['mod_version']}.jar"
|
||||
if not jar.exists():
|
||||
sys.exit(f"companion jar not found: {jar} (run without --skip-mod)")
|
||||
@@ -105,16 +122,44 @@ def check_default_shader():
|
||||
sys.exit(f"config/defaultoptions/extra/config/iris.properties names {named}, the pack has {files}")
|
||||
|
||||
|
||||
def normalise_export(archive):
|
||||
"""packwiz writes an export's entries, the manifest's file list and the mod list in a random order: sort them."""
|
||||
with zipfile.ZipFile(archive) as z:
|
||||
files = {i.filename: z.read(i.filename) for i in z.infolist() if not i.is_dir()}
|
||||
if "manifest.json" in files:
|
||||
manifest = json.loads(files["manifest.json"])
|
||||
manifest["files"].sort(key=lambda f: (f["projectID"], f["fileID"]))
|
||||
files["manifest.json"] = (json.dumps(manifest, indent=2, ensure_ascii=False) + "\n").encode()
|
||||
if "modlist.html" in files:
|
||||
lines = files["modlist.html"].decode().splitlines()
|
||||
items = sorted(line for line in lines if line.lstrip().startswith("<li>"))
|
||||
files["modlist.html"] = "\n".join(["<ul>", *items, "</ul>", ""]).encode()
|
||||
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for name in sorted(files):
|
||||
info = zipfile.ZipInfo(name, (1980, 1, 1, 0, 0, 0))
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.external_attr = 0o644 << 16
|
||||
z.writestr(info, files[name])
|
||||
|
||||
|
||||
def write_checksums(paths):
|
||||
(BUILD / "SHA256SUMS").write_text("".join(f"{lock.digest(p)} {p.relative_to(ROOT).as_posix()}\n" for p in sorted(paths)))
|
||||
|
||||
|
||||
def main():
|
||||
lock.enter_venv()
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--skip-mod", action="store_true", help="reuse the last companion mod build")
|
||||
args = parser.parse_args()
|
||||
|
||||
check_versions()
|
||||
check_default_shader()
|
||||
# Written from the pack's data, so these go first: the drop pages ship in the data pack the mod embeds.
|
||||
run([sys.executable, str(ROOT / "tools/jei_sources.py")], ROOT)
|
||||
run([sys.executable, str(ROOT / "tools/quests.py")], ROOT) # the quest book (Phase 8) from docs/quests.yaml
|
||||
if not args.skip_mod:
|
||||
build_mod()
|
||||
jar = companion_jar()
|
||||
check_default_shader()
|
||||
run([sys.executable, str(ROOT / "tools/quests.py")], ROOT) # the quest book (Phase 8) from docs/quests.yaml
|
||||
packwiz("refresh", cwd=PACK)
|
||||
pack = tomllib.loads((PACK / "pack.toml").read_text())
|
||||
base = f"{pack['name'].lower().replace(' ', '-')}-{pack['version']}"
|
||||
@@ -127,9 +172,11 @@ def main():
|
||||
out = BUILD / f"{base}{suffix}"
|
||||
out.unlink(missing_ok=True)
|
||||
packwiz(command, "export", "-o", str(out), cwd=dest)
|
||||
normalise_export(out)
|
||||
check_overrides(out, jar)
|
||||
report[out.name] = left_out
|
||||
|
||||
write_checksums([jar, BUILD / "stage/full/pack.toml", BUILD / "stage/full/index.toml", *(BUILD / name for name in report)])
|
||||
print("\nExports (no third-party jars in overrides):")
|
||||
for name, left_out in report.items():
|
||||
print(f" build/{name}")
|
||||
|
||||
@@ -20,6 +20,8 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DEFAULTS = ROOT / "pack/config/defaultoptions/keybindings.txt"
|
||||
# Share a key on purpose (Adrian, 2026-11-09): JEI's only works in a screen, Cobblemon's sends out a Pokémon in the world.
|
||||
BY_DESIGN = [{"key.jei.showRecipe", "key.cobblemon.throwpartypokemon"}]
|
||||
INSTANCE = (Path.home() / ".var/app/org.prismlauncher.PrismLauncher/data/PrismLauncher/instances"
|
||||
/ "Cobblemon Survival Pack (dev)/minecraft/options.txt")
|
||||
|
||||
@@ -52,6 +54,8 @@ def main():
|
||||
def clashes(entries):
|
||||
# One mod's binds share a context, so distinct modifiers keep them apart (e.g. JEI's
|
||||
# Page Down and Shift+Page Down). Anything else on the same key shows as a conflict.
|
||||
if {name for name, _ in entries} in BY_DESIGN:
|
||||
return False
|
||||
mods = {name.rsplit(".", 1)[0] for name, _ in entries}
|
||||
return len(entries) > 1 and not (len(mods) == 1 and len({m for _, m in entries}) == len(entries))
|
||||
|
||||
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prove the build reproducible: build the working tree twice from clean copies in different folders, compare the bytes.
|
||||
|
||||
Copies every tracked and untracked file (not what .gitignore hides) to build/reproducible/a and build/reproducible/the/other/b,
|
||||
so a leaked path shows up. Each build gets its own Gradle home with --fresh-gradle (slower: Loom sets up Minecraft
|
||||
twice); the pinned tools are shared, since they're checked by hash. On a mismatch it names the jar or pack entries that differ.
|
||||
|
||||
Usage: tools/check_reproducible.py [--fresh-gradle]
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import lock
|
||||
|
||||
ROOT = lock.ROOT
|
||||
WORK = ROOT / "build/reproducible"
|
||||
|
||||
|
||||
def build(copy, fresh_gradle):
|
||||
shutil.rmtree(copy, ignore_errors=True)
|
||||
files = subprocess.run(["git", "ls-files", "-co", "--exclude-standard", "-z"], cwd=ROOT, check=True,
|
||||
capture_output=True, text=True).stdout.split("\0")
|
||||
for name in filter(None, files):
|
||||
if (ROOT / name).is_file():
|
||||
target = copy / name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(ROOT / name, target)
|
||||
env = os.environ | {"COBBLESURVIVAL_CACHE": str(lock.CACHE)}
|
||||
if fresh_gradle:
|
||||
env["GRADLE_USER_HOME"] = str(copy.parent / f"{copy.name}-gradle")
|
||||
with open(copy.parent / f"{copy.name}.log", "w") as log:
|
||||
subprocess.run([sys.executable, str(copy / "tools/build.py")], cwd=copy, env=env, stdout=log, stderr=subprocess.STDOUT, check=True)
|
||||
return dict(reversed(line.split(" ", 1)) for line in (copy / "build/SHA256SUMS").read_text().splitlines())
|
||||
|
||||
|
||||
def entries(path):
|
||||
with zipfile.ZipFile(path) as z:
|
||||
return {i.filename: (i.CRC, i.date_time, i.external_attr) for i in z.infolist()}
|
||||
|
||||
|
||||
def explain(a, b, name):
|
||||
if not name.endswith((".jar", ".mrpack", ".zip")):
|
||||
return
|
||||
ea, eb = entries(a / name), entries(b / name)
|
||||
for entry in sorted(ea.keys() | eb.keys()):
|
||||
if ea.get(entry) != eb.get(entry):
|
||||
print(f" {entry}: {ea.get(entry)} / {eb.get(entry)}")
|
||||
if list(ea) != list(eb):
|
||||
print(" entries are in a different order")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--fresh-gradle", action="store_true", help="give each build its own empty Gradle home")
|
||||
args = parser.parse_args()
|
||||
a, b = WORK / "a", WORK / "the/other/b"
|
||||
first, second = build(a, args.fresh_gradle), build(b, args.fresh_gradle)
|
||||
different = [name for name in sorted(first.keys() | second.keys()) if first.get(name) != second.get(name)]
|
||||
for name in sorted(first):
|
||||
print(f"{'DIFFERENT' if name in different else 'same '} {first[name][:16]} {name}")
|
||||
for name in different:
|
||||
print(f" {name}:")
|
||||
explain(a, b, name)
|
||||
if different:
|
||||
sys.exit(f"{len(different)} of {len(first)} outputs differ")
|
||||
print(f"All {len(first)} outputs are byte-identical.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check on the test server that a balance change goes live with tools/server_update.py alone (Adrian, 2026-11-09):
|
||||
the server loads the update's data instead of the copy in its jar, `/reload` picks up a changed file while it runs,
|
||||
and without the update the jar's copy is used again.
|
||||
|
||||
Usage: tools/check_server_update.py (needs eula.txt from tools/selftest.py --accept-eula)
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
from mcserver import ROOT, SERVER, Server, eula_accepted, prepare
|
||||
|
||||
ZIP = SERVER / "config/cobblesurvival/datapack.zip"
|
||||
TUNING = "data/cobblesurvival/tuning/server.json"
|
||||
LEVEL = "world-update-check"
|
||||
FLAT = {"level-name": LEVEL, "level-type": "minecraft:flat",
|
||||
"generator-settings": '{"layers":[{"block":"minecraft:stone","height":4}],"biome":"minecraft:plains"}'}
|
||||
|
||||
|
||||
def expect(line, *parts):
|
||||
missing = [p for p in parts if p not in line]
|
||||
if missing:
|
||||
sys.exit(f"expected {missing} in: {line}")
|
||||
print(f"ok: {line.strip()}")
|
||||
|
||||
|
||||
def set_boss_evs(value):
|
||||
with zipfile.ZipFile(ZIP) as z:
|
||||
items = [(i, z.read(i.filename)) for i in z.infolist()]
|
||||
out = BytesIO()
|
||||
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for info, content in items:
|
||||
if info.filename == TUNING:
|
||||
content = json.dumps(json.loads(content) | {"boss_evs": value}).encode()
|
||||
z.writestr(info, content)
|
||||
part = ZIP.with_name(ZIP.name + ".part")
|
||||
part.write_bytes(out.getvalue())
|
||||
part.replace(ZIP) # as an update does, with the server running
|
||||
|
||||
|
||||
def main():
|
||||
if not eula_accepted():
|
||||
sys.exit("build/server/eula.txt does not accept the Minecraft EULA yet (see tools/selftest.py --accept-eula).")
|
||||
args_file = prepare()
|
||||
subprocess.run([sys.executable, str(ROOT / "tools/server_update.py"), "--install", str(SERVER), "--level", LEVEL, "--force"], check=True)
|
||||
try:
|
||||
with Server(args_file, FLAT, stdout_name="update-check-stdout.log") as server:
|
||||
server.wait_ready()
|
||||
first = server.command("cobblesurvival data")
|
||||
expect(first, "data server;", "boss_evs 170;")
|
||||
set_boss_evs(150)
|
||||
server.command("reload")
|
||||
for _ in range(60):
|
||||
time.sleep(2)
|
||||
line = server.command("cobblesurvival data")
|
||||
if "boss_evs 150" in line:
|
||||
break
|
||||
expect(line, "data server;", "boss_evs 150;")
|
||||
server.stop()
|
||||
ZIP.unlink()
|
||||
with Server(args_file, FLAT, stdout_name="update-check-stdout.log") as server:
|
||||
server.wait_ready()
|
||||
expect(server.command("cobblesurvival data"), "data jar;", "boss_evs 170;")
|
||||
server.stop()
|
||||
finally:
|
||||
ZIP.unlink(missing_ok=True)
|
||||
print("SERVER UPDATE CHECK PASSED")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"mod/gradle.properties": "981e881361121f4109d87b3292eadec054fd73945e24249a29134dcc174045c1",
|
||||
"mod/src/main/java/dev/cobblesurvival/mixin/HardcorePartyMixin.java": "7dc3158882fa6baf04409493505d5e91f56c6e112f69bd9f095772a72b31e6e4",
|
||||
"mod/src/main/java/dev/cobblesurvival/mixin/RegeneratorBlockMixin.java": "84706a42b2a6d5359993831e17f886680decb76e027c451c64463d009e36acae",
|
||||
"mod/src/main/kotlin/dev/cobblesurvival/jei/DropSources.kt": "9437c1dac38c1c236067d661379b02b04657cee231ccdf4035183c6639c2e0f9",
|
||||
"mod/src/main/kotlin/dev/cobblesurvival/jei/DropSourcesJei.kt": "89e7e6dc3b8ef4aac3e095887c63ceb95f3214270e2d91e0322074c09f41ac55",
|
||||
"mod/src/main/kotlin/dev/cobblesurvival/machine/MachineTooltips.kt": "cadf0f01673700779f0f893986a03d0030ce057084b95fe36aa341a3ed5b326b",
|
||||
"mod/src/main/kotlin/dev/cobblesurvival/machine/Machines.kt": "9c468e5578aa4d357c8e09c622d22d6f1a69c2dcf065b32baf13ac1c64845225",
|
||||
"mod/src/main/kotlin/dev/cobblesurvival/summon/Summons.kt": "2df4a11b33a36d63111d2e92595fb01143ae9917dc82ebee5a682d5bdd64727e",
|
||||
"mod/src/main/kotlin/dev/cobblesurvival/zone/InZoneCondition.kt": "20ab5c4266fc603bc08e66f27dd9685dc96c0d1b2d61b089e6d61960e89a7e7f",
|
||||
"mod/src/main/resources/META-INF/neoforge.mods.toml": "6c1a320fb16ce37c7dfb9faf0525ec48880f7b4480ce24f30630599846e61730",
|
||||
"mod/src/main/resources/assets/cobblesurvival/blockstates/altar_core.json": "57b74639738a3576d15063af1848624ec5b031fdc1837033cff0400efcd2f706",
|
||||
"mod/src/main/resources/assets/cobblesurvival/blockstates/dynamo_core.json": "9aa37f891c2ca32d66be875ac18b9ab2cc58d5c7ed8a043c9f3978892a455588",
|
||||
"mod/src/main/resources/assets/cobblesurvival/blockstates/heat_core.json": "292a281a1566be3ccf0e296bb5615c821aed154fdfac396f84cef87084fdc80d",
|
||||
"mod/src/main/resources/assets/cobblesurvival/blockstates/rebirth_core.json": "079141fc87870499fd3ee783b7185cc3aba70101b8431bc17f2dc7d3afcff6af",
|
||||
"mod/src/main/resources/assets/cobblesurvival/blockstates/synthesis_core.json": "f42e0504367e9cea748fe6f2b470cb42d7c3035239f5e1179dcdafd9eb074612",
|
||||
"mod/src/main/resources/assets/cobblesurvival/lang/en_us.json": "cddf978aef708e01207414563c30bda7d1c21db38f039804bfbb0ac29953ea8b",
|
||||
"mod/src/main/resources/assets/cobblesurvival/models/block/altar_core.json": "da7bdfa2847aeda52989f59654d5eca2ae8905083cb434819068dbe91ff14134",
|
||||
"mod/src/main/resources/assets/cobblesurvival/models/block/altar_core_on.json": "f63d9474e7eb2215d72727bd236ed10e2624e018d9f7d744d8b5ec2791761bfd",
|
||||
"mod/src/main/resources/assets/cobblesurvival/models/block/dynamo_core.json": "5e54173692d983cede7c3f15f33199e602e431a8d813e020efb44dbff7918c88",
|
||||
"mod/src/main/resources/assets/cobblesurvival/models/block/dynamo_core_on.json": "5a58d14ffb75656c988ab0f862861c7bf25816099f34fc53c72cfc15d03b29c3",
|
||||
"mod/src/main/resources/assets/cobblesurvival/models/block/heat_core.json": "b0787387105981ffcd5db139a0396e185e846a366c365cf875dc1b3facea53b5",
|
||||
"mod/src/main/resources/assets/cobblesurvival/models/block/heat_core_on.json": "c40bf57cee6b7bf94bc5bd60bf00bfc48953aa1b3492b5a324e9c159be2238bc",
|
||||
"mod/src/main/resources/assets/cobblesurvival/models/block/rebirth_core.json": "e190fd0ead67f4bf812a20e14dcd2d831feffe4fb290142c16a5d5c64e7dd505",
|
||||
"mod/src/main/resources/assets/cobblesurvival/models/block/rebirth_core_on.json": "d4c96795b8237d70b1e7e9cee7644ea8006a44622d3b6a03eb12d6b9dd317607",
|
||||
"mod/src/main/resources/assets/cobblesurvival/models/block/synthesis_core.json": "9bf74b06602ed26979fc24bab4cea638f41e62942ed06f22ea1245309ff94632",
|
||||
"mod/src/main/resources/assets/cobblesurvival/models/block/synthesis_core_on.json": "c93d8f9d0bd7edc8787f27851127e6f901b35781393ddea4630046a1514562a4",
|
||||
"mod/src/main/resources/assets/cobblesurvival/models/item/altar_core.json": "93e215f9216fa98c40e94e4e43ea6b6a4134330f64463c86269f97527cfe6305",
|
||||
"mod/src/main/resources/assets/cobblesurvival/models/item/dynamo_core.json": "361f79c2482a693942365018a5c25aa92776a00ce3040d0cf9788ef3f5369f2d",
|
||||
"mod/src/main/resources/assets/cobblesurvival/models/item/heat_core.json": "95501c83502ad086d65b8e2c8cd92f291af86cb1cd9d472c924c532bed935b0a",
|
||||
"mod/src/main/resources/assets/cobblesurvival/models/item/rebirth_core.json": "fc7b8a1c542443cf4cc8865245f18bceaa9b5600d159cc5a1b52f8b583331f5b",
|
||||
"mod/src/main/resources/assets/cobblesurvival/models/item/summon_sigil.json": "49030496139683753061379efc58921f22c4a0aa6c83bb63a076dd5a66025328",
|
||||
"mod/src/main/resources/assets/cobblesurvival/models/item/synthesis_core.json": "561a5991627f1191b3f3dc4bfb8bc6a87476ecb620ba17b212fdd89245713232",
|
||||
"mod/src/main/resources/assets/cobblesurvival/textures/block/altar_core_front.png": "a16647d24710eee6bf68d06ea4a1259dc5bbb8fd3fb1dc9d3f3f43258997caa5",
|
||||
"mod/src/main/resources/assets/cobblesurvival/textures/block/altar_core_front_on.png": "463556ab8451cc5dc320772c192821ff6f35784e2b9f78720baddd6256602808",
|
||||
"mod/src/main/resources/assets/cobblesurvival/textures/block/altar_core_front_on.png.mcmeta": "eff55807164fe4f0db2f60ab8a75f03b75489abb0a3c89598e84a65db429ddb3",
|
||||
"mod/src/main/resources/assets/cobblesurvival/textures/block/altar_core_side.png": "7e551e598fcec62ed041076d13f792b05d01f7e6c64998cd5e36e9957bf6b1cb",
|
||||
"mod/src/main/resources/assets/cobblesurvival/textures/block/altar_core_top.png": "b78f1ac851f903c73a20cac25a566b973f3984e9c4a06c16d4eaf0545722f9a7",
|
||||
"mod/src/main/resources/assets/cobblesurvival/textures/block/dynamo_core_front.png": "0dbd458fa47da1531ddd182c11d7cc4c0a077267fa9d89637fa63dc5a6aaa608",
|
||||
"mod/src/main/resources/assets/cobblesurvival/textures/block/dynamo_core_front_on.png": "e6ad3abce9474434577f132ad80a15cbe162ed151c663cc92244ab66ae8d5d7c",
|
||||
"mod/src/main/resources/assets/cobblesurvival/textures/block/dynamo_core_front_on.png.mcmeta": "eff55807164fe4f0db2f60ab8a75f03b75489abb0a3c89598e84a65db429ddb3",
|
||||
"mod/src/main/resources/assets/cobblesurvival/textures/block/dynamo_core_side.png": "8eeaa40b87fe284f13a7d58c0b7cbf2dd1d77c2276300ff549413c12be715690",
|
||||
"mod/src/main/resources/assets/cobblesurvival/textures/block/dynamo_core_top.png": "e94049677a8136d3c8597cb4aeea856521cb8c7fd5793ef9c156d220a5df6603",
|
||||
"mod/src/main/resources/assets/cobblesurvival/textures/block/heat_core_front.png": "38e46b92c05861468adc8c100fc788525c4e3d6ee3dd200660e8a1cff1c12203",
|
||||
"mod/src/main/resources/assets/cobblesurvival/textures/block/heat_core_front_on.png": "404b331722df27013507b825b97306af1d2947c5aee6e9716bc86b8e70c4c26a",
|
||||
"mod/src/main/resources/assets/cobblesurvival/textures/block/heat_core_front_on.png.mcmeta": "eff55807164fe4f0db2f60ab8a75f03b75489abb0a3c89598e84a65db429ddb3",
|
||||
"mod/src/main/resources/assets/cobblesurvival/textures/block/heat_core_side.png": "6ce92e298906d3992aeda8f71b65b5c9aa82137698933af5f8a6da5a387aebb2",
|
||||
"mod/src/main/resources/assets/cobblesurvival/textures/block/heat_core_top.png": "a277c1a81d7e471c1f9a6c97a6c462589f888e9797816444f08667c261812346",
|
||||
"mod/src/main/resources/assets/cobblesurvival/textures/block/rebirth_core_front.png": "3a4feebf9cb51b287bdedfa81ad3aa623b249e27f6f390ff30ced0fbf781312c",
|
||||
"mod/src/main/resources/assets/cobblesurvival/textures/block/rebirth_core_front_on.png": "67a87560d9b0476b8208934dc8e16dff4ea39c656817309c0e8564cf1d7c722c",
|
||||
"mod/src/main/resources/assets/cobblesurvival/textures/block/rebirth_core_front_on.png.mcmeta": "eff55807164fe4f0db2f60ab8a75f03b75489abb0a3c89598e84a65db429ddb3",
|
||||
"mod/src/main/resources/assets/cobblesurvival/textures/block/rebirth_core_side.png": "aa851a2b6775849c752eaa40e01c31bf8c7b6c311b828256d5702b895bc6fada",
|
||||
"mod/src/main/resources/assets/cobblesurvival/textures/block/rebirth_core_top.png": "539826b19c9a9aa7c5833f94817b7f905a6cf04f1c93d63d3c591b9c0d773a5a",
|
||||
"mod/src/main/resources/assets/cobblesurvival/textures/block/synthesis_core_front.png": "c58583bab198bf38fa1f2e7fd785c247be9e709cf0d72cf5d9a0af38e3e03e1f",
|
||||
"mod/src/main/resources/assets/cobblesurvival/textures/block/synthesis_core_front_on.png": "185cba132cdb71c29300fd87ae433027dc8dddb5910a38372c173c5a636e0864",
|
||||
"mod/src/main/resources/assets/cobblesurvival/textures/block/synthesis_core_front_on.png.mcmeta": "eff55807164fe4f0db2f60ab8a75f03b75489abb0a3c89598e84a65db429ddb3",
|
||||
"mod/src/main/resources/assets/cobblesurvival/textures/block/synthesis_core_side.png": "b7d289800824b16a0e1a961c042b40d688ae33d64bc5c7081d16d3d2d3a0825f",
|
||||
"mod/src/main/resources/assets/cobblesurvival/textures/block/synthesis_core_top.png": "fd1ee6c550f0a7fe7ea97022b32a046ad69563727dc05e31aa339c9b8647aa64",
|
||||
"mod/src/main/resources/assets/cobblesurvival/textures/item/summon_sigil.png": "8b7e9bea51f529c250f03fcd8816aadf1e6b9cf4a53a439c16650b6767c71f56",
|
||||
"mod/src/main/resources/cobblesurvival.mixins.json": "aae2a4db2673be681e3b8389682a89b79a60462a3e157ab11e1dd70b48e79f95",
|
||||
"pack/config/defaultoptions/extra/config/iris.properties": "3866233ba2035bd6ffe6e06cf74c7b1f16869184c274276ca0bd546f1e8bf003",
|
||||
"pack/config/defaultoptions/keybindings.txt": "515362c7a71b203b8ba3ea9d125c6b8cafc2abdb480df5936b9af6a3632d865b",
|
||||
"pack/config/xaero/minimap/default_radar_categories_client.json": "e0a234797b1063dd91a6fec8ebdd3828b93dc39ee4c134d11175aa6d1f26fc20",
|
||||
"pack/kubejs/assets/cobblesurvival/textures/item/mire_waders.png": "37f7469789d281cbd2ab40f483914eec29f5c82e2c3575854cd276f3296ebb24",
|
||||
"pack/kubejs/assets/cobblesurvival/textures/item/revival_charm.png": "084c0a7b17bb429c317843a7c45096dc8cb1ab61c748293475c1f3d6a5984086",
|
||||
"pack/kubejs/assets/regenerating_ore_veins/models/block/regenerator_block.json": "1ae6a922c225ec2e41778055975fb1b2f2fe1d72e6fa150c07a282743dd7531d",
|
||||
"pack/kubejs/assets/regenerating_ore_veins/textures/block/regenerator_glass.png": "a92836513b16f1d5b872c8a3f888c5f340762bb2a9a3d54b56d5ef2a3fe44426",
|
||||
"pack/kubejs/startup_scripts/mire_waders.js": "946be2db734646167fe55ba83b57c6f310ba40313a18a959b84c0952e2302112",
|
||||
"pack/kubejs/startup_scripts/revival_charm.js": "a93eb4401ca303c9aab15ef8cd1edcb34cc7242cbb3501e6703f32e9b3f57e2b",
|
||||
"pack/mods/appleskin.pw.toml": "7e4f29bab7da458bdc022467188b586aa92b4dec60660342df9d9829d129a0c4",
|
||||
"pack/mods/architectury-api.pw.toml": "e8f28975e00df5584ba171e81252c4f3fd3955225a46ae14bb9d5ca43dfb22b8",
|
||||
"pack/mods/balm.pw.toml": "603cdc47142b5aa5729cbbf265a473574c9e2b55561de4318e9842d3d4b3b995",
|
||||
"pack/mods/cloth-config.pw.toml": "66b2e57fad1d958893206d82ff878159b7fdfef64ccb74fe1232b71012409953",
|
||||
"pack/mods/cobblebase.pw.toml": "d5f55b1134cd34a2dead81b3790a2e7104cb05dd1f3936abff6188b55297ef7b",
|
||||
"pack/mods/cobbledex-rei-emi-jei.pw.toml": "45c76954469169c0bf17f7fdbd418c4e90c8362818dfdbd4ea65c661bb61a971",
|
||||
"pack/mods/cobblehardcoremon.pw.toml": "1ec1dbf7caf20e38c7fe0ffa8ac8697f8dbfe431a31683119f43b81d27c6b41a",
|
||||
"pack/mods/cobblemon-fight-or-flight-reborn.pw.toml": "227eea45819938ae2e0911d825e9ccf0e0236b1a36e46627204d8aa53becc9e7",
|
||||
"pack/mods/cobblemon-hms.pw.toml": "6f4a03d49d8a3df16414a00b3c4feb1a859ef6d1ab024a31b734c3829cbcda5b",
|
||||
"pack/mods/cobblemon-poke-power.pw.toml": "29ccd4c39e5af1a2fb63a40bf67ecd03bcced42e9e1edf296b26fd1847740a54",
|
||||
"pack/mods/cobblemon-quest-reloaded.pw.toml": "fed165481433c2dc8c0c9af939957e9b9852883c1307ca3aef9e1c4e6faf14b4",
|
||||
"pack/mods/cobblemon.pw.toml": "e6bed1ffe881690add7bc456fd8a71c136b01ea4f39683648ae7b0e404329d83",
|
||||
"pack/mods/cobblemon_tp_pastures.pw.toml": "2cb849c31c6c51e394b254850243112c6a1a93a3b3534de1ebb9add05a90dd2d",
|
||||
"pack/mods/cobbreeding.pw.toml": "aa8f86c0deec2e5ff6a20360be15748c5281faaa0acf45f15044eff42f182ad9",
|
||||
"pack/mods/cold-sweat.pw.toml": "96b2b071c1669e5f71237adc77d9474d0dc5544509cebc0b242478e40915a4d4",
|
||||
"pack/mods/controlling.pw.toml": "2a1c6846800a0302a133e77e454888f86302a60bbfbcc9aafc462dc9260cd847",
|
||||
"pack/mods/default-options.pw.toml": "b3b7c128653cb40352af979f54e1865cecf050e58f47573381e6dc0214405ae1",
|
||||
"pack/mods/entityculling.pw.toml": "426238488c9ffd843f661f8bc74aa8e585fa6b4d342c43c4ba4cb8451298b725",
|
||||
"pack/mods/ferrite-core.pw.toml": "73360e397134d238ee8fd21d33fd7f9275e9441f74f8f1b6ed92278877c0b9f1",
|
||||
"pack/mods/ftb-library-forge.pw.toml": "df64ca595f6f0f79e3a4a844fd0a887b44f5e007f5deb9ac011b5cf67b3a2127",
|
||||
"pack/mods/ftb-quests-forge.pw.toml": "45d3bc7051ed6546e492e5be236f0271031dd0e45328cf8eecbb035d521e345c",
|
||||
"pack/mods/ftb-teams-forge.pw.toml": "c21c2308684a75cda7dd9d557d129c9f89f49be527da3d5aa675fff444c11f5e",
|
||||
"pack/mods/immediatelyfast.pw.toml": "b399e5e2392f947d59e68c944c7e48d8a5cc2c06db900dce582aa0c48cd46abc",
|
||||
"pack/mods/inventory-profiles-next.pw.toml": "ab503e89461dff879ee793253e173625b6ab868f570a371323ef725e567cf5ea",
|
||||
"pack/mods/iris.pw.toml": "db1c1e6382878b78e994dba1b9026935f8d1c5ca685c89d2437858e0a0dcab58",
|
||||
"pack/mods/jade.pw.toml": "f26cc6ba7ab8da7e027f2466e2e16c61cb8e2e3e640523d20c23257de927e772",
|
||||
"pack/mods/jei.pw.toml": "e7d8e13fb4e1bfe1c0cf18fedfea14cb2027a635c9f5812c4eb6bccb221c21c3",
|
||||
"pack/mods/kotlin-for-forge.pw.toml": "f0875fb7da8a6ce7f4fa69ef1ac015e79c723ae69ac227942df8b3997af7a381",
|
||||
"pack/mods/kubejs.pw.toml": "d18969eaa4eaa509816b272489f4621f625000c44370a58f428788c324510d20",
|
||||
"pack/mods/libipn.pw.toml": "0d2a00252d64ed7d2d9b19994f7accb780304be61f3903d36e810714b414c6cb",
|
||||
"pack/mods/lootr.pw.toml": "6353e846dff3e2899ddce9459d276b09610aa406755ea27c8af3a674b2c86e2f",
|
||||
"pack/mods/matthiesen-core.pw.toml": "863ac987299be54dd902b4db055b1f52823d2c852c7086183adf71cf6f2eeda1",
|
||||
"pack/mods/mezzconfig.pw.toml": "af54d9d538d47d23cbceecfbd74c13f3b214a4fca7e8177c307fc5d04af53e39",
|
||||
"pack/mods/modernfix.pw.toml": "c285056590179db67fd1a37256048a6e1b9b408cdcab3f3b3bb203095d79ca81",
|
||||
"pack/mods/mouse-tweaks.pw.toml": "39cee4df36d04a7530cde51b55154f76fb8954341728be08c3b2ee24b07ed742",
|
||||
"pack/mods/regenerating-ore-veins.pw.toml": "e3be05bd89ad1091014025fe45abd2dcb4318930486597ee3daf02d9eb30648d",
|
||||
"pack/mods/rhino.pw.toml": "8be16680efb71ff64ebd5881943d58f7ef6ce36a95c7a4a856d7dc62b82231ef",
|
||||
"pack/mods/searchables.pw.toml": "0474af66efaf73fd9103e38bd639e547e2928d29ae3ea95c321c9eb1a1c91734",
|
||||
"pack/mods/sodium.pw.toml": "85b08670f3768dbb22477f04339fdda9907769bc1c875cc45352f82c4dece324",
|
||||
"pack/mods/worldgen-editor.pw.toml": "311a5bebcb7bd0b2b98cc6c4a8c344befaa69d0c958c3bf2ce8048f0b958faf1",
|
||||
"pack/mods/xaeros-minimap.pw.toml": "41d879f70cc4d6aead3db959865bd69ead6a3fd8be4ae3aed33963a98f3172e4",
|
||||
"pack/mods/xaeros-world-map.pw.toml": "d90210a531e4c53f9698399bd1d5e733657b5e1eefa236269a4f77962e4c74db",
|
||||
"pack/shaderpacks/complementary-reimagined.pw.toml": "75d1d2488a0c332a9a58af7e57f980a1b006c02b3a1c580fbd50443088e4eab0"
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import lock
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
OUT = ROOT / "datapacks/core/data/cobblebase/loot_table"
|
||||
CONDITION = "cobblesurvival:in_zone"
|
||||
@@ -48,10 +50,7 @@ FAMILIES = {
|
||||
|
||||
|
||||
def cobblebase_jar():
|
||||
jars = sorted((ROOT / "build/server/mods").glob("cobblebase-*.jar"))
|
||||
if not jars:
|
||||
sys.exit("no Cobblebase jar in build/server/mods: run tools/build.py and tools/selftest.py (or the quick check) first")
|
||||
return jars[-1]
|
||||
return lock.mod_jar("cobblebase")
|
||||
|
||||
|
||||
def zone_items():
|
||||
|
||||
+88
-20
@@ -3,8 +3,8 @@
|
||||
a JEI page naming its sources; 2026-11-03: alongside the Cobblemon Info add-on, which shows spawns and drops but not
|
||||
the island's zones).
|
||||
|
||||
JEI runs on the client, which on a dedicated server doesn't have the pack's server data, so the pages are built from
|
||||
this file inside the companion mod: mod/src/main/resources/assets/cobblesurvival/jei/drop_sources.json. It holds:
|
||||
JEI runs on the client, which on a dedicated server doesn't have the pack's server data, so the server sends each player
|
||||
this file (dev.cobblesurvival.jei.DropSources): datapacks/core/data/cobblesurvival/drop_sources/pages.json. It holds:
|
||||
|
||||
pokemon every species of a line that lives on the island: its drops (as the Drop Synthesizer rolls them)
|
||||
and the zones it spawns in, or the zones where its line lives
|
||||
@@ -18,9 +18,8 @@ this file inside the companion mod: mod/src/main/resources/assets/cobblesurvival
|
||||
pasture_finds per Cobblebase skill and zone, what a pasture's Pokémon can find (the pack's zone-bound tables)
|
||||
|
||||
The self-test drop_sources_match_the_server compares it with the live server, so run this after changing species
|
||||
drops, spawns, guardians, the End boss, the altar, ore veins or Cobblebase's tables, then rebuild the mod.
|
||||
|
||||
Needs the Cobblemon jar in build/server/mods (any test-server run).
|
||||
drops, spawns, guardians, the End boss, the altar, ore veins or Cobblebase's tables (tools/build.py and
|
||||
tools/server_update.py run it).
|
||||
|
||||
Usage: tools/jei_sources.py
|
||||
"""
|
||||
@@ -30,21 +29,86 @@ import zipfile
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
import lock
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DATA = ROOT / "datapacks/core/data"
|
||||
OUT = ROOT / "mod/src/main/resources/assets/cobblesurvival/jei/drop_sources.json"
|
||||
OUT = DATA / "cobblesurvival/drop_sources/pages.json"
|
||||
SKILLS = {"mining": "mining", "finder_ore": "ore_finder", "finder_evo": "evolution_item_finder"}
|
||||
|
||||
|
||||
def amount_range(drops):
|
||||
a, _, b = str(drops.get("amount", 1)).partition("-")
|
||||
return range(int(a), int(b or a) + 1)
|
||||
|
||||
|
||||
def yield_chance(entry):
|
||||
"""The chance a picked entry gives at least one item: a count of 0 gives nothing."""
|
||||
low, _, high = str(entry.get("quantityRange", entry.get("quantity", 1))).partition("-")
|
||||
low, high = int(low), int(high or low)
|
||||
return 1.0 if low > 0 else high / (high + 1)
|
||||
|
||||
|
||||
def selection_odds(entries, chosen):
|
||||
"""{item: the chance one drop roll for [chosen] picks gives at least one}, as DropTable.getDrops works: in table
|
||||
order, the first entry that passes its percentage is picked; a round with none uses up a pick. A table can list
|
||||
one item twice (Snorlax's apples), so this follows items, not entries."""
|
||||
percent = [float(e.get("percentage", 100)) / 100 for e in entries]
|
||||
quantity = [int(e.get("quantity", 1)) for e in entries]
|
||||
limit = [int(e.get("maxSelectableTimes", 1)) for e in entries]
|
||||
start = (tuple(i for i in range(len(entries)) if quantity[i] <= chosen), 0, tuple(0 for _ in entries), frozenset())
|
||||
states, odds = {start: 1.0}, defaultdict(float)
|
||||
while states:
|
||||
following = defaultdict(float)
|
||||
def advance(possible, count, times, have, p):
|
||||
if count < chosen and possible:
|
||||
following[(possible, count, times, have)] += p
|
||||
for (possible, count, times, have), p in states.items():
|
||||
miss = 1.0
|
||||
for i in possible:
|
||||
hit = miss * percent[i]
|
||||
miss *= 1 - percent[i]
|
||||
if hit <= 0:
|
||||
continue
|
||||
now = count + quantity[i]
|
||||
picked = times[:i] + (times[i] + 1,) + times[i + 1:]
|
||||
left = tuple(j for j in possible if not (j == i and picked[j] >= limit[j]) and quantity[j] <= chosen - now)
|
||||
item, gives = entries[i].get("item"), yield_chance(entries[i])
|
||||
if item is None or item in have:
|
||||
advance(left, now, picked, have, p * hit)
|
||||
continue
|
||||
odds[item] += p * hit * gives
|
||||
advance(left, now, picked, have | {item}, p * hit * gives)
|
||||
if gives < 1:
|
||||
advance(left, now, picked, have, p * hit * (1 - gives))
|
||||
if miss > 0:
|
||||
advance(possible, count + 1, times, have, p * miss)
|
||||
states = following
|
||||
return odds
|
||||
|
||||
|
||||
def drop_entries(drops):
|
||||
"""Cobblemon drop table → [{item, chance (percent, absent = always), count ("a-b")}]."""
|
||||
"""Cobblemon drop table → [{item, chance (percent that a roll gives at least one, absent = always), count ("a-b")}]."""
|
||||
drops = drops or {}
|
||||
entries = drops.get("entries", [])
|
||||
picks = amount_range(drops)
|
||||
odds = defaultdict(float)
|
||||
if entries:
|
||||
for chosen in picks:
|
||||
for item, chance in selection_odds(entries, chosen).items():
|
||||
odds[item] += chance / len(picks)
|
||||
counts = {}
|
||||
for e in entries:
|
||||
if "item" in e:
|
||||
low, _, high = str(e.get("quantityRange", e.get("quantity", 1))).partition("-")
|
||||
low, high = max(int(low), 1), int(high or low)
|
||||
a, b = counts.get(e["item"], (low, high))
|
||||
counts[e["item"]] = (min(a, low), max(b, high))
|
||||
out = []
|
||||
for e in (drops or {}).get("entries", []):
|
||||
if "item" not in e:
|
||||
continue
|
||||
entry = {"item": e["item"], "count": str(e.get("quantityRange", e.get("quantity", 1)))}
|
||||
if "percentage" in e and float(e["percentage"]) < 100:
|
||||
entry["chance"] = float(e["percentage"])
|
||||
for item, (low, high) in counts.items():
|
||||
entry = {"item": item, "count": str(low) if low == high else f"{low}-{high}"}
|
||||
if odds[item] < 0.9995:
|
||||
entry["chance"] = round(odds[item] * 100, 1)
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
@@ -79,8 +143,7 @@ def root_of(sid, species):
|
||||
|
||||
|
||||
def main():
|
||||
jar = next((ROOT / "build/server/mods").glob("Cobblemon-*.jar"))
|
||||
species = species_data(jar)
|
||||
species = species_data(lock.mod_jar("cobblemon"))
|
||||
plan = json.loads((ROOT / "world/zones.json").read_text())
|
||||
zones = {z["id"]: z for z in plan["zones"] + plan["caves"] + plan.get("dimensions", [])}
|
||||
|
||||
@@ -107,8 +170,13 @@ def main():
|
||||
line_zones[root_of(sid, species)].setdefault(zid, sid)
|
||||
|
||||
table = json.loads((ROOT / "world/summons.json").read_text())
|
||||
summoned = {f"cobblemon:{k}": {"zone": v["zone"], "level": table["tiers"][str(v["tier"])]["level"]} for k, v in table["summons"].items()}
|
||||
summons = [{"species": sid, "name": species[sid]["name"], **s} for sid, s in summoned.items()]
|
||||
level_of = lambda v: table["tiers"][str(v["tier"])]["level"]
|
||||
summoned = {f"cobblemon:{k}": {"zone": v["zone"], "level": level_of(v)} for k, v in table["summons"].items() if "form" not in v}
|
||||
def sigil(key, v):
|
||||
base = f"cobblemon:{v.get('species', key)}"
|
||||
name = f"{v['form'].title()} {species[base]['name']}" if "form" in v else species[base]["name"]
|
||||
return {"species": f"cobblemon:{key}", "pokemon": base, "name": name, "zone": v["zone"], "level": level_of(v)}
|
||||
summons = [sigil(k, v) for k, v in table["summons"].items()]
|
||||
|
||||
pokemon = []
|
||||
for sid, s in sorted(species.items()):
|
||||
@@ -163,9 +231,9 @@ def main():
|
||||
for (skill, zid), items in sorted(finds.items(), key=lambda kv: (kv[0][0], kv[0][1] != "*", kv[0][1]))]
|
||||
|
||||
data = {
|
||||
"_comment": "Generated by tools/jei_sources.py from the pack's data; do not edit by hand. Read by the companion "
|
||||
"mod's JEI plugin (jei/DropSourcesJei.kt); checked against the live server by the self-test "
|
||||
"drop_sources_match_the_server.",
|
||||
"_comment": "Generated by tools/jei_sources.py from the pack's data; do not edit by hand. The server sends it to "
|
||||
"players for the companion mod's JEI pages (jei/DropSources.kt); checked against the live server by the "
|
||||
"self-test drop_sources_match_the_server.",
|
||||
"zones": {zid: {"name": z["name"], "levels": z["levels"]} for zid, z in zones.items()},
|
||||
"pokemon": pokemon, "summons": summons, "spawn_drops": spawn_drops, "guardians": guardians, "end_boss": end_boss,
|
||||
"altar": {"tiers": altar["tiers"], "specials": altar.get("specials", [])}, "rebirth": rebirth.get("extra", []),
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
"""The build's pinned tools (tools/build-lock.toml, tools/requirements.txt) and the environment it runs in.
|
||||
|
||||
Each tool is downloaded once into build/.cache and checked against its hash, so what a build runs depends on the
|
||||
repo, not on what the machine has installed. COBBLESURVIVAL_CACHE moves the cache (tools/check_reproducible.py shares
|
||||
one between its builds: everything in it is checked by hash, so it can't change their result).
|
||||
"""
|
||||
import hashlib
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tomllib
|
||||
import urllib.request
|
||||
import venv
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
LOCK = tomllib.loads((ROOT / "tools/build-lock.toml").read_text())
|
||||
CACHE = Path(os.environ.get("COBBLESURVIVAL_CACHE", ROOT / "build/.cache"))
|
||||
# Nothing the build writes may depend on the machine's clock zone, language or Python's per-run hash seed.
|
||||
BUILD_ENV = {"TZ": "UTC", "LC_ALL": "C.UTF-8", "PYTHONHASHSEED": "0", "PYTHONUTF8": "1"}
|
||||
|
||||
|
||||
def digest(path, algorithm="sha256"):
|
||||
return hashlib.new(algorithm, Path(path).read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def fetch(url, dest, expected, algorithm="sha256"):
|
||||
"""[dest], downloaded from [url] if it isn't there yet; exits if the file is not the one pinned."""
|
||||
if dest.exists() and digest(dest, algorithm) == expected:
|
||||
return dest
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
part = dest.with_name(dest.name + ".part")
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "cobblemon-survival-pack build"})
|
||||
with urllib.request.urlopen(request, timeout=300) as response, open(part, "wb") as out:
|
||||
shutil.copyfileobj(response, out)
|
||||
if digest(part, algorithm) != expected:
|
||||
part.unlink()
|
||||
sys.exit(f"{url} does not match the pinned {algorithm} {expected}")
|
||||
part.replace(dest)
|
||||
return dest
|
||||
|
||||
|
||||
def download(name):
|
||||
"""A file listed under [downloads] in tools/build-lock.toml."""
|
||||
entry = LOCK["downloads"][name]
|
||||
return fetch(entry["url"], CACHE / name, entry["sha256"])
|
||||
|
||||
|
||||
def mod_jar(slug):
|
||||
"""A mod's jar as pack/mods/<slug>.pw.toml pins it, for the tools that read a mod's own data."""
|
||||
meta = tomllib.loads((ROOT / f"pack/mods/{slug}.pw.toml").read_text())
|
||||
file = meta["download"]
|
||||
return fetch(file["url"], CACHE / "mods" / meta["filename"], file["hash"], file["hash-format"])
|
||||
|
||||
|
||||
def jdk():
|
||||
"""The pinned JDK's home, the only one Gradle gets to compile with."""
|
||||
pin = LOCK["jdk"]
|
||||
here = f"{platform.system().lower()}-{platform.machine()}"
|
||||
if here != pin["platform"]:
|
||||
sys.exit(f"tools/build-lock.toml pins a JDK for {pin['platform']}, this machine is {here}")
|
||||
home = CACHE / f"jdk-{pin['version']}"
|
||||
if not (home / "bin/javac").exists():
|
||||
archive = fetch(pin["url"], CACHE / f"jdk-{pin['version']}.tar.gz", pin["sha256"])
|
||||
unpacking = home.with_name(home.name + ".part")
|
||||
shutil.rmtree(unpacking, ignore_errors=True)
|
||||
unpacking.mkdir(parents=True)
|
||||
with tarfile.open(archive) as tar:
|
||||
tar.extractall(unpacking, filter="tar")
|
||||
shutil.rmtree(home, ignore_errors=True)
|
||||
next(unpacking.iterdir()).rename(home)
|
||||
unpacking.rmdir()
|
||||
return home
|
||||
|
||||
|
||||
def packwiz():
|
||||
"""The pinned packwiz, built from its pinned commit with the pinned Go."""
|
||||
pin = LOCK["packwiz"]
|
||||
binary = CACHE / f"packwiz-{pin['version']}" / "packwiz"
|
||||
if not binary.exists():
|
||||
if not shutil.which("go"):
|
||||
sys.exit("building the pinned packwiz needs Go (https://go.dev/dl/)")
|
||||
env = os.environ | {"GOBIN": str(binary.parent), "GOTOOLCHAIN": pin["go"], "GOFLAGS": "-trimpath"}
|
||||
subprocess.run(["go", "install", f"{pin['module']}@{pin['version']}"], env=env, check=True)
|
||||
return binary
|
||||
|
||||
|
||||
def enter_venv():
|
||||
"""Re-runs the calling script in a private venv with exactly tools/requirements.txt and the build's environment."""
|
||||
requirements = ROOT / "tools/requirements.txt"
|
||||
home = CACHE / f"venv-{digest(requirements)[:12]}"
|
||||
if Path(sys.prefix) == home and all(os.environ.get(k) == v for k, v in BUILD_ENV.items()):
|
||||
return
|
||||
if f"{sys.version_info[0]}.{sys.version_info[1]}" != LOCK["python"]:
|
||||
print(f"warning: tools/build-lock.toml pins Python {LOCK['python']}, this is {sys.version.split()[0]}", file=sys.stderr)
|
||||
python = home / "bin/python"
|
||||
if not python.exists():
|
||||
venv.create(home, with_pip=True)
|
||||
subprocess.run([python, "-m", "pip", "install", "--quiet", "--require-hashes", "-r", requirements], check=True)
|
||||
os.execve(python, [str(python), sys.argv[0], *sys.argv[1:]], os.environ | BUILD_ENV)
|
||||
+2
-2
@@ -15,6 +15,7 @@ import tomllib
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import lock
|
||||
from packwiz_sync import java, sync
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -82,8 +83,7 @@ def prepare():
|
||||
SERVER.mkdir(parents=True, exist_ok=True)
|
||||
args_file = SERVER / "libraries/net/neoforged/neoforge" / version / "unix_args.txt"
|
||||
if not args_file.exists():
|
||||
installer = download(f"https://maven.neoforged.net/releases/net/neoforged/neoforge/{version}/"
|
||||
f"neoforge-{version}-installer.jar", CACHE / f"neoforge-{version}-installer.jar")
|
||||
installer = lock.download(f"neoforge-{version}-installer.jar")
|
||||
subprocess.run([java(), "-jar", str(installer), "--install-server", str(SERVER)],
|
||||
cwd=SERVER, check=True, stdout=subprocess.DEVNULL)
|
||||
sync("server", SERVER, BUILD / "stage" / "full")
|
||||
|
||||
+4
-20
@@ -14,27 +14,11 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import lock
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
CACHE = ROOT / "build" / ".cache"
|
||||
# Pinned; the bootstrap runs with --bootstrap-no-update so neither self-updates.
|
||||
TOOLS = {
|
||||
"packwiz-installer-0.5.14.jar":
|
||||
"https://github.com/packwiz/packwiz-installer/releases/download/v0.5.14/packwiz-installer.jar",
|
||||
"packwiz-installer-bootstrap-0.0.3.jar":
|
||||
"https://github.com/packwiz/packwiz-installer-bootstrap/releases/download/v0.0.3/packwiz-installer-bootstrap.jar",
|
||||
}
|
||||
|
||||
|
||||
def cached(name):
|
||||
jar = CACHE / name
|
||||
if not jar.exists():
|
||||
CACHE.mkdir(parents=True, exist_ok=True)
|
||||
with urllib.request.urlopen(TOOLS[name], timeout=120) as r, open(jar, "wb") as f:
|
||||
shutil.copyfileobj(r, f)
|
||||
return jar
|
||||
|
||||
|
||||
def java():
|
||||
@@ -56,9 +40,9 @@ def sync(side, target, pack_dir):
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
try:
|
||||
url = f"http://127.0.0.1:{server.server_port}/pack.toml"
|
||||
subprocess.run([java(), "-jar", str(cached("packwiz-installer-bootstrap-0.0.3.jar")),
|
||||
subprocess.run([java(), "-jar", str(lock.download("packwiz-installer-bootstrap-0.0.3.jar")),
|
||||
"--bootstrap-no-update",
|
||||
"--bootstrap-main-jar", str(cached("packwiz-installer-0.5.14.jar")),
|
||||
"--bootstrap-main-jar", str(lock.download("packwiz-installer-0.5.14.jar")),
|
||||
"-g", "-s", side, url],
|
||||
cwd=target, check=True)
|
||||
finally:
|
||||
|
||||
Executable
+249
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Measure the server under a realistic load with spark (roadmap Phase 9).
|
||||
|
||||
Boots the test server on the island world template once per scenario, adds the fake players and bases of
|
||||
`/cobblesurvival perf` (mod: selftest/PerfLoad.kt), lets things settle and records every tick, while spark profiles the
|
||||
server thread. Writes build/perf/<scenario>.json and .sparkprofile (open that in spark's viewer if you want to look closer) and docs/performance.md.
|
||||
|
||||
What it can't show: fake clients send no packets and encode none, so network cost is left out, and no battles are fought.
|
||||
|
||||
Usage: tools/perf_test.py [--scenarios solo group stress] [--settle 120] [--measure 180] [--no-doc]
|
||||
"""
|
||||
import argparse
|
||||
import gzip
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
from mcserver import BUILD, ROOT, SELFTEST_JVM_ARGS, SERVER, Server, eula_accepted, prepare, scan_log
|
||||
|
||||
TEMPLATE = BUILD / "world-template"
|
||||
OUT = BUILD / "perf"
|
||||
# players, pastures each, Pokémon per pasture, machines each
|
||||
SCENARIOS = {"solo": (1, 0, 0, 0), "group": (5, 4, 8, 5), "stress": (8, 6, 16, 10)}
|
||||
HEAP = "6G"
|
||||
|
||||
|
||||
def fields(buf):
|
||||
"""Protobuf wire format: (field number, wire type, value)."""
|
||||
i, n = 0, len(buf)
|
||||
|
||||
def varint():
|
||||
nonlocal i
|
||||
shift = result = 0
|
||||
while True:
|
||||
b = buf[i]
|
||||
i += 1
|
||||
result |= (b & 0x7f) << shift
|
||||
if not b & 0x80:
|
||||
return result
|
||||
shift += 7
|
||||
|
||||
while i < n:
|
||||
key = varint()
|
||||
number, wire = key >> 3, key & 7
|
||||
if wire == 0:
|
||||
yield number, wire, varint()
|
||||
elif wire == 1:
|
||||
yield number, wire, buf[i:i + 8]
|
||||
i += 8
|
||||
elif wire == 2:
|
||||
length = varint()
|
||||
yield number, wire, buf[i:i + length]
|
||||
i += length
|
||||
elif wire == 5:
|
||||
yield number, wire, buf[i:i + 4]
|
||||
i += 4
|
||||
else:
|
||||
raise ValueError(f"wire type {wire}")
|
||||
|
||||
|
||||
def doubles(value):
|
||||
import struct
|
||||
return struct.unpack(f"<{len(value) // 8}d", value)
|
||||
|
||||
|
||||
def varints(value):
|
||||
return [v for v in iter_varints(value)]
|
||||
|
||||
|
||||
def iter_varints(value):
|
||||
i, n = 0, len(value)
|
||||
while i < n:
|
||||
shift = result = 0
|
||||
while True:
|
||||
b = value[i]
|
||||
i += 1
|
||||
result |= (b & 0x7f) << shift
|
||||
if not b & 0x80:
|
||||
break
|
||||
shift += 7
|
||||
yield result
|
||||
|
||||
|
||||
def read_profile(path):
|
||||
"""spark's sampler data: {class.method: self ms}, inclusive per method, total ms of the server thread."""
|
||||
raw = path.read_bytes()
|
||||
if raw[:2] == b"\x1f\x8b":
|
||||
raw = gzip.decompress(raw)
|
||||
nodes, roots = [], []
|
||||
for number, _, value in fields(raw):
|
||||
if number != 2:
|
||||
continue
|
||||
flat, children, time_ms = [], [], 0.0
|
||||
for n, _, v in fields(value):
|
||||
if n == 3: # children: every stack frame, flattened
|
||||
node = {"class": "", "method": "", "time": 0.0, "kids": []}
|
||||
for fn, wire, fv in fields(v):
|
||||
if fn == 3:
|
||||
node["class"] = fv.decode()
|
||||
elif fn == 4:
|
||||
node["method"] = fv.decode()
|
||||
elif fn == 8:
|
||||
node["time"] += sum(doubles(fv)) if wire == 2 else doubles(fv)[0]
|
||||
elif fn == 9:
|
||||
node["kids"] += varints(fv) if wire == 2 else [fv]
|
||||
flat.append(node)
|
||||
elif n == 4:
|
||||
time_ms += sum(doubles(v))
|
||||
elif n == 5:
|
||||
children += varints(v)
|
||||
nodes.append(flat)
|
||||
roots.append((time_ms, children))
|
||||
self_ms, inclusive = defaultdict(float), defaultdict(float)
|
||||
total = 0.0
|
||||
for flat, (time_ms, children) in zip(nodes, roots):
|
||||
total += time_ms
|
||||
def walk(index, path_seen):
|
||||
node = flat[index]
|
||||
name = f"{node['class']}.{node['method']}"
|
||||
self_ms[name] += node["time"] - sum(flat[k]["time"] for k in node["kids"])
|
||||
fresh = name not in path_seen
|
||||
if fresh:
|
||||
inclusive[name] += node["time"]
|
||||
path_seen.add(name)
|
||||
for k in node["kids"]:
|
||||
walk(k, path_seen)
|
||||
if fresh:
|
||||
path_seen.discard(name)
|
||||
sys.setrecursionlimit(20000)
|
||||
for c in children:
|
||||
walk(c, set())
|
||||
return self_ms, inclusive, total
|
||||
|
||||
|
||||
def group_of(name):
|
||||
if name.startswith("libc.so") or ".park" in name or "waitUntilNextTick" in name or "managedBlock" in name:
|
||||
return "Waiting for the next tick"
|
||||
if name.startswith("dev.cobblesurvival"):
|
||||
return "Companion mod"
|
||||
if name.startswith(("com.cobblemon", "com.cobbledex")):
|
||||
return "Cobblemon"
|
||||
if name.startswith(("net.minecraft", "com.mojang", "it.unimi")):
|
||||
return "Minecraft"
|
||||
if name.startswith(("java.", "jdk.", "sun.", "kotlin.")):
|
||||
return "JVM and Kotlin"
|
||||
return "Other mods"
|
||||
|
||||
|
||||
def summarise(profile):
|
||||
self_ms, inclusive, total = read_profile(profile)
|
||||
groups = defaultdict(float)
|
||||
for name, ms in self_ms.items():
|
||||
groups[group_of(name)] += ms
|
||||
pick = lambda prefix: sorted(((n, ms) for n, ms in inclusive.items() if n.startswith(prefix)), key=lambda kv: -kv[1])[:8]
|
||||
return {"total_ms": total, "groups": dict(sorted(groups.items(), key=lambda kv: -kv[1])),
|
||||
"companion_top": pick("dev.cobblesurvival"), "cobblemon_top": pick("com.cobblemon"),
|
||||
"self_top": sorted(self_ms.items(), key=lambda kv: -kv[1])[:10]}
|
||||
|
||||
|
||||
def parse_report(line):
|
||||
return {k: float(v) for k, v in re.findall(r"(\w+)=([\d.]+)", line)}
|
||||
|
||||
|
||||
def run(name, config, args_file, recipe, settle, measure):
|
||||
players, pastures, pokemon, machines = config
|
||||
world = SERVER / "world"
|
||||
shutil.rmtree(world, ignore_errors=True)
|
||||
shutil.copytree(TEMPLATE, world)
|
||||
for old in SERVER.glob("config/spark/**/*.sparkprofile"):
|
||||
old.unlink()
|
||||
with Server(args_file, {"level-type": recipe["level_type"], "level-seed": recipe["seed"]}, heap=HEAP,
|
||||
stdout_name=f"perf-{name}-stdout.log", jvm_args=SELFTEST_JVM_ARGS) as server:
|
||||
boot = server.wait_ready()
|
||||
print(f"[{name}] booted in {boot:.0f}s: {server.command(f'cobblesurvival perf start {players} {pastures} {pokemon} {machines}').strip()}", flush=True)
|
||||
time.sleep(settle)
|
||||
server.command("cobblesurvival perf measure")
|
||||
print(f"[{name}] {server.command(f'spark profiler start --timeout {measure} --save-to-file').strip()[:120]}", flush=True)
|
||||
time.sleep(measure + 15)
|
||||
report = server.command("cobblesurvival perf report").strip()
|
||||
tps = re.sub(r"§.", "", server.command("spark tps"))
|
||||
health = re.sub(r"§.", "", server.command("spark health"))
|
||||
print(f"[{name}] {report}", flush=True)
|
||||
for _ in range(30):
|
||||
profiles = sorted(SERVER.glob("config/spark/**/*.sparkprofile"))
|
||||
if profiles:
|
||||
break
|
||||
time.sleep(2)
|
||||
server.stop()
|
||||
errors = [line[:200] for line in scan_log(server.log)][:30]
|
||||
result = {"scenario": name, "players": players, "pastures_each": pastures, "pokemon_each": pokemon, "machines_each": machines,
|
||||
"heap": HEAP, "report": parse_report(report), "raw_report": report, "tps": tps, "health": health, "log_errors": errors}
|
||||
if profiles:
|
||||
kept = OUT / f"{name}.sparkprofile"
|
||||
shutil.copy2(profiles[-1], kept)
|
||||
result["profile"] = summarise(kept)
|
||||
(OUT / f"{name}.json").write_text(json.dumps(result, indent=1))
|
||||
return result
|
||||
|
||||
|
||||
def write_doc(results, settle, measure):
|
||||
lines = ["# Performance", "",
|
||||
f"Generated by `tools/perf_test.py`: the island world template, a {HEAP} heap, {settle} s to settle, then {measure} s measured tick by tick with spark profiling the server thread.",
|
||||
"Players are the self-tests' fake clients: the server ticks them, loads their chunks and spawns Pokémon around them. One walks a long ring round the island when there are several. They send and encode no packets and fight no battles, so read this as the cost of the world, not of the network.", "",
|
||||
"A tick has 50 ms; over that, the server falls behind.", "",
|
||||
"| Scenario | Players | Pastures × Pokémon each | Machines each | Mean tick | p95 | p99 | Worst | Ticks over 50 ms | Pokémon pastured / wild | Working machines | Loaded chunks |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |"]
|
||||
for r in results:
|
||||
p = r["report"]
|
||||
lines.append(f"| {r['scenario']} | {r['players']} | {r['pastures_each']} × {r['pokemon_each']} | {r['machines_each']} | {p['mean_ms']:.1f} ms | {p['p95_ms']:.1f} ms | {p['p99_ms']:.1f} ms | "
|
||||
f"{p['max_ms']:.0f} ms | {int(p['spikes_over_50ms'])} of {int(p['ticks'])} | {int(p['pastured'])} / {int(p['wild'])} | {int(p['working'])} of {int(p['machines'])} | {int(p['chunks'])} |")
|
||||
for r in results:
|
||||
if "profile" not in r:
|
||||
continue
|
||||
prof = r["profile"]
|
||||
total = sum(ms for g, ms in prof["groups"].items() if g != "Waiting for the next tick") or 1
|
||||
prof["groups"].pop("Waiting for the next tick", None)
|
||||
lines += ["", f"## Where the server thread's busy time goes: {r['scenario']}", "", "| Part | Share |", "| --- | --- |"]
|
||||
lines += [f"| {g} | {ms / total * 100:.1f} % |" for g, ms in prof["groups"].items()]
|
||||
lines += ["", "Companion mod, heaviest methods including what they call:", ""]
|
||||
lines += [f"- `{n}`: {ms / total * 100:.2f} %" for n, ms in prof["companion_top"]]
|
||||
(ROOT / "docs/performance.md").write_text("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--scenarios", nargs="+", default=list(SCENARIOS), choices=list(SCENARIOS))
|
||||
parser.add_argument("--settle", type=int, default=120)
|
||||
parser.add_argument("--measure", type=int, default=180)
|
||||
parser.add_argument("--no-doc", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if not eula_accepted():
|
||||
sys.exit("build/server/eula.txt does not accept the Minecraft EULA yet (see tools/selftest.py --accept-eula).")
|
||||
if not (TEMPLATE / "level.dat").exists():
|
||||
sys.exit("no build/world-template: run tools/make_world.py first")
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
recipe = json.loads((ROOT / "world/world.json").read_text())
|
||||
args_file = prepare()
|
||||
results = [run(name, SCENARIOS[name], args_file, recipe, args.settle, args.measure) for name in args.scenarios]
|
||||
if not args.no_doc:
|
||||
write_doc(results, args.settle, args.measure)
|
||||
print("PERF TEST DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+2
-2
@@ -232,12 +232,12 @@ def chapter_quests(chapter, book, zones):
|
||||
q["tasks"] = [{k: v for k, v in t.items() if k != "each"} for t in tasks]
|
||||
# One quest per Sigil: craft it, use it, catch what comes (world/summons.json; names from the JEI data).
|
||||
if chapter.get("summons"):
|
||||
sigils = json.loads((ROOT / "mod/src/main/resources/assets/cobblesurvival/jei/drop_sources.json").read_text())["summons"]
|
||||
sigils = json.loads((ROOT / "datapacks/core/data/cobblesurvival/drop_sources/pages.json").read_text())["summons"]
|
||||
for s in sigils:
|
||||
quests.append({
|
||||
"id": s["species"].split(":")[1], "title": s["name"], "icon": "cobblesurvival:summon_sigil", "after": ["about"],
|
||||
"text": f"Craft the {s['name']} Sigil and use it in {zones[s['zone']]['name']}. Catch the Lv {s['level']} {s['name']} that answers.",
|
||||
"tasks": [{"cobblemon": "catch", "pokemon": [s["species"]]}],
|
||||
"tasks": [{"cobblemon": "catch", "pokemon": [s["pokemon"]]}],
|
||||
})
|
||||
# "Catch Pokémon of a zone": its spawn pool's species (Cobblemon Quests' own biome filter knows no zones).
|
||||
for q in quests:
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Exact packages for tools/ (tools/lock.py installs them into a private venv). Hashes: Linux x86_64 wheels for Python 3.14, and the source archive.
|
||||
numpy==2.4.6 \
|
||||
--hash=sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8 \
|
||||
--hash=sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e \
|
||||
--hash=sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda
|
||||
pillow==12.3.0 \
|
||||
--hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b \
|
||||
--hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c \
|
||||
--hash=sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce
|
||||
PyYAML==6.0.3 \
|
||||
--hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
|
||||
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
|
||||
--hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f
|
||||
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()
|
||||
+6
-4
@@ -12,7 +12,6 @@ writes, under datapacks/core/data/cobblesurvival/:
|
||||
and the sigil's texture and item model in the companion mod. The fights are written by tools/zone_bosses.py, so run that
|
||||
afterwards, and tools/jei_sources.py and tools/quests.py after it.
|
||||
|
||||
Needs the Cobblemon jar in build/server/mods (any test-server run), for the names.
|
||||
|
||||
Usage: tools/summons.py
|
||||
"""
|
||||
@@ -23,6 +22,8 @@ from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
import lock
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DATA = ROOT / "datapacks/core/data/cobblesurvival"
|
||||
ASSETS = ROOT / "mod/src/main/resources/assets/cobblesurvival"
|
||||
@@ -65,7 +66,7 @@ def main():
|
||||
table = json.loads((ROOT / "world/summons.json").read_text())
|
||||
plan = json.loads((ROOT / "world/zones.json").read_text())
|
||||
zones = {z["id"]: z["name"] for z in plan["zones"] + plan["caves"] + plan.get("dimensions", [])}
|
||||
species_names = names(next((ROOT / "build/server/mods").glob("Cobblemon-*.jar")))
|
||||
species_names = names(lock.mod_jar("cobblemon"))
|
||||
|
||||
summons, recipes = DATA / "summons", DATA / "recipe"
|
||||
summons.mkdir(parents=True, exist_ok=True)
|
||||
@@ -74,9 +75,10 @@ def main():
|
||||
comment = "Generated by tools/summons.py from world/summons.json; do not edit by hand."
|
||||
for species, s in table["summons"].items():
|
||||
tier = table["tiers"][str(s["tier"])]
|
||||
name = species_names[species]
|
||||
form = s.get("form")
|
||||
name = f"{form.title()} {species_names[s['species']]}" if form else species_names[species]
|
||||
(summons / f"{species}.json").write_text(json.dumps(
|
||||
{"_comment": comment, "species": species, "level": tier["level"], "zone": s["zone"]}, indent=1) + "\n")
|
||||
{"_comment": comment, "species": species, "pokemon": f"{s['species']} {form}" if form else species, "level": tier["level"], "zone": s["zone"]}, indent=1) + "\n")
|
||||
lore = f"Use in {zones[s['zone']]} to summon a Lv {tier['level']} {name}"
|
||||
(recipes / f"summon_{species}.json").write_text(json.dumps({
|
||||
"_comment": comment, "type": "minecraft:crafting_shaped", "category": "misc",
|
||||
|
||||
@@ -7,7 +7,6 @@ datapacks/core/data/cobblesurvival/bosses/<zone>.json, and bosses/end.json for t
|
||||
guardian's level (TIERS): which patterns it uses, how long they telegraph, how often they come; damage grows with
|
||||
level; a Fire, Ice or Poison guardian burns, freezes or poisons. Placeholder numbers, see docs/balance.md.
|
||||
|
||||
Needs the Cobblemon jar in build/server/mods (any test-server run) for the guardians' types.
|
||||
|
||||
Usage: tools/zone_bosses.py
|
||||
"""
|
||||
@@ -15,10 +14,11 @@ import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import lock
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DATA = ROOT / "datapacks/core/data/cobblesurvival"
|
||||
OUT = DATA / "bosses"
|
||||
MODS = ROOT / "build/server/mods"
|
||||
|
||||
ARENA = 24
|
||||
ARENA_BY_ZONE = {"basalt": 32}
|
||||
@@ -79,8 +79,7 @@ def species_data(jar):
|
||||
|
||||
|
||||
def main():
|
||||
jar = next(MODS.glob("Cobblemon-*.jar"))
|
||||
types, heights = species_data(jar)
|
||||
types, heights = species_data(lock.mod_jar("cobblemon"))
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
for old in OUT.glob("*.json"):
|
||||
old.unlink()
|
||||
@@ -99,7 +98,7 @@ def main():
|
||||
print(f"end: {END['species']} Lv {END['level']}, {len(end['patterns'])} patterns")
|
||||
table = json.loads((ROOT / "world/summons.json").read_text())
|
||||
for species, s in table["summons"].items():
|
||||
fight = boss(table["tiers"][str(s["tier"])]["level"], types.get(species, set()), SUMMON_ARENA, summon=species)
|
||||
fight = boss(table["tiers"][str(s["tier"])]["level"], types.get(s.get("species", species), set()), SUMMON_ARENA, summon=species)
|
||||
(OUT / f"summon_{species}.json").write_text(json.dumps({"_comment": comment, **fight}, indent=1) + "\n")
|
||||
print(f"{len(table['summons'])} summoned legendaries")
|
||||
|
||||
|
||||
@@ -127,6 +127,8 @@ def assign(preview, plan):
|
||||
elif "water" in rule:
|
||||
inherit_from_land(zone, biome_mask(plan["inherit"]))
|
||||
mask = ~land & (zone == -1) & biome_mask(rule["water"])
|
||||
if "beyond" in rule:
|
||||
mask &= dist > rule["beyond"]
|
||||
else:
|
||||
sys.exit(f"zone {spec['id']}: unknown rule {rule}")
|
||||
zone[mask] = index
|
||||
|
||||
+10
-8
@@ -57,6 +57,8 @@ import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import lock
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
OUT = ROOT / "datapacks/core/data/cobblesurvival/spawn_pool_world"
|
||||
GUARDIANS = ROOT / "datapacks/core/data/cobblesurvival/guardians"
|
||||
@@ -83,10 +85,7 @@ HIGH_ZONE_LEVEL = 30
|
||||
|
||||
|
||||
def cobblemon_jar():
|
||||
jars = sorted((ROOT / "build/server/mods").glob("Cobblemon-*.jar"))
|
||||
if not jars:
|
||||
sys.exit("no Cobblemon jar in build/server/mods: run tools/build.py and tools/selftest.py (or the quick check) first")
|
||||
return jars[-1]
|
||||
return lock.mod_jar("cobblemon")
|
||||
|
||||
|
||||
def load_entries(jar):
|
||||
@@ -388,8 +387,9 @@ def main():
|
||||
lo, hi = zones[zid]["levels"]
|
||||
kinds = {"submerged", "surface"} if "water" in zones[zid].get("rule", {}) else {"grounded"}
|
||||
for name in roots:
|
||||
root = f"cobblemon:{name}"
|
||||
items = [i for i in raw_lines[root] if i[0].get("spawnablePositionType") in kinds]
|
||||
base, _, form = name.partition(" ")
|
||||
root = f"cobblemon:{base}"
|
||||
items = [i for i in raw_lines[root] if i[0].get("spawnablePositionType") in kinds and (not form or i[0]["pokemon"].lower() == name)]
|
||||
if not items:
|
||||
# No usable spawn data at all: one plain entry across the band.
|
||||
items = [({"type": "pokemon", "pokemon": name, "presets": ["natural"], "spawnablePositionType": "grounded"}, root, (lo, hi))]
|
||||
@@ -416,8 +416,10 @@ def main():
|
||||
"level": f"{a}-{b}", "weight": homeless["weight"], "condition": condition,
|
||||
})
|
||||
names = sorted({species[m]["name"] for _, m, _, _ in fitting if m in species})
|
||||
report[zid].append((species.get(root, {}).get("name", root), names))
|
||||
homeless_roots.add(root)
|
||||
label = species.get(root, {}).get("name", root)
|
||||
report[zid].append((f"{form.split()[0].title()} {label}" if form else label, names))
|
||||
if not form: # a regional form comes on top of its line, which keeps its own home
|
||||
homeless_roots.add(root)
|
||||
|
||||
low_flyers = [(zid, s["pokemon"]) for zid, spawns in pools.items() for s in spawns
|
||||
if zones[zid]["levels"][0] < HIGH_ZONE_LEVEL and f"cobblemon:{s['pokemon'].split()[0].lower()}" in strong_flyers]
|
||||
|
||||
Reference in new issue
Block a user