Cobblemon Survival Pack: phases 0-2 and Phase 3 zones and hazards

Free NeoForge 1.21.1 modpack on Cobblemon 1.8.4 built with packwiz, plus
the Kotlin companion mod (cobblesurvival) and its built-in data pack.

- Phase 0: packwiz pack with Modrinth/CurseForge cross-links, build and
  export tooling, Prism instance sync, test server with smoke checks.
- Phase 1: Poke Ball shaped island (WorldGen Editor preset, seed isle-26),
  Adrian's biome list, no natural ore, structures without ore or vanilla
  mobs, no Nether portals, world template builder and world report.
- Phase 2: permadeath (CobbleHardcoreMon plus the companion mod's gap
  fixes), Revival Charm, expensive Healing Machine, Lootr refreshing
  loot, in-game self-tests on Minecraft's GameTest framework.
- Phase 3 so far: 14 zones with their own spawn pools and level bands
  (tools/zone_layout.py, tools/zone_spawns.py, "zone" spawn condition),
  Cold Sweat heat and cold as gates with boss-drop insulation recipes,
  spawn survey and hazard self-tests in tools/quick_check.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-10-29 19:01:21 +01:00
commit 398301f0be
232 files changed
+72665

No files matched your search

+266
View File
@@ -0,0 +1,266 @@
"""Layered, read-only view of the game's resources, resolved like Minecraft's pack stack.
Layers from bottom to top: vanilla server data (plus the vanilla client jar for
textures, if Prism has it), NeoForge, every mod jar listed in pack/mods, then
datapacks/core. A higher layer replaces a file with the same path, tags are
merged over all layers, and a pack.mcmeta "filter" block hides matching files
of every layer below it (how Phase 3 can blank Cobblemon's own spawn pools).
Jars are copied into build/fieldguide-cache/jars first, so a server or Prism
instance that replaces its jars mid-run cannot change what we read.
"""
import json
import re
import shutil
import tomllib
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
BUILD = ROOT / "build"
CACHE = BUILD / "fieldguide-cache"
PRISM = Path.home() / ".var/app/org.prismlauncher.PrismLauncher/data/PrismLauncher"
MOD_DIRS = [BUILD / "stage/full/mods", BUILD / "server/mods",
PRISM / "instances/Cobblemon Survival Pack (dev)/minecraft/mods"]
# Only these asset subtrees are indexed; Cobblemon alone ships ~20k model/sound files we never read.
ASSET_DIRS = ("lang/", "models/item/", "models/block/", "textures/item/", "textures/block/", "blockstates/")
class Layer:
"""One resource pack: a jar or a folder, with its pack.mcmeta filter."""
def __init__(self, name, path, roots=("data", "assets"), modids=()):
self.name, self.path, self.modids = name, Path(path), list(modids)
self.files = {}
self.filters = []
self.zip = zipfile.ZipFile(self.path) if self.path.is_file() else None
names = self.zip.namelist() if self.zip else [
p.relative_to(self.path).as_posix() for p in self.path.rglob("*") if p.is_file()]
for n in names:
parts = n.split("/", 3)
if len(parts) < 4 or parts[0] not in roots or n.endswith("/"):
continue
if parts[0] == "assets" and not parts[2].startswith(("lang", "models", "textures", "blockstates")):
continue
if parts[0] == "assets" and not (parts[2] + "/" + parts[3]).startswith(ASSET_DIRS):
continue
self.files[n] = n
meta = self.read("pack.mcmeta", missing_ok=True)
if meta:
block = json.loads(meta).get("filter", {}).get("block", [])
self.filters = [(re.compile(f.get("namespace", ".*")), re.compile(f.get("path", ".*"))) for f in block]
def read(self, rel, missing_ok=False):
try:
return self.zip.read(rel) if self.zip else (self.path / rel).read_bytes()
except (KeyError, FileNotFoundError):
if missing_ok:
return None
raise
def __repr__(self):
return f"Layer({self.name})"
def loads(raw):
"""JSON as Minecraft's lenient reader accepts it (a few mod files carry // comments)."""
text = raw.decode("utf-8-sig")
try:
return json.loads(text)
except json.JSONDecodeError:
text = re.sub(r"^\s*//.*$", "", text, flags=re.M)
return json.loads(re.sub(r",(\s*[}\]])", r"\1", text))
class DataStack:
def __init__(self, layers):
self.layers = layers
self.index = {}
self.errors = []
self._ids = {}
self._json = {}
for i, layer in enumerate(layers):
for rel in layer.files:
self.index.setdefault(rel, []).append(i)
self.unfiltered = {rel: list(idxs) for rel, idxs in self.index.items()}
for i, layer in enumerate(layers):
if not layer.filters:
continue
for rel, idxs in self.index.items():
ns, path = rel.split("/", 2)[1:]
if any(n.fullmatch(ns) and p.fullmatch(path) for n, p in layer.filters):
self.index[rel] = [j for j in idxs if j >= i]
@property
def modids(self):
return {"minecraft", "neoforge", "c"} | {m for layer in self.layers for m in layer.modids}
def exists(self, rel):
return bool(self.index.get(rel))
def read(self, rel):
idxs = self.index.get(rel)
return self.layers[idxs[-1]].read(rel) if idxs else None
def layer_of(self, rel):
idxs = self.index.get(rel)
return self.layers[idxs[-1]] if idxs else None
def json(self, rel):
if rel not in self._json:
raw = self.read(rel)
try:
self._json[rel] = loads(raw) if raw is not None else None
except (ValueError, UnicodeDecodeError) as e:
self.errors.append(f"{rel}: {e}")
self._json[rel] = None
return self._json[rel]
def all_json(self, rel):
"""Every layer's copy of a file, bottom to top (tags merge these)."""
out = []
for i in self.index.get(rel, []):
try:
out.append(loads(self.layers[i].read(rel)))
except (ValueError, UnicodeDecodeError) as e:
self.errors.append(f"{self.layers[i].name}:{rel}: {e}")
return out
def ids(self, kind, root="data", ext=".json"):
"""{namespace:path -> rel} for every visible resource of a kind, e.g. ids('recipe')."""
key = (root, kind, ext)
if key not in self._ids:
found = {}
for rel, idxs in self.index.items():
if not idxs or not rel.startswith(root + "/") or not rel.endswith(ext):
continue
_, ns, rest = rel.split("/", 2)
if rest.startswith(kind + "/"):
found[f"{ns}:{rest[len(kind) + 1:-len(ext)]}"] = rel
self._ids[key] = found
return self._ids[key]
def hidden(self, kind, root="data", ext=".json"):
"""{namespace:path -> json} for resources of a kind that a pack.mcmeta filter hides from every layer,
read from the topmost layer that has them (e.g. Cobblemon's own spawn pools under datapacks/core's filter)."""
out = {}
for rel, idxs in self.unfiltered.items():
if self.index.get(rel) or not rel.startswith(root + "/") or not rel.endswith(ext):
continue
_, ns, rest = rel.split("/", 2)
if rest.startswith(kind + "/"):
try:
out[f"{ns}:{rest[len(kind) + 1:-len(ext)]}"] = (rel, loads(self.layers[idxs[-1]].read(rel)))
except (ValueError, UnicodeDecodeError) as e:
self.errors.append(f"{self.layers[idxs[-1]].name}:{rel}: {e}")
return dict(sorted(out.items()))
def resource(self, kind, rid, root="data", ext=".json"):
ns, path = split_id(rid)
return f"{root}/{ns}/{kind}/{path}{ext}"
def get(self, kind, rid):
return self.json(self.resource(kind, rid))
def split_id(rid):
rid = rid.lstrip("#")
return rid.split(":", 1) if ":" in rid else ("minecraft", rid)
def norm_id(rid):
ns, path = split_id(rid)
return f"{ns}:{path}"
# --- locating and caching jars -----------------------------------------------------------
def pack_mod_files():
"""Jar filenames the pack defines (pack/mods/*.pw.toml)."""
out = []
for toml in sorted((ROOT / "pack/mods").glob("*.pw.toml")):
data = tomllib.loads(toml.read_text())
if data.get("filename", "").endswith(".jar"):
out.append(data["filename"])
return out
def find_jar(name, dirs=MOD_DIRS):
for d in dirs:
if (d / name).is_file():
return d / name
return None
def companion_jar():
for d in MOD_DIRS:
jars = sorted(d.glob("cobblesurvival-*.jar"), key=lambda p: p.stat().st_mtime)
if jars:
return jars[-1]
return None
def cached(src):
"""Copy a jar into build/fieldguide-cache/jars unless an identical copy is there."""
dst = CACHE / "jars" / src.name
st = src.stat()
if not dst.exists() or dst.stat().st_size != st.st_size or int(dst.stat().st_mtime) != int(st.st_mtime):
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
return dst
def jar_modids(path):
with zipfile.ZipFile(path) as z:
try:
toml = tomllib.loads(z.read("META-INF/neoforge.mods.toml").decode())
except (KeyError, tomllib.TOMLDecodeError, UnicodeDecodeError):
return []
return [m["modId"] for m in toml.get("mods", []) if "modId" in m]
def newest(pattern_dir, pattern):
hits = sorted(Path(pattern_dir).glob(pattern)) if Path(pattern_dir).exists() else []
return hits[-1] if hits else None
def build_stack(datapack=ROOT / "datapacks/core", extra_layers=(), log=print):
"""Create the layer stack. Missing optional inputs are reported, missing required ones raise."""
pack_toml = tomllib.loads((ROOT / "pack/pack.toml").read_text())
mc = pack_toml.get("versions", {}).get("minecraft", "1.21.1")
neo = pack_toml.get("versions", {}).get("neoforge")
libs = BUILD / "server/libraries"
vanilla = newest(libs / "net/minecraft/server", f"{mc}-*/server-{mc}-*-extra.jar")
if not vanilla:
raise SystemExit(f"vanilla data jar not found under {libs} (run tools/smoke_test.py --load-only once)")
layers = []
client = PRISM / f"libraries/com/mojang/minecraft/{mc}/minecraft-{mc}-client.jar"
if client.exists():
layers.append(Layer("minecraft-client", cached(client), roots=("assets",)))
else:
log(f"note: no vanilla client jar at {client}; vanilla items get no icons")
layers.append(Layer("minecraft", cached(vanilla), modids=["minecraft"]))
neo_jar = newest(libs / "net/neoforged/neoforge", f"{neo or '*'}/neoforge-*-universal.jar")
if neo_jar:
layers.append(Layer("neoforge", cached(neo_jar), modids=["neoforge"]))
else:
log("note: NeoForge universal jar not found; c: tags from NeoForge are missing")
mods = []
for name in pack_mod_files():
src = find_jar(name)
if not src:
log(f"warning: {name} (pack/mods) not found in {', '.join(str(d) for d in MOD_DIRS)}")
continue
mods.append(cached(src))
comp = companion_jar()
if comp:
mods.append(cached(comp))
for jar in sorted(mods, key=lambda p: p.name.lower()):
layers.append(Layer(jar.name, jar, modids=jar_modids(jar)))
for extra in extra_layers:
layers.append(Layer(Path(extra).name, extra))
layers.append(Layer("datapacks/core", datapack, roots=("data",)))
# KubeJS serves pack/kubejs/data as a data pack and pack/kubejs/assets as a resource pack.
if (ROOT / "pack/kubejs").is_dir():
layers.append(Layer("pack/kubejs", ROOT / "pack/kubejs"))
return DataStack(layers)