"""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