Files
claudeandClaude Opus 5.5 398301f0be 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>
2026-10-29 19:01:21 +01:00

261 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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