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>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-10-29 19:01:21 +01:00
commit 398301f0be
232 files changed
+72665

No files matched your search

+793
View File
@@ -0,0 +1,793 @@
"""The field guide model: items, recipes, loot, Pokémon and what is obtainable on the island.
Every way to get something is a *source* with a base status (from the island
model) and *needs*: groups of node keys (item:, block:, mob:, species:,
spawn:) of which at least one per group must be obtainable. A fixpoint over
all nodes then decides what the island can provide, so an item whose only
recipe needs Nether quartz is flagged even though the recipe exists.
"""
import json
from collections import defaultdict
from .datasource import BUILD, norm_id, split_id
from .loot import Loot
from .spawns import Spawns, parse_props
from .tags import Tags
from .world import CODE_LOOT, RANK, Island
STATUS = {v: k for k, v in RANK.items()}
REGISTRIES = ("worldgen/biome", "item", "block", "worldgen/structure", "worldgen/placed_feature", "entity_type", "fluid")
# Recipe types -> the station item that must exist (crafting tables and furnaces are always reachable).
STATIONS = {
"minecraft:blasting": "minecraft:blast_furnace", "minecraft:smoking": "minecraft:smoker",
"minecraft:campfire_cooking": "minecraft:campfire", "minecraft:stonecutting": "minecraft:stonecutter",
"minecraft:smithing_transform": "minecraft:smithing_table", "minecraft:smithing_trim": "minecraft:smithing_table",
"cobblemon:cooking_pot": "#cobblemon:campfire_pots", "cobblemon:cooking_pot_shapeless": "#cobblemon:campfire_pots",
"cobblemon:brewing_stand": "minecraft:brewing_stand",
}
RECIPE_LABEL = {
"crafting_shaped": "Crafting", "crafting_shapeless": "Crafting (shapeless)", "smelting": "Smelting",
"blasting": "Blast furnace", "smoking": "Smoker", "campfire_cooking": "Campfire", "stonecutting": "Stonecutter",
"smithing_transform": "Smithing", "cooking_pot": "Cooking Pot", "cooking_pot_shapeless": "Cooking Pot (shapeless)",
"brewing_stand": "Brewing stand",
}
# KubeJS's own crafting types (used when a recipe needs KubeJS features) read like vanilla's.
KUBEJS_TYPES = {"kubejs:shaped": "minecraft:crafting_shaped", "kubejs:shapeless": "minecraft:crafting_shapeless"}
# Creative-only or operator items: listed, but never flagged as "no source".
TECHNICAL = {"cobblemon:pokemon_model", "cobblemon:npc_editor", "cobblemon:habitat_block",
"ftblibrary:fluid_container", "ftblibrary:icon_item", "ftbquests:barrier", "ftbquests:stage_barrier", "ftbquests:detector",
"ftbquests:custom_icon", "ftbquests:missing_item", "ftbquests:lootcrate", "ftbquests:banner",
"ftbquests:loot_crate_opener", "ftbquests:screen_1", "ftbquests:screen_3", "ftbquests:screen_5",
"ftbquests:screen_7", "ftbquests:task_screen_configurator", "ftbquests:progress_detector"}
CATEGORIES = [ # first match wins
("Poké Balls", ["#cobblemon:poke_balls"]), ("Poké Rods", ["#cobblemon:poke_rods"]),
("Evolution items", ["#cobblemon:evolution_items", "#cobblemon:evolution_stones"]),
("Fossils", ["#cobblemon:fossils"]), ("Berries", ["#cobblemon:berries"]),
("Apricorns", ["#cobblemon:apricorns", "#cobblemon:apricorn_sprouts"]), ("Mints", ["#cobblemon:mints", "#cobblemon:mint_leaves", "#cobblemon:mint_seeds"]),
("Medicine", ["#cobblemon:potions", "#cobblemon:remedies", "#cobblemon:revives", "#cobblemon:ethers",
"#cobblemon:full_heal_bottles", "#cobblemon:restores"]),
("Vitamins & candy", ["#cobblemon:vitamins", "#cobblemon:candies", "#cobblemon:experience_candies",
"#cobblemon:iv_candies", "#cobblemon:mochis", "#cobblemon:feathers", "#cobblemon:ability_changers"]),
("Held items", ["#cobblemon:held/is_held_item"]), ("Type gems", ["#cobblemon:type_gems"]),
("Food & cooking", ["#cobblemon:poke_food", "#cobblemon:regional_delicacies", "#cobblemon:sweets",
"#cobblemon:aprijuices", "#cobblemon:teacups", "#cobblemon:herbs", "#cobblemon:seeds"]),
("Machines", ["#cobblemon:machines", "#cobblemon:fossil_machine_parts", "#cobblemon:pokedex"]),
]
class Model:
def __init__(self, stack, log=print, extras=(), kubejs=None):
self.stack, self.log, self.kubejs = stack, log, kubejs
self.tags = {r: Tags(stack, r) for r in REGISTRIES}
self.itags = self.tags["item"]
log("island: biomes, structures, natural blocks, mobs")
self.island = Island(stack, self.tags, log)
log("spawns")
self.spawns = Spawns(stack, self.tags, self.island)
# Spawn pools a pack.mcmeta filter hides (Cobblemon's own, replaced by the zone pools): not in the game,
# but resolved the same way for tools that plan from them (tools/zone_spawns.py).
self.hidden_spawns = Spawns(stack, self.tags, self.island, pools=stack.hidden("spawn_pool_world"))
self.loot = Loot(stack, self.itags)
self.lang = self._lang()
self.sources = defaultdict(list) # node key -> [source]
self.uses = defaultdict(list) # item -> [use]
self.recipes = {}
self.pack_recipes = set() # recipe ids added or changed by the pack's KubeJS scripts
self.referenced = set()
self.unresolved = defaultdict(set)
self._registry_vanilla()
log("species")
self._species()
log("recipes")
self._recipes()
self._kubejs(kubejs)
log("loot tables")
self._loot_sources()
self._drops()
self._berries()
self._fossils()
self._interactions()
self._manual()
for extra in extras:
self._extra(extra)
self._registry_mods()
self._world_nodes()
self._spawn_nodes()
self._evolutions()
self._uses()
log("solving availability")
self._solve()
# --- names --------------------------------------------------------------------------------
def _lang(self):
lang = {}
for rel, idxs in self.stack.index.items():
if rel.endswith("/lang/en_us.json") and rel.startswith("assets/"):
for d in self.stack.all_json(rel):
lang.update(d)
return lang
def item_name(self, iid):
ns, path = split_id(iid)
for key in (f"item.{ns}.{path}", f"block.{ns}.{path}"):
if key in self.lang:
return self.lang[key]
return path.replace("_", " ").title()
def biome_name(self, b):
ns, path = split_id(b)
return self.lang.get(f"biome.{ns}.{path}", path.replace("_", " ").title())
def entity_name(self, e):
ns, path = split_id(e)
return self.lang.get(f"entity.{ns}.{path}", path.replace("_", " ").title())
def structure_name(self, s):
ns, path = split_id(s)
return path.split("/")[-1].replace("_", " ").title() + ("" if ns == "minecraft" else f" ({ns})")
def tooltip(self, iid):
ns, path = split_id(iid)
lines = []
for suffix in (".tooltip", ".tooltip_1", ".tooltip_2", ".desc"):
for kind in ("item", "block"):
if f"{kind}.{ns}.{path}{suffix}" in self.lang:
lines.append(self.lang[f"{kind}.{ns}.{path}{suffix}"])
return lines
# --- registries ---------------------------------------------------------------------------
def _registry_vanilla(self):
rep = BUILD / ".cache/datagen-1.21.1/reports/registries.json"
if rep.exists():
reg = json.loads(rep.read_text())
self.vanilla_items = set(reg["minecraft:item"]["entries"])
self.vanilla_blocks = set(reg["minecraft:block"]["entries"])
self.vanilla_entities = set(reg["minecraft:entity_type"]["entries"])
else:
self.log(f"note: {rep} missing; vanilla registries guessed from the language file")
self.vanilla_items = {"minecraft:" + k.split(".", 2)[2] for k in self.lang
if k.startswith(("item.minecraft.", "block.minecraft.")) and k.count(".") == 2}
self.vanilla_blocks = {"minecraft:" + k.split(".", 2)[2] for k in self.lang
if k.startswith("block.minecraft.") and k.count(".") == 2}
self.vanilla_entities = set()
self.blocks = set(self.vanilla_blocks) | {k for k in self.stack.ids("blockstates", root="assets")
if not k.startswith("minecraft:")}
def _registry_mods(self):
"""Mod items: item models that have a name in the language file or are used as an item in data."""
models = {k for k in self.stack.ids("models/item", root="assets") if not k.startswith("minecraft:")}
named = set()
for k in self.lang:
parts = k.split(".")
if len(parts) == 3 and parts[0] in ("item", "block") and parts[1] != "minecraft":
named.add(f"{parts[1]}:{parts[2]}")
mods = {m for m in models if m in named or m in self.referenced}
# Items a KubeJS startup script registers have no model in any jar (KubeJS makes it at runtime);
# the server's item registry from the KubeJS export has them.
mods |= {i for i in (self.kubejs or {}).get("items", []) if not i.startswith("minecraft:")}
self.mod_items = sorted(mods)
self.items = self.vanilla_items | set(self.mod_items)
missing = {r for r in self.referenced if r not in self.items and not r.startswith("#")
and split_id(r)[0] in self.stack.modids}
for r in sorted(missing):
self.unresolved["item ids used in data but not registered (mod not installed?)"].add(r)
# --- species --------------------------------------------------------------------------------
def _species(self):
self.species = {}
for sid, rel in self.stack.ids("species").items():
d = self.stack.json(rel) or {}
ns, path = split_id(sid)
key = f"{ns}:{path.split('/')[-1]}"
forms = d.get("forms", [])
self.species[key] = {
"id": key, "name": self.lang.get(f"{ns}.species.{key.split(':')[1]}.name", d.get("name", key)),
"dex": d.get("nationalPokedexNumber", 0), "types": [t for t in (d.get("primaryType"), d.get("secondaryType")) if t],
"implemented": d.get("implemented", False) is True, "labels": d.get("labels", []),
"pre": norm_id("cobblemon:" + d["preEvolution"].split()[0]) if d.get("preEvolution") else None,
"evolutions": [dict(e, _form=None) for e in d.get("evolutions", [])] +
[dict(e, _form=f.get("name")) for f in forms for e in (f.get("evolutions") or [])],
"drops": d.get("drops"), "pokedex": d.get("pokedex", []),
"forms": [{"name": f.get("name"), "aspects": f.get("aspects", []), "pokedex": f.get("pokedex", []),
"battle_only": f.get("battleOnly", False)} for f in forms],
}
for e in (d.get("drops") or {}).get("entries", []):
self.referenced.add(norm_id(e["item"]))
def species_name(self, sid):
return self.species.get(sid, {}).get("name") or sid.split(":")[-1].title()
# --- recipes --------------------------------------------------------------------------------
def _conditions_ok(self, d):
conds = d.get("neoforge:conditions")
return all(self._cond(c) for c in conds) if conds else True
def _cond(self, c):
t = c.get("type", "")
if t == "neoforge:mod_loaded":
return c.get("modid") in self.stack.modids
if t == "neoforge:not":
return not self._cond(c.get("value", {}))
if t == "neoforge:and":
return all(self._cond(v) for v in c.get("values", []))
if t == "neoforge:or":
return any(self._cond(v) for v in c.get("values", []))
if t == "neoforge:false":
return False
if t == "neoforge:tag_empty":
return not self.itags.resolve("#" + c.get("tag", ""))
if t == "neoforge:item_exists":
return True
return True
def ingredient(self, ing):
"""Ingredient JSON -> {"items": [...], "tag": label or None}."""
if ing is None:
return None
if isinstance(ing, list):
items, tags = [], []
for i in ing:
r = self.ingredient(i)
if r:
items += r["items"]
tags.append(r["tag"])
return {"items": sorted(set(items)), "tag": " / ".join(t for t in tags if t) or None}
if isinstance(ing, str):
ing = {"tag": ing[1:]} if ing.startswith("#") else {"item": ing}
t = ing.get("type", "")
if "item" in ing:
iid = norm_id(ing["item"])
self.referenced.add(iid)
return {"items": [iid], "tag": None}
if "tag" in ing:
items = sorted(self.itags.resolve("#" + ing["tag"]))
if not items:
self.unresolved["empty item tags in recipes"].add("#" + ing["tag"])
return {"items": items, "tag": "#" + norm_id(ing["tag"])}
if t in ("neoforge:compound",):
return self.ingredient(ing.get("children", []))
if t == "neoforge:difference":
base, sub = self.ingredient(ing.get("base")), self.ingredient(ing.get("subtracted"))
return {"items": sorted(set(base["items"]) - set(sub["items"] if sub else [])), "tag": base["tag"]}
if t == "neoforge:intersection":
parts = [self.ingredient(c) for c in ing.get("children", [])]
items = set(parts[0]["items"]) if parts else set()
for p in parts[1:]:
items &= set(p["items"])
return {"items": sorted(items), "tag": None}
if "items" in ing:
return self.ingredient(ing["items"])
self.unresolved["unknown ingredient types"].add(t or json.dumps(ing)[:60])
return {"items": [], "tag": None}
def _recipes(self):
for rid, rel in sorted(self.stack.ids("recipe").items()):
d = self.stack.json(rel)
if d and self._conditions_ok(d):
self._recipe(rid, d)
def _recipe(self, rid, d, script=None):
"""One recipe JSON -> self.recipes[rid] and a source for its result. script: the KubeJS script line
that added or changed it."""
rtype = norm_id(d.get("type", ""))
rtype = KUBEJS_TYPES.get(rtype, rtype)
res = d.get("result")
if isinstance(res, str):
res = {"id": res}
if not isinstance(res, dict) or not (res.get("id") or res.get("item")):
return # special recipes (dyeing, map cloning, trims) have no fixed result
out = norm_id(res.get("id") or res.get("item"))
count = res.get("count", 1)
grid, slots, extra = None, [], {}
if rtype.endswith(("crafting_shaped", "cooking_pot")):
keys = {k: self.ingredient(v) for k, v in d.get("key", {}).items()}
pattern = d.get("pattern", [])
grid = [[keys.get(ch) if ch != " " else None for ch in row] for row in pattern]
for row in grid:
slots += [s for s in row if s]
elif rtype.endswith(("crafting_shapeless", "cooking_pot_shapeless")):
slots = [self.ingredient(i) for i in d.get("ingredients", [])]
elif rtype.endswith(("smelting", "blasting", "smoking", "campfire_cooking", "stonecutting")):
slots = [self.ingredient(d.get("ingredient"))]
extra = {"time": d.get("cookingtime")}
elif rtype.endswith("smithing_transform"):
slots = [self.ingredient(d.get(k)) for k in ("template", "base", "addition")]
elif rtype == "cobblemon:brewing_stand":
slots = [self.ingredient(d.get("input")), self.ingredient(d.get("bottle"))]
else:
for k in ("ingredient", "ingredients", "input", "base", "addition", "template", "bottle"):
if k in d:
v = self.ingredient(d[k])
slots += [v] if v else []
if "key" in d:
slots += [self.ingredient(v) for v in d["key"].values()]
self.unresolved["recipe types read generically"].add(rtype)
slots = [s for s in slots if s]
self.referenced.add(out)
label = RECIPE_LABEL.get(rtype.split(":")[1], rtype) + (" (pack recipe)" if script else "")
recipe = {"id": rid, "type": rtype, "label": label, "result": out, "count": count, "grid": grid, "slots": slots,
**extra, **({"script": script} if script else {})}
self.recipes[rid] = recipe
needs = [s["items"] and [f"item:{i}" for i in s["items"]] or [] for s in slots]
station = STATIONS.get(rtype)
if station:
needs.append([f"item:{i}" for i in self.itags.resolve(station)])
self.add(f"item:{out}", "recipe", f"{recipe['label']}", needs=needs, recipe=rid)
def _remove_recipe(self, rid):
r = self.recipes.pop(rid, None)
if r:
key = f"item:{r['result']}"
self.sources[key] = [s for s in self.sources[key] if s.get("recipe") != rid]
return r
def _kubejs(self, export):
"""What the pack's KubeJS scripts did to the recipes, read from the test server (kubejs_export.py)."""
if not export:
self.unresolved["KubeJS recipes not read (no export; see docs/field-guide.md)"].add("pack/kubejs/server_scripts")
return
for rid in export["removed"]:
if not self._remove_recipe(rid):
self.unresolved["recipes KubeJS removed that the data does not have"].add(rid)
for rid, d in sorted(export["recipes"].items()):
self._remove_recipe(rid) # changed in place (replaceInput, ...): the export has the final JSON
marker = d.get("_kubejs_changed_marker", {})
self._recipe(rid, d, script=f"{marker.get('source')}:{marker.get('line')}")
self.pack_recipes.add(rid)
if export["stale"]:
self.unresolved["KubeJS recipe export older than the pack (rerun tools/fieldguide/build.py)"].add(
f"exported {export['exported']}")
# --- loot -------------------------------------------------------------------------------
def _loot_places(self):
"""loot table -> [(place text, status, needs)] for chests, archaeology and features."""
places = defaultdict(list)
for sid, s in self.island.structures.items():
if not RANK[s["status"]] and not s["loot"]:
s["loot"] = self.island.walk(s["data"], start=sid)["loot"] | {
f"minecraft:{p}" for p in CODE_LOOT.get(s["type"], [])}
label = self.structure_name(sid) + ("" if RANK[s["status"]] else " (not on the island)")
for t in s["loot"]:
places[t].append((label, s["status"], sid))
for t, f in self.island.feature_loot.items():
st = self.island.biome_status(f["biomes"])
if RANK[st]:
via = sorted(f["via"])[0]
places[t].append((via.split(":")[-1].split("/")[-1].replace("_", " ").title() + " (worldgen feature)",
st, via))
for tid in list(self.loot.ids):
ns, path = split_id(tid)
if ns == "cobblemon" and path.startswith("injection/"):
target = path[len("injection/"):]
targets = ([f"minecraft:chests/village/village_{v}_house" for v in
("desert", "plains", "savanna", "snowy", "taiga")]
if target == "chests/village_house" else [f"minecraft:{target}"])
for tg in targets:
for p in places.get(tg, []):
places[tid].append((p[0] + " (Cobblemon extra loot)", p[1], p[2]))
return places
def _loot_sources(self):
places = self._loot_places()
alpha_spawns = None
for tid in sorted(self.loot.ids):
ns, path = split_id(tid)
if ns not in self.stack.modids:
continue # data for a mod that is not installed (Cobblemon ships some for Adorn, Botany Pots)
data = self.loot.table(tid) or {}
drops = self.loot.flatten(tid)
if not drops:
continue
for d in drops:
self.referenced.add(d["item"])
ltype = data.get("type", "")
common = {"table": tid}
if path.startswith("blocks/"):
block = f"{ns}:{path[7:]}"
for d in drops:
self.add(f"item:{d['item']}", "block", f"Break {self.item_name(block)}",
needs=[[f"block:{block}"]], block=block, **_chance(d), **common)
elif path.startswith("entities/"):
mob = f"{ns}:{path[9:].split('/')[0]}"
for d in drops:
self.add(f"item:{d['item']}", "mob", f"Drop from {self.entity_name(mob)}",
needs=[[f"mob:{mob}"]], mob=mob, **_chance(d), **common)
elif tid in places:
ps = places[tid]
best = max((p[1] for p in ps), key=RANK.get)
needs = [["item:minecraft:brush"]] if ltype == "minecraft:archaeology" else []
kind = "archaeology" if ltype == "minecraft:archaeology" else "chest"
where = sorted({p[0] for p in ps if p[1] == best})
for d in drops:
self.add(f"item:{d['item']}", kind,
("Brush suspicious blocks in " if kind == "archaeology" else "Loot in ") + _join(where),
base=best, needs=needs, places=where, **_chance(d), **common)
elif path.startswith(("gameplay/fishing", "fishing/pokerod", "injection/gameplay/fishing")):
rod = (["item:" + i for i in self.itags.resolve("#cobblemon:poke_rods")] if path.startswith("fishing/")
else ["item:minecraft:fishing_rod"])
if path.startswith("gameplay/fishing/") and path != "gameplay/fishing":
continue # reached through gameplay/fishing
for d in drops:
self.add(f"item:{d['item']}", "fishing", "Fishing" + (" with a Poké Rod" if path.startswith("fishing/") else ""),
needs=[rod], **_chance(d), **common)
elif path.startswith("alpha/"):
if alpha_spawns is None:
alpha_spawns = self._alpha_spawns()
typ = path.split("/")[2].split("_")[0] if path.startswith("alpha/types/") else None
keys = [f"spawn:{i}" for i, t in alpha_spawns if typ is None or typ in t]
for d in drops:
self.add(f"item:{d['item']}", "alpha", "Defeat a wild Alpha Pokémon" + (f" ({typ} type)" if typ else ""),
needs=[keys], **_chance(d), **common)
elif ns == "minecraft" and path.startswith(("gameplay/", "shearing/")):
mob = {"cat_morning_gift": "cat", "hero_of_the_village": "villager", "piglin_bartering": "piglin",
"sniffer_digging": "sniffer", "panda_sneeze": "panda", "armadillo_shed": "armadillo",
"bogged": "bogged"}
key = next((v for k, v in mob.items() if k in path), None)
if key is None:
self.unresolved["loot tables with no known place"].add(tid)
continue
for d in drops:
self.add(f"item:{d['item']}", "gameplay", path.split("/")[-1].replace("_", " ").capitalize(),
needs=[[f"mob:minecraft:{key}"]], **_chance(d), **common)
elif not self._is_nested(tid):
self.unresolved["loot tables with no known place"].add(tid)
def _is_nested(self, tid):
"""Is this table only used from inside other tables (e.g. cobblemon:sets/...)?"""
if not hasattr(self, "_nested"):
self._nested = {norm_id(s) for other in self.loot.ids for s in _strings(self.loot.table(other) or {})
if ":" in s and norm_id(s) in self.loot.ids and norm_id(s) != other}
return tid in self._nested
def _alpha_spawns(self):
out = []
for i, e in enumerate(self.spawns.entries):
for m in e["members"]:
if any(p == "alpha=true" for p in m["props"]):
out.append((i, self.species.get(m["species"], {}).get("types", [])))
return out
def _drops(self):
"""Pokémon drops: species JSON 'drops', overridden per spawn by a spawn's own 'drops'."""
by_species = defaultdict(list)
override = defaultdict(list)
for i, e in enumerate(self.spawns.entries):
for m in e["members"]:
(override if e.get("drops") else by_species)[m["species"]].append(i)
if e.get("drops"):
for d in _drop_entries(e["drops"]):
self.add(f"item:{d['item']}", "pokemon", f"Defeat wild {self.species_name(m['species'])} (spawn {e['id']})",
needs=[[f"spawn:{i}"]], species=m["species"], chance=d["chance"])
self.referenced.add(d["item"])
for sid, sp in self.species.items():
for d in _drop_entries(sp.get("drops")):
keys = [f"spawn:{i}" for i in by_species.get(sid, [])]
if keys:
self.add(f"item:{d['item']}", "pokemon", f"Defeat wild {sp['name']}",
needs=[keys], species=sid, chance=d["chance"])
else:
self.add(f"item:{d['item']}", "pokemon", f"Defeat wild {sp['name']} (it has no wild spawn)",
base="unavailable", species=sid, chance=d["chance"])
def _berries(self):
groves = self.btags.resolve("#minecraft:is_overworld") # berry groves are added to these biomes
for bid, rel in self.stack.ids("berries").items():
d = self.stack.json(rel) or {}
biomes = set()
for sc in d.get("spawnConditions", []):
v = sc.get("variant", "")
if v.endswith("all_biome"):
biomes |= groves
elif v.endswith("preferred_biome"):
biomes |= self.tags["worldgen/biome"].resolve_all(["#" + t for t in d.get("preferredBiomeTags", [])]) & groves
elif v.endswith("specific_biome"):
biomes |= self.tags["worldgen/biome"].resolve_all(["#" + sc["biome"]]) & groves
here = self.island.island_biomes(biomes)
if biomes:
self.add(f"item:{bid}", "berry", "Wild berry trees in " + _join([self.biome_name(b) for b in here] or ["(no island biome)"]),
base=self.island.biome_status(here), biomes=here)
for partner, result in d.get("mutations", {}).items():
if norm_id(bid) < norm_id(partner): # each pair is listed in both berries
self.add(f"item:{norm_id(result)}", "mutation",
f"Berry mutation: plant {self.item_name(bid)} next to {self.item_name(partner)}",
needs=[[f"item:{bid}"], [f"item:{norm_id(partner)}"]], parents=[bid, norm_id(partner)])
def _fossils(self):
parts = [[f"item:{i}"] for i in sorted(self.itags.resolve("#cobblemon:fossil_machine_parts"))]
for fid, rel in self.stack.ids("fossils").items():
d = self.stack.json(rel) or {}
sp, props = parse_props(d.get("result", ""))
items = [norm_id(f) for f in d.get("fossils", [])]
self.add(f"species:{sp}", "fossil", "Revive from " + " + ".join(self.item_name(i) for i in items),
needs=[[f"item:{i}"] for i in items] + parts, fossils=items)
for i in items:
self.uses[i].append({"kind": "fossil", "text": f"Revives {self.species_name(sp)} (Fossil Analyzer + Restoration Tank)",
"species": sp})
def _interactions(self):
for pid, rel in self.stack.ids("pokemon_interactions").items():
d = self.stack.json(rel) or {}
targets = [parse_props(r.get("target", ""))[0] for r in d.get("requirements", []) if r.get("variant") == "properties"]
for inter in d.get("interactions", []):
held = [r.get("itemCondition") for r in inter.get("requirements", []) if r.get("variant") == "owner_held_item"]
tool = sorted(self.itags.resolve_all(held)) if held else []
for eff in inter.get("effects", []):
if eff.get("variant") not in ("drop_item", "give_item") or not eff.get("item"):
continue
item = norm_id(eff["item"])
self.referenced.add(item)
for sp in targets:
how = f"Use {_join([h.lstrip('#') for h in held]) or 'an item'} on your {self.species_name(sp)}"
self.add(f"item:{item}", "interaction", how,
needs=[[f"species:{sp}"]] + ([[f"item:{t}" for t in tool]] if tool else []), species=sp)
def _manual(self):
from pathlib import Path
cfg = json.loads((Path(__file__).parent / "manual_sources.json").read_text())
for s in cfg["sources"]:
self.add(f"item:{s['item']}", "manual", s["how"], needs=s.get("needs", []))
axes = [f"item:{i}" for i in self.itags.resolve("#minecraft:axes")]
for item in sorted(self.vanilla_items | {k for k in self.stack.ids("models/item", root="assets")}):
ns, path = split_id(item)
if path.startswith("stripped_"):
self.add(f"item:{item}", "manual", "Strip with an axe", needs=[[f"item:{ns}:{path[9:]}"], axes])
if cfg.get("concrete_from_powder"):
for b in sorted(self.vanilla_items):
if b.endswith("_concrete_powder"):
self.add(f"item:{b[:-7]}", "manual", "Place concrete powder in water", needs=[[f"item:{b}"]])
def _extra(self, path):
"""Extra sources from a JSON file (ore veins, trades, ...); KubeJS recipes come from _kubejs."""
d = json.loads(open(path).read())
for s in d.get("sources", []):
self.add(f"item:{norm_id(s['item'])}", s.get("kind", "extra"), s["how"], base=s.get("status", "available"),
needs=s.get("needs", []))
for rid in d.get("remove_recipes", []):
self._remove_recipe(rid)
# --- world nodes: blocks, mobs ---------------------------------------------------------------
def _world_nodes(self):
for blk in self.blocks | set(self.island.natural):
st, biomes = self.island.natural_status(blk)
n = self.island.natural.get(blk)
if n:
where = [self.biome_name(b) for b in biomes] or []
structs = [self.structure_name(v[10:]) for v in n["via"] if v.startswith("structure:")]
self.add(f"block:{blk}", "natural", "Generates naturally", base=st, biomes=biomes, structures=sorted(set(structs)),
where=where)
if blk in self.items:
self.add(f"block:{blk}", "placed", "Placed by a player", needs=[[f"item:{blk}"]])
for mob, entries in self.island.mobs.items():
for e in entries:
where = sorted(e["where"])
text = {"biome": "Spawns in " + _join([self.biome_name(b) for b in where]),
"structure": "Spawns in " + _join([self.structure_name(s) for s in where]),
"special": _join(where).capitalize()}[e["kind"]]
self.add(f"mob:{mob}", e["kind"], text, base=e["status"])
for mob, (item, note) in self.island.item_mobs.items():
self.add(f"mob:{mob}", "item", note.capitalize(), needs=[[f"item:{item}"]])
def _spawn_nodes(self):
self.spawn_by_species = defaultdict(list)
self._spawn_sources("spawn", self.spawns.entries)
self._spawn_sources("hidden_spawn", self.hidden_spawns.entries) # no species source: they never spawn
for i, e in enumerate(self.spawns.entries):
for m in e["members"]:
self.spawn_by_species[m["species"]].append(i)
self.add(f"species:{m['species']}", "wild", f"Wild spawn {e['id']}", needs=[[f"spawn:{i}"]], spawn=i)
for sid, sp in self.species.items():
if "starter" in sp["labels"]:
self.add(f"species:{sid}", "starter", "Starter Pokémon (chosen once per player)")
def _spawn_sources(self, prefix, entries):
rods = [f"item:{i}" for i in self.itags.resolve("#cobblemon:poke_rods")]
for i, e in enumerate(entries):
for c in e["conditions"]:
base = self.island.biome_status(c["biomes"])
if c["structures"]["refs"]:
base = STATUS[min(RANK[base], RANK[c["structures"]["status"] or "unavailable"])]
needs = []
if c["near"]:
needs.append([f"block:{b}" for grp in c["near"] for b in grp])
if c["base"]:
needs.append([f"block:{b}" for grp in c["base"] for b in grp])
if c["poke_snack"]:
needs.append(["item:cobblemon:poke_snack"])
if e["position"] == "fishing":
needs.append(rods)
self.add(f"{prefix}:{i}", "spawn", " · ".join(c["text"]) or "anywhere", base=base, needs=needs)
def _evolutions(self):
self.evo_from = defaultdict(list)
for sid, sp in self.species.items():
for e in sp["evolutions"]:
target, props = parse_props(e.get("result", ""))
if not target:
continue
how, needs, base = [], [[f"species:{sid}"]], "available"
variant = e.get("variant")
ctx = e.get("requiredContext")
if variant == "item_interact" and ctx:
items = sorted(self.itags.resolve(ctx)) if str(ctx).startswith("#") else [norm_id(ctx)]
how.append("use " + _join([self.item_name(i) for i in items]))
needs.append([f"item:{i}" for i in items])
for i in items:
self.referenced.add(i)
self.uses[i].append({"kind": "evolution", "text": f"Evolves {sp['name']} into {self.species_name(target)}",
"species": target, "from": sid})
elif variant == "trade":
how.append("trade" + (f" for {self.species_name(norm_id('cobblemon:' + ctx))}" if ctx else ""))
for r in e.get("requirements", []):
text, rneeds, rbase = self._requirement(r, sp, target)
if text:
how.append(text)
needs += rneeds
base = STATUS[min(RANK[base], RANK[rbase])]
desc = f"Evolve {sp['name']}" + (f" ({e['_form']} form)" if e.get("_form") else "") + (": " + ", ".join(how) if how else "")
self.add(f"species:{target}", "evolution", desc, base=base, needs=needs, species=sid,
props=props, how=how)
self.evo_from[sid].append({"to": target, "how": how, "props": props, "form": e.get("_form")})
def _requirement(self, r, sp, target):
"""Evolution requirement -> (text, needs, base status)."""
v = r.get("variant", "")
if v == "level":
return f"level {r.get('minLevel', '?')}" + (f"–{r['maxLevel']}" if r.get("maxLevel") else ""), [], "available"
if v == "held_item":
cond = r.get("itemCondition", "")
items = sorted(self.itags.resolve(cond)) if str(cond).startswith("#") else [norm_id(cond)]
for i in items:
self.referenced.add(i)
self.uses[i].append({"kind": "evolution", "text": f"Held by {sp['name']} to evolve into {self.species_name(target)}",
"species": target, "from": sp["id"]})
return "holding " + _join([self.item_name(i) for i in items]), [[f"item:{i}" for i in items]], "available"
if v == "friendship":
return f"friendship {r.get('amount')}", [], "available"
if v == "time_range":
return r.get("range", ""), [], "available"
if v == "biome":
biomes = self.tags["worldgen/biome"].resolve(r.get("biomeCondition", "")) if r.get("biomeCondition") else set()
here = self.island.island_biomes(biomes)
text = "in " + (_join([self.biome_name(b) for b in here]) if here else r.get("biomeCondition", "") + " (not on the island)")
return text, [], self.island.biome_status(here) if here else "unavailable"
if v == "structure":
st, which = self.island.structure_status([r.get("structureCondition", "")])
return f"near {r.get('structureCondition', '').lstrip('#')}", [], st
if v == "negate":
inner, _, _ = self._requirement(r.get("inner", {}), sp, target)
return f"not ({inner})", [], "available"
if v == "weather":
return "raining" if r.get("isRaining") else "thunderstorm" if r.get("isThundering") else "clear weather", [], "available"
if v == "moon_phase":
return f"moon {str(r.get('moonPhase', '')).lower().replace('_', ' ')}", [], "available"
if v == "has_move":
return f"knows {r.get('move')}", [], "available"
if v == "has_move_type":
return f"knows a {r.get('type')} move", [], "available"
if v == "properties":
return r.get("target", ""), [], "available"
if v == "party_member":
return f"party has {r.get('target')}", [], "available"
if v == "advancement":
return f"advancement {r.get('requiredAdvancement')}", [], "available"
if v in ("blocks_traveled", "damage_taken", "recoil", "defeat", "use_move", "battle_critical_hits"):
return f"{v.replace('_', ' ')} {r.get('amount', '')} {r.get('move', '') or r.get('target', '')}".strip(), [], "available"
if v in ("stat_compare", "stat_equal"):
return " ".join(str(x) for x in r.values() if x != v), [], "available"
if v == "property_range":
return f"{r.get('feature')} {r.get('range')}", [], "available"
self.unresolved["evolution requirement variants shown raw"].add(v)
return v, [], "available"
# --- uses -----------------------------------------------------------------------------------
def _uses(self):
for rid, r in self.recipes.items():
seen = set()
for s in r["slots"]:
for i in s["items"]:
if i not in seen:
seen.add(i)
self.uses[i].append({"kind": "recipe", "recipe": rid, "text": f"{r['label']}: {self.item_name(r['result'])}",
"result": r["result"]})
held = self.itags.resolve("#cobblemon:held/is_held_item")
for i in held:
self.uses[i].append({"kind": "held", "text": "Held item" + (": " + " ".join(self.tooltip(i)) if self.tooltip(i) else "")})
for eid, rel in self.stack.ids("spawn_bait_effects").items():
d = self.stack.json(rel) or {}
item = norm_id(d.get("item", eid))
effects = [e.get("type", "").split(":")[-1].replace("_", " ") for e in d.get("effects", [])]
self.uses[item].append({"kind": "bait", "text": "Bait / Poké Snack effect: " + _join(sorted(set(effects)))})
for i, e in enumerate(self.spawns.entries):
for c in e["conditions"]:
refs = [r for r in c["near_refs"] + c["base_refs"] if not r.startswith("#")]
for ref in refs:
for m in e["members"]:
self.uses[norm_id(ref)].append({"kind": "spawn", "text": f"Nearby block that attracts {self.species_name(m['species'])}",
"species": m["species"]})
# --- solving ----------------------------------------------------------------------------------
def add(self, key, kind, text, base="available", needs=(), **data):
self.sources[key].append({"kind": kind, "text": text, "base": base, "needs": [list(g) for g in needs], **data})
def _solve(self):
status = defaultdict(int)
changed, passes = True, 0
while changed:
changed, passes = False, passes + 1
for key, srcs in self.sources.items():
best = status[key]
if best == 2:
continue
for src in srcs:
v = RANK[src["base"]]
for group in src["needs"]:
v = min(v, max((status[k] for k in group), default=0))
if v <= best:
break
best = max(best, v)
if best != status[key]:
status[key] = best
changed = True
self.status = status
self.passes = passes
for key, srcs in self.sources.items():
for src in srcs:
v, blocked = RANK[src["base"]], []
for group in src["needs"]:
g = max((status[k] for k in group), default=0)
if g < 2:
blocked.append(group)
v = min(v, g)
src["status"] = STATUS[v]
blocked = [g for g in blocked if max((status[k] for k in g), default=0) < RANK[src["base"]]]
blocked.sort(key=lambda g: max((status[k] for k in g), default=0)) # worst first
src["blocked_by"] = [g[:6] for g in blocked][:3]
def get_status(self, key):
return STATUS[self.status.get(key, 0)]
@property
def btags(self):
return self.tags["worldgen/biome"]
def _chance(d):
p = d["p"]
text = "always" if p >= 0.995 else f"≈{p * 100:.3g}%"
lo, hi = d["count"]
if (lo, hi) != (1, 1):
text += f", ×{int(lo)}" + (f"–{int(hi)}" if hi != lo else "")
if d["notes"]:
text += " (" + ", ".join(d["notes"]) + ")"
return {"chance": text}
def _drop_entries(drops):
out = []
for e in (drops or {}).get("entries", []):
if "percentage" in e:
text = f"{e['percentage']:g}%"
elif "quantityRange" in e:
text = f"×{e['quantityRange']}"
else:
text = "×" + str(e.get("quantity", 1))
out.append({"item": norm_id(e["item"]), "chance": text})
return out
def _join(items, limit=6):
items = list(items)
if len(items) > limit:
return ", ".join(items[:limit]) + f" and {len(items) - limit} more"
return ", ".join(items)
def _strings(obj):
if isinstance(obj, str):
yield obj
elif isinstance(obj, dict):
for v in obj.values():
yield from _strings(v)
elif isinstance(obj, list):
for v in obj:
yield from _strings(v)