- Button Isle is peaceful: wild Pokémon only fight a player who hit them first (tuning/server.json peaceful_zones); its apricorns and coal make Poké Balls (4 apricorns + coal or charcoal), and the world report checks it has enough. - Quest book: no boilerplate; each zone page says what is particular to it, and every zone that needs gear or a field move to be safe has its own quest. New page on the bigger mods (Cold Sweat, Fight or Flight, Cobblebase, breeding, veins, shared loot, maps and info). - The Mire's poison stops 10 blocks below the surface. - Player battles never cost a Pokémon (CobbleHardcoreMon config, mixins, and the companion mod's own permadeath). - tools/item_audit.py: every item with a use has a source on the island; new sources for the gaps it found (species additions, three recipes). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
808 lines
44 KiB
Python
808 lines
44 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
|
||
import fnmatch
|
||
cfg = json.loads((Path(__file__).parent / "manual_sources.json").read_text())
|
||
known = self.vanilla_items | set(self.stack.ids("models/item", root="assets"))
|
||
for s in cfg["sources"]:
|
||
for item in sorted(fnmatch.filter(known, s["pattern"])) if "pattern" in s else [s["item"]]:
|
||
self.add(f"item:{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)
|