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

+260
View File
@@ -0,0 +1,260 @@
"""Cobblemon world spawns (data/*/spawn_pool_world), resolved against the island.
Mirrors Cobblemon 1.8.4: a spawn's conditions are OR'd and its anticonditions
veto; presets (spawn_detail_presets) are merged into every condition with
MergeMode.INSERT (lists appended, single values kept if already set) and their
anticondition is appended (SpawnDetailPreset.apply). A biome named in any
anticondition is never considered (BiomePrecalculation).
"""
import json
from .datasource import ROOT, norm_id, split_id
MERGED_FIELDS_SKIP = {"isRaining", "isThundering", "isSlimeChunk", "markers", "isPokeSnack"} # not in copyFrom()
TIME_RANGES = {"day", "night", "morning", "noon", "afternoon", "evening", "midnight", "predawn", "dawn", "dusk",
"twilight", "any"}
MOON = ["full", "waning gibbous", "last quarter", "waning crescent", "new", "waxing crescent", "first quarter",
"waxing gibbous"]
def parse_range(text, default=(1, 100)):
if text is None:
return list(default)
if isinstance(text, (int, float)):
return [int(text), int(text)]
parts = str(text).split("-")
try:
return [int(parts[0]), int(parts[-1])] if parts[0] else list(default)
except ValueError:
return list(default)
def parse_props(text):
"""'venusaur held_item=cobblemon:grass_gem alpha=true' -> species id and extra properties."""
tokens = str(text).split()
species = norm_id(tokens[0] if ":" in tokens[0] else "cobblemon:" + tokens[0]) if tokens else ""
return species, tokens[1:]
def as_list(v):
return [] if v is None else v if isinstance(v, list) else [v]
class Spawns:
def __init__(self, stack, tags, island, pools=None):
"""pools: {pool id: (file, json)} to read instead of the stack's visible spawn_pool_world files."""
self.stack, self.tags, self.island = stack, tags, island
self.btags, self.blocktags = tags["worldgen/biome"], tags["block"]
self.zones = load_zones()
self.unknown_zones = set()
self.presets = {}
for pid, rel in sorted(stack.ids("spawn_detail_presets").items()):
self.presets[split_id(pid)[1]] = stack.json(rel) or {}
self.entries = []
self.skipped = []
modids = stack.modids
if pools is None:
pools = {fid: (rel, stack.json(rel)) for fid, rel in sorted(stack.ids("spawn_pool_world").items())}
for fid, (rel, d) in pools.items():
d = d or {}
if d.get("enabled") is False:
self.skipped.append((fid, "disabled"))
continue
if not self._mods_ok(d, modids):
self.skipped.append((fid, "needs other mods"))
continue
for s in d.get("spawns", []):
if s.get("type") not in ("pokemon", "pokemon-herd") or not self._mods_ok(s, modids):
continue
self.entries.append(self._entry(fid, rel, s))
@staticmethod
def _mods_ok(d, modids):
return (all(m in modids for m in d.get("neededInstalledMods", []))
and not any(m in modids for m in d.get("neededUninstalledMods", [])))
def _entry(self, fid, rel, s):
conds = [dict(c) for c in as_list(s.get("condition")) + as_list(s.get("conditions"))]
antis = [dict(c) for c in as_list(s.get("anticondition")) + as_list(s.get("anticonditions"))]
bucket, position, weight = s.get("bucket"), s.get("spawnablePositionType", s.get("context")), s.get("weight")
for name in s.get("presets", []):
p = self.presets.get(name.split(":")[-1])
if p is None:
continue
bucket = p.get("bucket", bucket)
position = p.get("spawnablePositionType", p.get("context", position))
if p.get("condition"):
if conds:
for c in conds:
for k, v in p["condition"].items():
if k in MERGED_FIELDS_SKIP:
continue
if isinstance(v, list):
c[k] = list(c.get(k, [])) + v
else:
c.setdefault(k, v)
else:
conds.append(dict(p["condition"]))
if p.get("anticondition"):
antis.append(dict(p["anticondition"]))
if s.get("type") == "pokemon":
species, extra = parse_props(s.get("pokemon", ""))
members = [{"species": species, "props": extra, "level": parse_range(s.get("level", s.get("levelRange"))),
"role": "single"}]
else:
members = []
herd = parse_range(s.get("levelRange", s.get("level")))
for m in s.get("herdablePokemon", []):
species, extra = parse_props(m.get("pokemon", ""))
lv = parse_range(m.get("levelRange"), herd)
if m.get("isLeader"): # leaders list 1-100; the herd's own range is what they spawn at
lv = [max(lv[0], herd[0]), min(lv[1], herd[1])]
members.append({"species": species, "props": extra, "level": lv,
"role": "leader" if m.get("isLeader") else "follower", "weight": m.get("weight"),
"max": m.get("maxTimes")})
excluded = set()
for a in antis:
excluded |= self.btags.resolve_all(a.get("biomes", []))
out_conds, biomes, zones = [], set(), set()
for c in conds or [{}]:
oc = self._condition(c)
oc["biomes"] = sorted(set(oc["biomes"]) - excluded)
out_conds.append(oc)
biomes |= set(oc["biomes"])
zones |= set(oc["zones"])
mults = as_list(s.get("weightMultiplier")) + as_list(s.get("weightMultipliers"))
return {
"id": s.get("id") or fid, "file": rel, "pool": fid, "type": s["type"], "bucket": bucket,
"weight": weight, "percentage": s.get("percentage"), "position": position,
"level": parse_range(s.get("level", s.get("levelRange"))) if s.get("type") == "pokemon-herd" else members[0]["level"],
"max_herd": s.get("maxHerdSize"), "members": members, "presets": s.get("presets", []),
"conditions": out_conds, "anticonditions": [self._condition(a, anti=True) for a in antis],
"biomes": sorted(biomes), "zones": sorted(zones), "multipliers": [{"multiplier": m.get("multiplier"),
"when": self._condition(m.get("condition", {}))["text"]}
for m in mults],
"drops": s.get("drops"),
}
def _condition(self, c, anti=False):
"""Condition JSON -> island biomes, structure/block requirements and readable tokens."""
isl = self.island
if c.get("biomes"):
allb = self.btags.resolve_all(c["biomes"])
here = isl.island_biomes(allb)
else:
allb, here = None, sorted(b for b in isl.biomes if isl.biomes[b]["kind"] != "nether")
text = []
# the companion mod's zone condition (mod/.../zone/Zones.kt): "zones" wins over "zone"
zones = as_list(c["zones"]) if c.get("zones") is not None else as_list(c.get("zone"))
if zones:
text.append(("zones " if len(zones) > 1 else "zone ") + ", ".join(self._zone_label(z) for z in zones))
if c.get("dimensions"):
text.append("dimension " + ", ".join(c["dimensions"]))
if c.get("structures"):
st, which = isl.structure_status(c["structures"])
text.append("in/near " + ", ".join(_short(x) for x in c["structures"]))
# the structure decides where this can happen: only biomes the structures generate in
if not anti:
where = {b for w in which for b in isl.structures[w]["biomes"]}
here = [b for b in here if b in where]
else:
st, which = None, []
tr = c.get("timeRange")
if tr:
text.append(tr if tr in TIME_RANGES else f"time {tr}")
if c.get("moonPhase") is not None:
phases = []
for part in str(c["moonPhase"]).split(","):
lo, _, hi = part.partition("-")
if lo.strip().isdigit():
phases += [MOON[i % 8] for i in range(int(lo), int(hi or lo) + 1)]
else:
phases.append(part.strip())
text.append("moon: " + ", ".join(phases))
if c.get("canSeeSky") is True:
text.append("open sky")
elif c.get("canSeeSky") is False:
text.append("no sky (under cover)")
if c.get("isRaining") is True:
text.append("raining")
elif c.get("isRaining") is False:
text.append("not raining")
if c.get("isThundering"):
text.append("thunderstorm")
lo, hi = c.get("minSkyLight"), c.get("maxSkyLight")
if (lo, hi) not in ((None, None), (0, 15)) and not (lo in (None, 0) and hi in (None, 15)):
text.append(f"sky light {lo if lo is not None else 0}–{hi if hi is not None else 15}")
lo, hi = c.get("minLight"), c.get("maxLight")
if lo is not None or hi is not None:
text.append(f"light {lo if lo is not None else 0}–{hi if hi is not None else 15}")
lo, hi = c.get("minY"), c.get("maxY")
if lo is not None and hi is not None:
text.append(f"y {int(lo)}…{int(hi)}")
elif lo is not None:
text.append(f"y ≥ {int(lo)}")
elif hi is not None:
text.append(f"y ≤ {int(hi)}")
for axis in ("X", "Z"):
lo, hi = c.get("min" + axis), c.get("max" + axis)
if lo is not None or hi is not None:
text.append(f"{axis.lower()} {'' if lo is None else int(lo)}…{'' if hi is None else int(hi)}")
if c.get("minHeight") is not None or c.get("maxHeight") is not None:
text.append(f"space {c.get('minHeight', '')}–{c.get('maxHeight', '')} high")
near = c.get("neededNearbyBlocks") or []
base = c.get("neededBaseBlocks") or []
if base and base != ["#cobblemon:natural"]:
text.append("standing on " + ", ".join(_short(b) for b in base))
if near:
text.append("near " + ", ".join(_short(b) for b in near))
if c.get("fluid"):
text.append("in " + _short(c["fluid"]))
if c.get("isSlimeChunk"):
text.append("slime chunk")
if c.get("isPokeSnack") is True:
text.append("lured by a Poké Snack")
elif c.get("isPokeSnack") is False and not anti:
pass
if c.get("minLureLevel"):
text.append(f"Lure {c['minLureLevel']}+" + (f" (max {c['maxLureLevel']})" if c.get("maxLureLevel") else ""))
if c.get("bait"):
text.append("bait " + _short(c["bait"]))
if c.get("rodType"):
text.append("rod " + _short(c["rodType"]))
if c.get("markers"):
text.append("markers " + ", ".join(c["markers"]))
if anti and c.get("biomes"):
text.insert(0, "biomes " + ", ".join(_short(b) for b in c["biomes"]))
return {
"biomes": here, "all_ids": sorted(allb) if allb else [],
"structures": {"refs": c.get("structures", []), "status": st, "present": which},
"near": [sorted(self.blocktags.resolve(b)) for b in near],
"near_refs": near, "base": [sorted(self.blocktags.resolve(b)) for b in base], "base_refs": base,
"poke_snack": c.get("isPokeSnack") is True, "zones": zones, "text": text,
}
def _zone_label(self, zid):
z = self.zones.get(zid)
if not z:
self.unknown_zones.add(zid)
return zid
return f"{z['name']} (Lv {z['levels'][0]}–{z['levels'][1]})"
def load_zones():
"""{zone id: {name, levels}} from world/zones.json (surface zones and caves)."""
path = ROOT / "world/zones.json"
if not path.exists():
return {}
plan = json.loads(path.read_text())
return {z["id"]: {"name": z.get("name", z["id"]), "levels": z.get("levels", [None, None])}
for z in plan.get("zones", []) + plan.get("caves", [])}
def _short(ref):
"""'#cobblemon:natural' -> '#natural', 'minecraft:oak_leaves' -> 'oak leaves'."""
tag = ref.startswith("#")
ns, path = split_id(ref)
name = path.replace("_", " ").replace("/", ": ")
if tag:
return "#" + (name if ns in ("minecraft", "cobblemon", "c") else f"{ns}:{name}")
return name