Adrian (2026-10-31): add Cobbreeding without a gate; only Ditto should
be rare, in an endgame area or gated behind something else.
- Cobbreeding 2.4.1 (Modrinth only) and Cloth Config (client) in the
pack; Cobblebase's hatchery takes Cobbreeding's eggs.
- Ditto moved from Button Isle to the Deep Dark with both of Cobblemon's
entries (rare in slime chunks, ultra-rare elsewhere): zone_spawns.py
pins take {"zone", "all_entries"}; Deep Dark target +1, Button Isle -1
so no other line moved; Southwold and Frostpeak guardians pinned (they
tie on base stats and flipped between runs).
- HMs' Trainer Ditto is made uncatchable; it still trades.
- check_deps.py skips other-side dependencies; Cobbreeding's harmless
client-mixin log line allowlisted.
- Self-tests cobbreeding_eggs_fit_the_hatchery, trainer_ditto_cant_be_caught;
docs, balance table, Phase 5 checklist.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
122 lines
5.2 KiB
Python
122 lines
5.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Static dependency check for a folder of NeoForge mod jars.
|
|
|
|
Reads META-INF/neoforge.mods.toml from every jar (and from jar-in-jar files
|
|
under META-INF/jarjar/), then checks that each required dependency is present
|
|
and inside its declared version range. Needs no server and no EULA, so it runs
|
|
anywhere; the server smoke test is still the real proof.
|
|
|
|
Version ranges use Maven syntax ("[1.8.4,1.9)", "[21.1,)", "1.0" = at least).
|
|
Build metadata after "+" is ignored, so Cobblemon "1.8.4+1.21.1" compares as
|
|
1.8.4; qualifiers follow Maven (21-beta < 21 < 21.1.265). A mismatch on the `minecraft` dependency is only a warning: every JEI
|
|
build for 1.21.1 declares "[1.21, 1.21.1)" and NeoForge 21.1 still loads it
|
|
(checked on the test server, 2026-10-28), so this check is stricter than the
|
|
loader there. A dependency limited to the other side (`side = "CLIENT"` in a
|
|
server folder, e.g. Cobbreeding's Cloth Config) is skipped.
|
|
|
|
Usage: tools/check_deps.py <mods_dir> [--neoforge VER] [--minecraft VER] [--side server|client]
|
|
"""
|
|
import argparse
|
|
import io
|
|
import re
|
|
import sys
|
|
import tomllib
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
PLATFORM = {"neoforge", "minecraft", "javafml", "fml", "kotlinforforge_loader"}
|
|
|
|
|
|
QUALIFIERS = {"alpha": 1, "a": 1, "beta": 2, "b": 2, "milestone": 3, "m": 3, "rc": 4, "cr": 4, "snapshot": 5}
|
|
RELEASE = (0, 10, "") # pads the shorter version: a release sorts above its own alpha/beta/rc
|
|
|
|
|
|
def key(version):
|
|
"""Maven-like ordering: numbers sort above qualifiers, so 21.1.265 > 21-beta > 21-alpha."""
|
|
parts = re.split(r"[.\-]", version.split("+", 1)[0])
|
|
return [(1, int(p), "") if p.isdigit() else (0, QUALIFIERS.get(p.lower(), 6), p.lower()) for p in parts if p]
|
|
|
|
|
|
def compare(a, b):
|
|
ka, kb = key(a), key(b)
|
|
n = max(len(ka), len(kb))
|
|
ka, kb = ka + [RELEASE] * (n - len(ka)), kb + [RELEASE] * (n - len(kb))
|
|
return (ka > kb) - (ka < kb)
|
|
|
|
|
|
def in_range(version, spec):
|
|
spec = spec.strip()
|
|
if spec in ("", "*"):
|
|
return True
|
|
if spec[0] not in "[(":
|
|
return compare(version, spec) >= 0
|
|
for part in re.findall(r"[\[(][^\])]*[\])]", spec):
|
|
lo_inc, hi_inc = part[0] == "[", part[-1] == "]"
|
|
bounds = part[1:-1].split(",")
|
|
if len(bounds) == 1:
|
|
if compare(version, bounds[0]) == 0:
|
|
return True
|
|
continue
|
|
lo, hi = (b.strip() for b in bounds)
|
|
if lo and (compare(version, lo) < 0 or (compare(version, lo) == 0 and not lo_inc)):
|
|
continue
|
|
if hi and (compare(version, hi) > 0 or (compare(version, hi) == 0 and not hi_inc)):
|
|
continue
|
|
return True
|
|
return False
|
|
|
|
|
|
def read_mods(data, name, found, deps, side):
|
|
z = zipfile.ZipFile(io.BytesIO(data))
|
|
names = set(z.namelist())
|
|
if "META-INF/neoforge.mods.toml" in names:
|
|
toml = tomllib.loads(z.read("META-INF/neoforge.mods.toml").decode("utf-8", "replace"))
|
|
manifest = z.read("META-INF/MANIFEST.MF").decode("utf-8", "replace") if "META-INF/MANIFEST.MF" in names else ""
|
|
jar_version = (re.search(r"Implementation-Version: *(\S+)", manifest) or [None, "0"])[1]
|
|
for mod in toml.get("mods", []):
|
|
version = str(mod.get("version", "0")).replace("${file.jarVersion}", jar_version)
|
|
found[mod["modId"]] = (version, name)
|
|
for mod_id, entries in toml.get("dependencies", {}).items():
|
|
for dep in entries:
|
|
required = dep.get("type", "required" if dep.get("mandatory", True) else "optional") == "required"
|
|
if required and dep.get("side", "BOTH").upper() in ("BOTH", side.upper()):
|
|
deps.append((mod_id, dep["modId"], dep.get("versionRange", "*"), name))
|
|
for inner in (n for n in names if n.startswith("META-INF/jarjar/") and n.endswith(".jar")):
|
|
read_mods(z.read(inner), f"{name}!{inner.rsplit('/', 1)[-1]}", found, deps, side)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("mods_dir", type=Path)
|
|
parser.add_argument("--neoforge")
|
|
parser.add_argument("--minecraft", default="1.21.1")
|
|
parser.add_argument("--side", choices=["server", "client"], default="server")
|
|
args = parser.parse_args()
|
|
|
|
found, deps = {}, []
|
|
for jar in sorted(args.mods_dir.glob("*.jar")):
|
|
read_mods(jar.read_bytes(), jar.name, found, deps, args.side)
|
|
if args.neoforge:
|
|
found["neoforge"] = (args.neoforge, "loader")
|
|
found["minecraft"] = (args.minecraft, "game")
|
|
|
|
problems, warnings = [], []
|
|
for owner, dep, spec, jar in deps:
|
|
if dep not in found:
|
|
if dep not in PLATFORM:
|
|
problems.append(f"{owner} ({jar}) needs {dep} {spec}: not installed")
|
|
elif not in_range(found[dep][0], spec):
|
|
(warnings if dep == "minecraft" else problems).append(
|
|
f"{owner} ({jar}) needs {dep} {spec}: found {found[dep][0]}")
|
|
print(f"{len(found)} mods, {len(deps)} required dependencies checked")
|
|
for w in warnings:
|
|
print(f" warning: {w}")
|
|
if problems:
|
|
print("\n".join(f" - {p}" for p in problems))
|
|
sys.exit(1)
|
|
print("all required dependencies satisfied")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|