"""Turn the model into fieldguide.json, the single file every renderer reads. Schema (version 1), all ids are namespaced ("cobblemon:eevee"): meta generator info, layers read, status legend, rarity bucket chances, kubejs_export (when the KubeJS recipe export was made, whether it is stale, what it removed/added) island biomes (kind, status), dimensions, structures (status, reason) locations [{id, name, kind, status, spawns: [spawn index]}] in display order spawns [{id, species members, level, bucket, weight, position, conditions, status, biomes, zones}]; zones: the companion mod's zone condition (ids from world/zones.json); species locations carry the zone names too hidden_spawns same shape, for spawn pools a pack.mcmeta filter hides (Cobblemon's own pools, replaced by the zone pools): never in the game, but resolved against the island the same way (status = what it would be if active); tools/zone_spawns.py plans the zones from them species {id: {name, dex, types, status, wild, locations, sources, evolutions, drops, pokedex_keys}} items {id: {name, mod, category, tooltip, status, sources, uses, icon, technical}} (mod items) vanilla {id: {name, status, icon}} for vanilla items the guide mentions recipes {id: {type, label, result, count, grid, slots, script?}}; script ("server_scripts:x.js:12") marks a recipe a pack KubeJS script added or changed problems data the generator could not resolve A runtime exporter or KubeJS dump can replace or extend any section; renderers only read this file. """ import datetime import json from .datasource import split_id from .model import CATEGORIES, TECHNICAL from .world import RANK KIND_ORDER = {"surface": 0, "cave": 1, "water": 2, "end": 3, "nether": 4} def build(model, icons=None): m, isl = model, model.island st = model.get_status icons = icons or {} locations = [] loc_spawns = {b: [] for b in isl.biomes} real_biomes = set(m.stack.ids("worldgen/biome")) # tags also name biomes of mods that are not installed spawns = [_spawn(m, i, e, "spawn", real_biomes) for i, e in enumerate(m.spawns.entries)] for i, e in enumerate(m.spawns.entries): for b in e["biomes"]: loc_spawns.setdefault(b, []).append(i) for b, info in sorted(isl.biomes.items(), key=lambda kv: (KIND_ORDER.get(kv[1]["kind"], 9), kv[0])): if info["kind"] == "nether": continue idx = [i for i in loc_spawns.get(b, []) if spawns[i]["status"] != "unavailable"] locations.append({"id": b, "name": m.biome_name(b), "kind": info["kind"], "status": info["status"], "reason": info.get("reason", ""), "spawns": idx}) species = {} for sid, sp in sorted(m.species.items(), key=lambda kv: (kv[1]["dex"], kv[0])): srcs = m.sources.get(f"species:{sid}", []) wild = [s for s in srcs if s["kind"] == "wild"] wild_status = max((s["status"] for s in wild), key=RANK.get, default="unavailable") locs = {} for s in wild: if RANK[s["status"]]: e = spawns[s["spawn"]] for b in e["biomes"]: lv = next((mm["level"] for mm in e["members"] if mm["species"] == sid), e["level"]) cur = locs.setdefault(b, {"level": None, "alpha": None, "buckets": set(), "special": True, "zones": set()}) cur["zones"] |= set(e["zones"]) k = "alpha" if e["bucket"] == "boss" else "level" cur[k] = list(lv) if cur[k] is None else [min(cur[k][0], lv[0]), max(cur[k][1], lv[1])] cur["buckets"].add(e["bucket"]) cur["special"] = cur["special"] and e["needs_blocks"] other = [_source(m, s) for s in srcs if s["kind"] != "wild"] species[sid] = { "name": sp["name"], "dex": sp["dex"], "types": sp["types"], "implemented": sp["implemented"], "labels": sp["labels"], "status": st(f"species:{sid}"), "wild": wild_status, "locations": [{"biome": b, "name": m.biome_name(b), "level": v["level"] or v["alpha"], "alpha_level": v["alpha"], "buckets": sorted(v["buckets"]), "zones": [m.spawns.zones.get(z, {}).get("name", z) for z in sorted(v["zones"], key=lambda z: m.spawns.zones.get(z, {}).get("levels") or [0])], "special": v["special"]} for b, v in sorted(locs.items(), key=lambda kv: (kv[1]["special"], m.biome_name(kv[0])))], "spawns": [s["spawn"] for s in wild], "sources": sorted(other, key=lambda s: -RANK[s["status"]]), "evolutions": [{"to": ev["to"], "name": m.species_name(ev["to"]), "how": ev["how"], "form": ev["form"]} for ev in m.evo_from.get(sid, [])], "pre": sp["pre"], "drops": _species_drops(sp.get("drops")), "pokedex_keys": sp["pokedex"] + [k for f in sp["forms"] for k in f["pokedex"] if not f["battle_only"]], "spawn_biomes_elsewhere": sorted({n for i in m.spawn_by_species.get(sid, []) for n in spawns[i]["all_biomes"]})[:20], "why_not_wild": sorted({w for i in m.spawn_by_species.get(sid, []) for w in spawns[i]["why_not"]})[:6] if wild_status == "unavailable" else [], } items, vanilla = {}, {} mentioned = set() for iid in m.mod_items: srcs = [_source(m, s) for s in _useful_sources(m, iid)] uses = _dedupe(m.uses.get(iid, [])) for s in srcs: mentioned.update(s.get("mention", [])) items[iid] = { "name": m.item_name(iid), "mod": split_id(iid)[0], "category": _category(m, iid), "tooltip": m.tooltip(iid), "status": st(f"item:{iid}"), "technical": iid in TECHNICAL, "sources": sorted(srcs, key=lambda s: (-RANK[s["status"]], s["kind"])), "uses": uses, "icon": icons.get(iid), "tags": sorted(t for t in m.itags.all_tags() if iid in m.itags.resolve(t))[:12], "reason": _reason(m, iid, srcs), } recipes = {} for rid, r in m.recipes.items(): if r["result"] in items or any(i in items for s in r["slots"] for i in s["items"]): recipes[rid] = {k: r[k] for k in ("type", "label", "result", "count", "grid", "slots", "script") if k in r} recipes[rid]["status"] = _recipe_status(m, rid) mentioned.add(r["result"]) for s in r["slots"]: mentioned.update(s["items"][:8]) for iid in sorted(mentioned): if iid not in items and iid in m.items: vsrcs = [_source(m, s) for s in _useful_sources(m, iid)] vanilla[iid] = {"name": m.item_name(iid), "status": st(f"item:{iid}"), "icon": icons.get(iid), "sources": sorted(vsrcs, key=lambda s: -RANK[s["status"]])[:12], "reason": _reason(m, iid, vsrcs)} missing_vanilla = sorted(i for i in m.vanilla_items if st(f"item:{i}") == "unavailable") spawner = m.stack.get("spawning", "cobblemon:best-spawner-config") or {} return { "meta": { "schema": 1, "generated": datetime.datetime.now().astimezone().isoformat(timespec="seconds"), "layers": [layer.name for layer in m.stack.layers], "world_type": isl.world_type, "statuses": {"available": "obtainable on the island", "conditional": "needs something that may not " "exist (the End, a stronghold inside the border)", "unavailable": "no source on the island"}, "buckets": spawner.get("worldBuckets", {}), "kubejs_export": m.kubejs and {k: m.kubejs[k] for k in ("exported", "kubejs", "stale", "removed")} | {"added_or_changed": sorted(m.kubejs["recipes"])}, }, "island": { "dimensions": isl.dimensions, "biomes": [{"id": b, "name": m.biome_name(b), **{k: v for k, v in info.items()}} for b, info in isl.biomes.items()], "structures": [{"id": s, "name": m.structure_name(s), "status": v["status"], "reason": v["reason"], "biomes": v["biomes"]} for s, v in sorted(isl.structures.items())], "notes": isl.notes, }, "locations": locations, "spawns": spawns, "species": species, "items": items, "vanilla": vanilla, "hidden_spawns": [_spawn(m, i, e, "hidden_spawn", real_biomes) for i, e in enumerate(m.hidden_spawns.entries)], "recipes": recipes, "problems": { **{k: sorted(v) for k, v in m.unresolved.items()}, "vanilla items with no source on the island": missing_vanilla, "unknown tags": sorted(set().union(*(t.missing for t in m.tags.values()))), "unreadable files": m.stack.errors, "spawn pools skipped": [f"{a} ({b})" for a, b in m.spawns.skipped], "zones in spawn conditions but not in world/zones.json": sorted(m.spawns.unknown_zones | m.hidden_spawns.unknown_zones), }, } def _spawn(m, i, e, prefix, real_biomes): isl = m.island status = m.get_status(f"{prefix}:{i}") srcs = m.sources.get(f"{prefix}:{i}", []) conds = [] for c, src in zip(e["conditions"], srcs): conds.append({"text": c["text"], "biomes": c["biomes"], "status": src.get("status"), "structures": c["structures"]["present"], "blocked": _names(m, src.get("blocked_by"))}) return { "i": i, "id": e["id"], "pool": e["pool"], "type": e["type"], "bucket": e["bucket"], "weight": e["weight"], "level": e["level"], "position": e["position"], "max_herd": e["max_herd"], "members": [{"species": mm["species"], "name": m.species_name(mm["species"]), "props": mm["props"], "level": mm["level"], "role": mm["role"], "weight": mm.get("weight")} for mm in e["members"]], "conditions": conds, "anticonditions": [a["text"] for a in e["anticonditions"] if a["text"]], "multipliers": e["multipliers"], "status": status, "biomes": e["biomes"], "zones": e["zones"], # every alternative needs particular blocks nearby (often player-built: concrete, pyramid blocks) "needs_blocks": all(any(t.startswith("near ") and "leaves" not in t for t in c["text"]) for c in e["conditions"]), "all_biomes": sorted({m.biome_name(b) for c in e["conditions"] for b in c.get("all_ids", []) if b in real_biomes and b not in isl.biomes}), "why_not": _why_not(e, srcs, isl, m, real_biomes) if status == "unavailable" else [], "drops": e.get("drops"), } def _why_not(e, srcs, isl, m, real_biomes): """Short reasons a spawn entry cannot happen on the island.""" out = [] for c, src in zip(e["conditions"], srcs): if c["structures"]["refs"] and not c["structures"]["present"]: out.append("needs a structure that does not generate here: " + ", ".join(r.lstrip("#").split(":")[-1].replace("_", " ") for r in c["structures"]["refs"])) elif not c["biomes"]: names = sorted({m.biome_name(b) for b in c.get("all_ids", []) if b in real_biomes}) out.append("biomes not on the island: " + ", ".join(names[:5]) + (" …" if len(names) > 5 else "") if names else "no biome on the island") elif src.get("blocked_by"): out.append("needs " + "; ".join(_names(m, src["blocked_by"]))) return out def _names(m, groups): out = [] for g in groups or []: if not g: out.append("an ingredient no item matches (empty tag)") continue names = [] for k in g[:4]: kind, rid = k.split(":", 1) names.append({"item": m.item_name, "block": m.item_name, "mob": m.entity_name, "species": m.species_name}.get(kind, lambda x: x)(rid) if kind != "spawn" else "a wild spawn") out.append(" or ".join(dict.fromkeys(names)) + (" …" if len(g) > 4 else "")) return out def _source(m, s): out = {"kind": s["kind"], "text": s["text"], "status": s.get("status", "unavailable")} for k in ("chance", "recipe", "places", "biomes", "species", "block", "mob", "table", "fossils", "parents"): if s.get(k) is not None: out[k] = s[k] if s.get("blocked_by"): out["blocked"] = _names(m, s["blocked_by"]) mention = [k.split(":", 1)[1] for g in s.get("needs", []) for k in g[:3] if k.startswith("item:")] if mention: out["mention"] = mention if out.get("biomes"): out["biome_names"] = [m.biome_name(b) for b in out["biomes"]] return out def _reason(m, iid, srcs): """One line on why an item has no source on the island (empty if it has one).""" if m.get_status(f"item:{iid}") != "unavailable": return "" if iid in m.island.removed_blocks: return "ore: its world generation is switched off on this island" blocked = [s for s in srcs if s.get("blocked")] if blocked: return "needs " + blocked[0]["blocked"][0] if srcs: text = srcs[0]["text"] return text if text.endswith(")") else text + " (not on the island)" raw = m.sources.get(f"item:{iid}", []) if raw: return "only from its own block, which never generates here" return "no recipe, loot table, drop or worldgen feature in the data" def _useful_sources(m, iid): """Drop circular 'break the placed block' sources and duplicates.""" out, seen = [], set() for s in m.sources.get(f"item:{iid}", []): if s["kind"] == "block" and s.get("block") == iid and m.island.natural_status(iid)[0] == "unavailable": continue key = (s["kind"], s["text"], s.get("recipe"), s.get("chance")) if key in seen: continue seen.add(key) out.append(s) return out def _dedupe(uses): out, seen = [], set() for u in uses: key = (u["kind"], u["text"]) if key not in seen: seen.add(key) out.append(u) return out def _category(m, iid): for name, tags in CATEGORIES: if any(iid in m.itags.resolve(t) for t in tags): return name if iid in m.blocks: return "Blocks" return "Other" def _recipe_status(m, rid): for s in m.sources.get(f"item:{m.recipes[rid]['result']}", []): if s.get("recipe") == rid: return s["status"] return "unavailable" def _species_drops(drops): out = [] for e in (drops or {}).get("entries", []): chance = f"{e['percentage']:g}%" if "percentage" in e else f"×{e.get('quantityRange', e.get('quantity', 1))}" out.append({"item": e["item"] if ":" in e["item"] else "minecraft:" + e["item"], "chance": chance}) return out def write(data, path): path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data, ensure_ascii=False, indent=1, default=_default) + "\n") def _default(o): if isinstance(o, set): return sorted(o) raise TypeError(type(o))