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>
261 lines
12 KiB
Python
261 lines
12 KiB
Python
"""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
|