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>
300 lines
15 KiB
Python
300 lines
15 KiB
Python
"""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))
|
||
|