Ore veins: make_world.py loads the island again row by row until Regenerating Ore Veins has filled every chunk (/cobblesurvival veins unfilled), cave tiers sit at their biome's depths, small zones get denser veins, showcase veins are full size. The world report checks surface tiers against the zone grid, cave tiers within two biome cells of their cave, and fails on unfilled chunks; the template passes (37,689 chunks). Drops: tools/drop_audit.py checks every creature drop a recipe or brewing needs (docs/pokemon-drops.md). Blaze rods, ghast tears, breeze rods, shulker shells and nether stars now drop from Pokemon through species additions (self-test drop_additions_apply). The field guide counts vanilla mobs as absent, applies species additions and reads the veins as sources. Cobblebase tried (loads, self-tests pass) and left out until Adrian decides its scope. Phase 4 checklist with the open questions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
805 lines
43 KiB
Python
805 lines
43 KiB
Python
"""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"]))
|
||
# Species additions (data/<ns>/species_additions/) replace the fields they set; drops is the one the guide shows.
|
||
for _, rel in sorted(self.stack.ids("species_additions").items()):
|
||
d = self.stack.json(rel) or {}
|
||
target = self.species.get(norm_id(d.get("target", "")))
|
||
if target is not None and "drops" in d:
|
||
target["drops"] = d["drops"]
|
||
for e in d["drops"].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"]]
|
||
if not self.island.mob_allowed(mob):
|
||
text += " (but only Pokémon live on the island)"
|
||
self.add(f"mob:{mob}", e["kind"], text, base=e["status"])
|
||
for mob, (item, note) in self.island.item_mobs.items():
|
||
if self.island.mob_allowed(mob):
|
||
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)
|