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