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:
claudeandClaude Opus 5.5 committed 2026-11-09 16:26:30 +01:00
1 parent 0741c84077
commit 4083791944
133 files changed
+8350 -3273

No files matched your search

+26
View File
@@ -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
View File
@@ -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}")
+4
View File
@@ -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))
+75
View File
@@ -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()
+76
View File
@@ -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()
+113
View File
@@ -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"
}
+3 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+249
View File
@@ -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
View File
@@ -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:
+13
View File
@@ -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
+155
View File
@@ -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
View File
@@ -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",
+4 -5
View File
@@ -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")
+2
View File
@@ -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
View File
@@ -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]