Cobblemon Survival Pack: phases 0-2 and Phase 3 zones and hazards

Free NeoForge 1.21.1 modpack on Cobblemon 1.8.4 built with packwiz, plus
the Kotlin companion mod (cobblesurvival) and its built-in data pack.

- Phase 0: packwiz pack with Modrinth/CurseForge cross-links, build and
  export tooling, Prism instance sync, test server with smoke checks.
- Phase 1: Poke Ball shaped island (WorldGen Editor preset, seed isle-26),
  Adrian's biome list, no natural ore, structures without ore or vanilla
  mobs, no Nether portals, world template builder and world report.
- Phase 2: permadeath (CobbleHardcoreMon plus the companion mod's gap
  fixes), Revival Charm, expensive Healing Machine, Lootr refreshing
  loot, in-game self-tests on Minecraft's GameTest framework.
- Phase 3 so far: 14 zones with their own spawn pools and level bands
  (tools/zone_layout.py, tools/zone_spawns.py, "zone" spawn condition),
  Cold Sweat heat and cold as gates with boss-drop insulation recipes,
  spawn survey and hazard self-tests in tools/quick_check.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-10-29 19:01:21 +01:00
commit 398301f0be
232 files changed
+72665

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
"""Field guide generator for the Cobblemon Survival Pack.
Reads the pack's effective game data (vanilla, NeoForge, every mod jar in
pack/mods, then datapacks/core on top) and writes build/field-guide/:
fieldguide.json (the single source), an HTML guide, and in-game exports.
Entry point: tools/fieldguide/build.py. Overview: docs/field-guide.md.
"""
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Generate the field guide: where every Pokémon lives on the island, how to get every mod item,
and what each item is for, from the pack's own data.
Reads vanilla data (build/server/libraries), NeoForge, every jar listed in pack/mods (copied to
build/fieldguide-cache/jars from build/stage/full, build/server or the Prism instance), then
datapacks/core on top, plus world/biomes.json and world/world.json for the island. The recipes the
pack's KubeJS scripts add, change or remove come from `/kubejs export` on the test server
(kubejs_export.py); when that export is older than the pack, this boots the test server once to
refresh it (about a minute) unless --no-server is given.
Writes build/field-guide/:
fieldguide.json the single source every renderer reads
index.html, pokemon.html, items.html, data.js, assets/, icons/ the HTML guide
ingame/ftbquests/ FTB Quests "Field Guide" chapters (not installed)
ingame/kubejs/ Pokédex "Found in" lang override (not installed)
Usage: tools/fieldguide/build.py [--out DIR] [--no-icons] [--no-server] [--from-json FILE] [--extra FILE ...]
--no-server never start Minecraft: use the last KubeJS recipe export even if the pack changed since
--from-json skip extraction and render an existing fieldguide.json (e.g. one a runtime exporter wrote)
--extra add sources from a JSON file (format in docs/field-guide.md), e.g. ore veins or trades
"""
import argparse
import json
import sys
import time
from pathlib import Path
sys.dont_write_bytecode = True # keep tools/fieldguide free of __pycache__
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from fieldguide import datasource, export_ftbquests, export_lang, guide, kubejs_export, render_html # noqa: E402
from fieldguide.icons import Icons # noqa: E402
from fieldguide.model import Model # noqa: E402
ROOT = Path(__file__).resolve().parent.parent.parent
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("--out", type=Path, default=ROOT / "build/field-guide")
ap.add_argument("--no-icons", action="store_true", help="skip rendering item icons")
ap.add_argument("--no-server", action="store_true", help="use the last KubeJS recipe export as it is")
ap.add_argument("--from-json", type=Path, help="render this fieldguide.json instead of extracting")
ap.add_argument("--extra", type=Path, action="append", default=[], help="extra sources JSON (repeatable)")
args = ap.parse_args()
out = args.out
started = time.time()
if args.from_json:
data = json.loads(args.from_json.read_text())
lang = {}
else:
print("reading the pack's data")
stack = datasource.build_stack(log=_log_once())
model = Model(stack, log=lambda msg: print(" " + msg), extras=args.extra, kubejs=_kubejs(args.no_server))
icons = {}
if not args.no_icons:
wanted = set(model.mod_items) | _vanilla_mentions(model)
ic = Icons(stack, out)
icons = ic.write_all(wanted)
print(f" icons: {len(icons)} rendered, {len(ic.missing)} without a usable model")
data = guide.build(model, icons)
guide.write(data, out / "fieldguide.json")
lang = model.lang
render_html.write(data, out)
chapters = export_ftbquests.write(data, out / "ingame")
if lang:
n, missing = export_lang.write(data, lang, out / "ingame")
print(f" Pokédex override: {n} keys" + (f", {len(missing)} keys not in the jar's lang" if missing else ""))
(out / "ingame/README.md").write_text(INGAME_README)
_summary(data, chapters, out, time.time() - started)
def _kubejs(no_server):
"""The pack's KubeJS recipe changes; refreshed from the test server when the pack changed since."""
data = kubejs_export.load()
if data and not data["stale"]:
print(f" KubeJS recipes: export from {data['exported']}")
return data
why = "no KubeJS recipe export yet" if not data else "pack/kubejs, pack/mods or datapacks/core changed since the last KubeJS recipe export"
if no_server:
print(f" warning: {why}; --no-server, so the guide " +
("uses the old export" if data else "shows no pack recipes"))
return data
print(f" {why}: booting the test server for /kubejs export")
try:
return kubejs_export.export(log=lambda msg: print(" " + msg))
except (SystemExit, Exception) as e: # e.g. no EULA, a server already running, mods failing to load
print(f" warning: KubeJS recipe export failed ({type(e).__name__}: {e}); the guide " +
("uses the old export" if data else "shows no pack recipes"))
return data
def _vanilla_mentions(model):
"""Vanilla items that show up in recipes or sources of mod items (they get icons too)."""
out = set()
mods = set(model.mod_items)
for r in model.recipes.values():
if r["result"] in mods or any(i in mods for s in r["slots"] for i in s["items"]):
out.add(r["result"])
for s in r["slots"]:
out.update(s["items"][:8])
for d in model.species.values():
for e in (d.get("drops") or {}).get("entries", []):
out.add(e["item"] if ":" in e["item"] else "minecraft:" + e["item"])
return {i for i in out if i.startswith("minecraft:")}
def _log_once():
seen = []
def log(msg):
if msg.startswith("warning:") and "not found in" in msg:
seen.append(msg.split()[1])
if len(seen) == 1:
print(" some pack/mods jars are not downloaded yet (run tools/build.py); continuing without them")
return
print(" " + msg)
return log
def _summary(data, chapters, out, secs):
sp = data["species"]
items = data["items"]
wild = sum(1 for s in sp.values() if s["wild"] == "available")
other = sum(1 for s in sp.values() if s["wild"] != "available" and s["status"] == "available")
cond = sum(1 for s in sp.values() if s["status"] == "conditional")
none = [s for s in sp.values() if s["status"] == "unavailable"]
nosrc = [i for i, v in items.items() if v["status"] == "unavailable" and not v["technical"]]
print(f"\nPokémon: {wild} wild on the island, {other} more by evolution/fossil/starter, {cond} only in the End, "
f"{len(none)} not obtainable ({sum(1 for s in none if not s['implemented'])} of them not implemented)")
for loc in data["locations"]:
n = len({m["species"] for i in loc["spawns"] for m in data["spawns"][i]["members"]})
print(f" {loc['name']:<24} {loc['kind']:<8} {n:>4} species")
print(f"Items: {len(items)} mod items, {len(nosrc)} with no source on the island")
print(f"FTB Quests chapters: " + ", ".join(f"{k} ({v} quests)" for k, v in chapters.items()))
print(f"Wrote {out} in {secs:.1f}s; open {out / 'index.html'}")
INGAME_README = """# In-game exports (generated, not installed)
- `ftbquests/`: FTB Quests "Field Guide" chapters. See `ftbquests/README.md`.
- `kubejs/`: Pokédex description override that appends "Found in: …". See `kubejs/README.md`.
Both are generated by `python3 tools/fieldguide/build.py` from `../fieldguide.json`.
Installing them is a separate decision; nothing here is copied into pack/.
"""
if __name__ == "__main__":
main()
+266
View File
@@ -0,0 +1,266 @@
"""Layered, read-only view of the game's resources, resolved like Minecraft's pack stack.
Layers from bottom to top: vanilla server data (plus the vanilla client jar for
textures, if Prism has it), NeoForge, every mod jar listed in pack/mods, then
datapacks/core. A higher layer replaces a file with the same path, tags are
merged over all layers, and a pack.mcmeta "filter" block hides matching files
of every layer below it (how Phase 3 can blank Cobblemon's own spawn pools).
Jars are copied into build/fieldguide-cache/jars first, so a server or Prism
instance that replaces its jars mid-run cannot change what we read.
"""
import json
import re
import shutil
import tomllib
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
BUILD = ROOT / "build"
CACHE = BUILD / "fieldguide-cache"
PRISM = Path.home() / ".var/app/org.prismlauncher.PrismLauncher/data/PrismLauncher"
MOD_DIRS = [BUILD / "stage/full/mods", BUILD / "server/mods",
PRISM / "instances/Cobblemon Survival Pack (dev)/minecraft/mods"]
# Only these asset subtrees are indexed; Cobblemon alone ships ~20k model/sound files we never read.
ASSET_DIRS = ("lang/", "models/item/", "models/block/", "textures/item/", "textures/block/", "blockstates/")
class Layer:
"""One resource pack: a jar or a folder, with its pack.mcmeta filter."""
def __init__(self, name, path, roots=("data", "assets"), modids=()):
self.name, self.path, self.modids = name, Path(path), list(modids)
self.files = {}
self.filters = []
self.zip = zipfile.ZipFile(self.path) if self.path.is_file() else None
names = self.zip.namelist() if self.zip else [
p.relative_to(self.path).as_posix() for p in self.path.rglob("*") if p.is_file()]
for n in names:
parts = n.split("/", 3)
if len(parts) < 4 or parts[0] not in roots or n.endswith("/"):
continue
if parts[0] == "assets" and not parts[2].startswith(("lang", "models", "textures", "blockstates")):
continue
if parts[0] == "assets" and not (parts[2] + "/" + parts[3]).startswith(ASSET_DIRS):
continue
self.files[n] = n
meta = self.read("pack.mcmeta", missing_ok=True)
if meta:
block = json.loads(meta).get("filter", {}).get("block", [])
self.filters = [(re.compile(f.get("namespace", ".*")), re.compile(f.get("path", ".*"))) for f in block]
def read(self, rel, missing_ok=False):
try:
return self.zip.read(rel) if self.zip else (self.path / rel).read_bytes()
except (KeyError, FileNotFoundError):
if missing_ok:
return None
raise
def __repr__(self):
return f"Layer({self.name})"
def loads(raw):
"""JSON as Minecraft's lenient reader accepts it (a few mod files carry // comments)."""
text = raw.decode("utf-8-sig")
try:
return json.loads(text)
except json.JSONDecodeError:
text = re.sub(r"^\s*//.*$", "", text, flags=re.M)
return json.loads(re.sub(r",(\s*[}\]])", r"\1", text))
class DataStack:
def __init__(self, layers):
self.layers = layers
self.index = {}
self.errors = []
self._ids = {}
self._json = {}
for i, layer in enumerate(layers):
for rel in layer.files:
self.index.setdefault(rel, []).append(i)
self.unfiltered = {rel: list(idxs) for rel, idxs in self.index.items()}
for i, layer in enumerate(layers):
if not layer.filters:
continue
for rel, idxs in self.index.items():
ns, path = rel.split("/", 2)[1:]
if any(n.fullmatch(ns) and p.fullmatch(path) for n, p in layer.filters):
self.index[rel] = [j for j in idxs if j >= i]
@property
def modids(self):
return {"minecraft", "neoforge", "c"} | {m for layer in self.layers for m in layer.modids}
def exists(self, rel):
return bool(self.index.get(rel))
def read(self, rel):
idxs = self.index.get(rel)
return self.layers[idxs[-1]].read(rel) if idxs else None
def layer_of(self, rel):
idxs = self.index.get(rel)
return self.layers[idxs[-1]] if idxs else None
def json(self, rel):
if rel not in self._json:
raw = self.read(rel)
try:
self._json[rel] = loads(raw) if raw is not None else None
except (ValueError, UnicodeDecodeError) as e:
self.errors.append(f"{rel}: {e}")
self._json[rel] = None
return self._json[rel]
def all_json(self, rel):
"""Every layer's copy of a file, bottom to top (tags merge these)."""
out = []
for i in self.index.get(rel, []):
try:
out.append(loads(self.layers[i].read(rel)))
except (ValueError, UnicodeDecodeError) as e:
self.errors.append(f"{self.layers[i].name}:{rel}: {e}")
return out
def ids(self, kind, root="data", ext=".json"):
"""{namespace:path -> rel} for every visible resource of a kind, e.g. ids('recipe')."""
key = (root, kind, ext)
if key not in self._ids:
found = {}
for rel, idxs in self.index.items():
if not idxs or not rel.startswith(root + "/") or not rel.endswith(ext):
continue
_, ns, rest = rel.split("/", 2)
if rest.startswith(kind + "/"):
found[f"{ns}:{rest[len(kind) + 1:-len(ext)]}"] = rel
self._ids[key] = found
return self._ids[key]
def hidden(self, kind, root="data", ext=".json"):
"""{namespace:path -> json} for resources of a kind that a pack.mcmeta filter hides from every layer,
read from the topmost layer that has them (e.g. Cobblemon's own spawn pools under datapacks/core's filter)."""
out = {}
for rel, idxs in self.unfiltered.items():
if self.index.get(rel) or not rel.startswith(root + "/") or not rel.endswith(ext):
continue
_, ns, rest = rel.split("/", 2)
if rest.startswith(kind + "/"):
try:
out[f"{ns}:{rest[len(kind) + 1:-len(ext)]}"] = (rel, loads(self.layers[idxs[-1]].read(rel)))
except (ValueError, UnicodeDecodeError) as e:
self.errors.append(f"{self.layers[idxs[-1]].name}:{rel}: {e}")
return dict(sorted(out.items()))
def resource(self, kind, rid, root="data", ext=".json"):
ns, path = split_id(rid)
return f"{root}/{ns}/{kind}/{path}{ext}"
def get(self, kind, rid):
return self.json(self.resource(kind, rid))
def split_id(rid):
rid = rid.lstrip("#")
return rid.split(":", 1) if ":" in rid else ("minecraft", rid)
def norm_id(rid):
ns, path = split_id(rid)
return f"{ns}:{path}"
# --- locating and caching jars -----------------------------------------------------------
def pack_mod_files():
"""Jar filenames the pack defines (pack/mods/*.pw.toml)."""
out = []
for toml in sorted((ROOT / "pack/mods").glob("*.pw.toml")):
data = tomllib.loads(toml.read_text())
if data.get("filename", "").endswith(".jar"):
out.append(data["filename"])
return out
def find_jar(name, dirs=MOD_DIRS):
for d in dirs:
if (d / name).is_file():
return d / name
return None
def companion_jar():
for d in MOD_DIRS:
jars = sorted(d.glob("cobblesurvival-*.jar"), key=lambda p: p.stat().st_mtime)
if jars:
return jars[-1]
return None
def cached(src):
"""Copy a jar into build/fieldguide-cache/jars unless an identical copy is there."""
dst = CACHE / "jars" / src.name
st = src.stat()
if not dst.exists() or dst.stat().st_size != st.st_size or int(dst.stat().st_mtime) != int(st.st_mtime):
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
return dst
def jar_modids(path):
with zipfile.ZipFile(path) as z:
try:
toml = tomllib.loads(z.read("META-INF/neoforge.mods.toml").decode())
except (KeyError, tomllib.TOMLDecodeError, UnicodeDecodeError):
return []
return [m["modId"] for m in toml.get("mods", []) if "modId" in m]
def newest(pattern_dir, pattern):
hits = sorted(Path(pattern_dir).glob(pattern)) if Path(pattern_dir).exists() else []
return hits[-1] if hits else None
def build_stack(datapack=ROOT / "datapacks/core", extra_layers=(), log=print):
"""Create the layer stack. Missing optional inputs are reported, missing required ones raise."""
pack_toml = tomllib.loads((ROOT / "pack/pack.toml").read_text())
mc = pack_toml.get("versions", {}).get("minecraft", "1.21.1")
neo = pack_toml.get("versions", {}).get("neoforge")
libs = BUILD / "server/libraries"
vanilla = newest(libs / "net/minecraft/server", f"{mc}-*/server-{mc}-*-extra.jar")
if not vanilla:
raise SystemExit(f"vanilla data jar not found under {libs} (run tools/smoke_test.py --load-only once)")
layers = []
client = PRISM / f"libraries/com/mojang/minecraft/{mc}/minecraft-{mc}-client.jar"
if client.exists():
layers.append(Layer("minecraft-client", cached(client), roots=("assets",)))
else:
log(f"note: no vanilla client jar at {client}; vanilla items get no icons")
layers.append(Layer("minecraft", cached(vanilla), modids=["minecraft"]))
neo_jar = newest(libs / "net/neoforged/neoforge", f"{neo or '*'}/neoforge-*-universal.jar")
if neo_jar:
layers.append(Layer("neoforge", cached(neo_jar), modids=["neoforge"]))
else:
log("note: NeoForge universal jar not found; c: tags from NeoForge are missing")
mods = []
for name in pack_mod_files():
src = find_jar(name)
if not src:
log(f"warning: {name} (pack/mods) not found in {', '.join(str(d) for d in MOD_DIRS)}")
continue
mods.append(cached(src))
comp = companion_jar()
if comp:
mods.append(cached(comp))
for jar in sorted(mods, key=lambda p: p.name.lower()):
layers.append(Layer(jar.name, jar, modids=jar_modids(jar)))
for extra in extra_layers:
layers.append(Layer(Path(extra).name, extra))
layers.append(Layer("datapacks/core", datapack, roots=("data",)))
# KubeJS serves pack/kubejs/data as a data pack and pack/kubejs/assets as a resource pack.
if (ROOT / "pack/kubejs").is_dir():
layers.append(Layer("pack/kubejs", ROOT / "pack/kubejs"))
return DataStack(layers)
+387
View File
@@ -0,0 +1,387 @@
"""FTB Quests "Field Guide" chapters from fieldguide.json (reference only, nothing locks).
Format of FTB Quests 2101.1.37 (read from its source, BaseQuestFile/Chapter/
Quest/TranslationManager): quests live in config/ftbquests/quests/; each
chapter is chapters/<filename>.snbt with 16-hex-digit string ids; titles,
subtitles and descriptions are not in the chapter file but in
lang/<locale>.snbt under keys "<type>.<ID>.title", "quest.<ID>.quest_subtitle",
"quest.<ID>.quest_desc" (a list of lines) and "chapter.<ID>.chapter_subtitle".
Icons are single-item stacks {id, components}; a Pokémon icon is
cobblemon:pokemon_model with the cobblemon:pokemon_item component
{species, aspects} (Cobblemon 1.8.4 PokemonItemComponent).
Ids are derived from names (sha256), so they stay the same between runs and
Phase 8's generator can place its own chapters next to these.
"""
import hashlib
from .world import RANK
CHAPTERS = ("field_guide_wild", "field_guide_other", "field_guide_items")
BIOME_ICONS = {
"minecraft:plains": "minecraft:short_grass", "minecraft:forest": "minecraft:oak_sapling",
"minecraft:cherry_grove": "minecraft:cherry_sapling", "minecraft:mangrove_swamp": "minecraft:mangrove_propagule",
"minecraft:jungle": "minecraft:jungle_sapling", "minecraft:desert": "minecraft:cactus",
"minecraft:badlands": "minecraft:red_sand", "minecraft:windswept_hills": "minecraft:gravel",
"minecraft:jagged_peaks": "minecraft:snow_block", "minecraft:lush_caves": "minecraft:moss_block",
"minecraft:dripstone_caves": "minecraft:pointed_dripstone", "minecraft:deep_dark": "minecraft:sculk",
"minecraft:basalt_deltas": "minecraft:basalt", "minecraft:ocean": "minecraft:kelp",
"minecraft:deep_ocean": "minecraft:prismarine", "minecraft:lukewarm_ocean": "minecraft:seagrass",
"minecraft:deep_lukewarm_ocean": "minecraft:sea_pickle", "minecraft:warm_ocean": "minecraft:brain_coral",
"minecraft:river": "minecraft:water_bucket", "minecraft:beach": "minecraft:sand",
}
COLUMNS = 14
def stable_id(name):
"""64-bit id from a name. FTB parses ids with Long.parseLong(hex, 16), so the top bit must be clear."""
v = int(hashlib.sha256(("cobblesurvival-fieldguide/" + name).encode()).hexdigest()[:16], 16) & 0x7FFF_FFFF_FFFF_FFFF
return f"{max(v, 2):016X}"
# --- SNBT in FTB Library's style (tabs, newline-separated, sorted keys) -------------------------
def _key(k):
return k if all(c.isalnum() or c in "._-+" for c in k) else _str(k)
def _str(s):
out = s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\t", "\\t").replace("\r", "")
return f'"{out}"'
def snbt(value, indent=0):
tab = "\t" * indent
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, int):
return str(value)
if isinstance(value, float):
return f"{value:g}d" if "e" not in f"{value:g}" else f"{value:.6f}d"
if isinstance(value, str):
return _str(value)
if isinstance(value, list):
if not value:
return "[ ]"
return "[\n" + "\n".join(tab + "\t" + snbt(v, indent + 1) for v in value) + "\n" + tab + "]"
if isinstance(value, dict):
if not value:
return "{ }"
return "{\n" + "\n".join(f"{tab}\t{_key(k)}: {snbt(v, indent + 1)}" for k, v in sorted(value.items())) + "\n" + tab + "}"
raise TypeError(type(value))
def parse_snbt(text):
"""Read SNBT the way FTB Library 2101's SNBTParser does (used to check our own output).
Lines whose trimmed text starts with // or # are comments; entries are separated by commas
or newlines; numbers carry b/s/l/f/d suffixes; true/false are bytes.
"""
lines = [ln for ln in text.split("\n") if not ln.strip().startswith(("//", "#"))]
src, pos = "\n".join(lines), 0
simple = lambda c: c.isalnum() or c in "._-+\u221e" # noqa: E731
def ws():
nonlocal pos
while pos < len(src) and src[pos] in " \t\r\n,":
pos += 1
def quoted():
nonlocal pos
pos += 1
out = []
esc = {"n": "\n", "t": "\t", "b": "\b", "r": "\r", "f": "\f", '"': '"', "\\": "\\"}
while True:
c = src[pos]
pos += 1
if c == "\n":
raise ValueError(f"newline inside a string at {pos}")
if c == "\\":
out.append(esc.get(src[pos], ""))
pos += 1
elif c == '"':
return "".join(out)
else:
out.append(c)
def word():
nonlocal pos
start = pos
while pos < len(src) and simple(src[pos]):
pos += 1
return src[start:pos]
def value():
nonlocal pos
ws()
c = src[pos]
if c == "{":
pos += 1
out = {}
while True:
ws()
if src[pos] == "}":
pos += 1
return out
key = quoted() if src[pos] == '"' else word()
ws()
if src[pos] != ":":
raise ValueError(f"expected ':' after {key!r} at {pos}")
pos += 1
out[key] = value()
if c == "[":
pos += 1
out = []
while True:
ws()
if src[pos] == "]":
pos += 1
if len({type(v) for v in out}) > 1:
raise ValueError("mixed list")
return out
out.append(value())
if c == '"':
return quoted()
w = word()
if w in ("true", "false"):
return w == "true"
if w[-1:] in "dD" and _num(w[:-1]):
return float(w[:-1])
if _num(w):
return int(w) if w.lstrip("-").isdigit() else float(w)
return w
result = value()
ws()
if pos != len(src):
raise ValueError(f"trailing data at {pos}")
return result
def _num(s):
try:
float(s)
return True
except ValueError:
return False
# --- chapter building --------------------------------------------------------------------------------
class Builder:
def __init__(self, data):
self.data = data
self.lang = {}
self.chapters = []
def chapter(self, filename, title, subtitle, icon, order):
cid = stable_id("chapter/" + filename)
self.lang[f"chapter.{cid}.title"] = title
self.lang[f"chapter.{cid}.chapter_subtitle"] = subtitle
ch = {"id": cid, "filename": filename, "group": "", "order_index": order, "icon": icon,
"default_quest_shape": "", "default_hide_dependency_lines": False,
"quests": [], "quest_links": [], "images": []}
self.chapters.append(ch)
return ch
def quest(self, ch, name, x, y, title, icon, subtitle=None, desc=(), shape=None, size=None):
qid = stable_id(f"{ch['filename']}/{name}")
q = {"id": qid, "x": float(x), "y": float(y), "icon": icon,
"tasks": [{"id": stable_id(f"{ch['filename']}/{name}/task"), "type": "checkmark"}]}
if shape:
q["shape"] = shape
if size:
q["size"] = float(size)
self.lang[f"quest.{qid}.title"] = title
if subtitle:
self.lang[f"quest.{qid}.quest_subtitle"] = subtitle
if desc:
self.lang[f"quest.{qid}.quest_desc"] = [line for line in desc if not line.startswith("{")]
ch["quests"].append(q)
return q
def item_icon(iid):
return {"id": iid}
def pokemon_icon(sid):
return {"id": "cobblemon:pokemon_model", "components": {"cobblemon:pokemon_item": {"species": sid, "aspects": []}}}
def species_lines(data, sid, limit=10):
sp = data["species"][sid]
lines = [f"#{sp['dex']} · " + " / ".join(t.capitalize() for t in sp["types"])]
if sp["locations"]:
lines.append("Found in:")
for loc in sp["locations"][:limit]:
if loc.get("special"):
lines.append(f"- {loc['name']}: only near particular blocks (e.g. buildings)")
continue
zones = f" ({', '.join(loc['zones'])})" if loc.get("zones") else ""
lines.append(f"- {loc['name']}{zones}: Lv {loc['level'][0]}-{loc['level'][1]}, "
+ ", ".join("alpha" if b == "boss" else b for b in loc["buckets"]))
if len(sp["locations"]) > limit:
lines.append(f"- and {len(sp['locations']) - limit} more places")
best = sorted((data["spawns"][i] for i in sp["spawns"] if data["spawns"][i]["status"] != "unavailable"),
key=lambda s: -(s["weight"] or 0))[:2]
conds = {" · ".join(c["text"]) for s in best for c in s["conditions"] if c["text"] and c["biomes"]}
for c in sorted(conds)[:2]:
lines.append(f"Usually: {c}")
other = [s for s in sp["sources"] if s["status"] != "unavailable"]
if other:
lines.append("Also:" if sp["locations"] else "Not found in the wild here. Get it by:")
lines += [f"- {s['text']}" for s in other[:4]]
for ev in sp["evolutions"][:4]:
lines.append(f"Evolves into {ev['name']}" + (f" ({', '.join(ev['how'])})" if ev["how"] else ""))
if sp["drops"]:
names = [f"{_item_name(data, d['item'])} {d['chance']}" for d in sp["drops"][:5]]
lines.append("Drops: " + ", ".join(names))
return [ln for ln in lines if ln is not None]
def _item_name(data, iid):
return (data["items"].get(iid) or data["vanilla"].get(iid) or {}).get("name") or iid.split(":")[1].replace("_", " ")
def primary_location(data, sid):
"""Location where the species is easiest to meet: most common bucket, then highest weight."""
best, key = None, None
order = {"common": 0, "uncommon": 1, "rare": 2, "ultra-rare": 3, "boss": 4}
for i in data["species"][sid]["spawns"]:
s = data["spawns"][i]
if s["status"] != "available":
continue
for b in s["biomes"]:
k = (s.get("needs_blocks", False), order.get(s["bucket"], 9), -(s["weight"] or 0))
if key is None or k < key:
best, key = b, k
return best
def build(data):
b = Builder(data)
species = data["species"]
wild = b.chapter("field_guide_wild", "Field Guide: Wild Pokémon",
["Where each Pokémon lives on this island. Reference only: nothing here locks anything.",
"Generated from the pack's spawn data by tools/fieldguide."], item_icon("cobblemon:poke_ball"), 900)
by_loc = {}
for sid, sp in species.items():
if sp["wild"] == "available":
loc = primary_location(data, sid)
if loc:
by_loc.setdefault(loc, []).append(sid)
y = 0.0
for loc in data["locations"]:
if loc["kind"] == "end":
continue
mons = sorted(by_loc.get(loc["id"], []), key=lambda s: species[s]["dex"])
everyone = sorted({m["species"] for i in loc["spawns"] for m in data["spawns"][i]["members"]},
key=lambda s: species.get(s, {}).get("dex", 0))
desc = [f"{len(everyone)} Pokémon can appear here. Each one's own entry lists all its places.", ""]
desc += [", ".join(species[s]["name"] for s in everyone if s in species)]
b.quest(wild, "loc/" + loc["id"], 0, y, loc["name"], item_icon(BIOME_ICONS.get(loc["id"], "minecraft:grass_block")),
subtitle=f"{len(everyone)} Pokémon", desc=desc, shape="square", size=1.5)
for n, sid in enumerate(mons):
b.quest(wild, "mon/" + sid, 2 + (n % COLUMNS), y + (n // COLUMNS), species[sid]["name"], pokemon_icon(sid),
desc=species_lines(data, sid), shape="circle")
y += max(1, (len(mons) + COLUMNS - 1) // COLUMNS) + 1.5
other = b.chapter("field_guide_other", "Field Guide: Other Pokémon",
["Pokémon that do not spawn in the wild on this island, and how to get them anyway."],
item_icon("cobblemon:old_amber_fossil"), 901)
obtainable = sorted((s for s, sp in species.items() if sp["wild"] != "available" and sp["status"] != "unavailable"),
key=lambda s: species[s]["dex"])
for n, sid in enumerate(obtainable):
b.quest(other, "mon/" + sid, n % COLUMNS, n // COLUMNS, species[sid]["name"], pokemon_icon(sid),
desc=species_lines(data, sid), shape="circle")
none = sorted((s for s, sp in species.items() if sp["status"] == "unavailable" and sp["implemented"]),
key=lambda s: species[s]["dex"])
rows = (len(obtainable) + COLUMNS - 1) // COLUMNS + 1
chunks = [none[i:i + 40] for i in range(0, len(none), 40)]
for n, chunk in enumerate(chunks):
b.quest(other, f"none/{n}", n * 2, rows + 0.5, "Not obtainable here" + (f" ({n + 1})" if len(chunks) > 1 else ""),
item_icon("minecraft:barrier"), subtitle=f"{len(chunk)} species",
desc=["These have no wild spawn, evolution, fossil or starter route on this island:", ""]
+ [", ".join(species[s]["name"] for s in chunk)], shape="square")
items = b.chapter("field_guide_items", "Field Guide: Items",
["How to get each Cobblemon item on this island and what it is for.",
"Red text: no source on the island."], item_icon("cobblemon:water_stone"), 902)
cats = {}
for iid, it in data["items"].items():
if not it["technical"] and (it["sources"] or it["uses"]):
cats.setdefault(it["category"], []).append(iid)
y = 0.0
for cat in sorted(cats):
ids = sorted(cats[cat], key=lambda i: data["items"][i]["name"])
for n, iid in enumerate(ids):
it = data["items"][iid]
lines = list(it["tooltip"][:2])
if it["status"] == "unavailable":
lines.append("&cNo source on this island.")
elif it["status"] == "conditional":
lines.append("&6Only under a condition (e.g. the End).")
srcs = [s for s in it["sources"] if RANK[s["status"]]] or it["sources"]
if srcs:
lines += ["", "How to get it:"] + [f"- {s['text']}" + (f" ({s['chance']})" if s.get("chance") else "")
for s in srcs[:6]]
if len(srcs) > 6:
lines.append(f"- and {len(srcs) - 6} more")
if it["uses"]:
lines += ["", "Used for:"] + [f"- {u['text']}" for u in it["uses"][:6]]
if len(it["uses"]) > 6:
lines.append(f"- and {len(it['uses']) - 6} more")
b.quest(items, "item/" + iid, n % COLUMNS, y + n // COLUMNS, it["name"], item_icon(iid),
subtitle=cat, desc=lines, shape="hexagon" if it["status"] == "available" else "octagon")
y += (len(ids) + COLUMNS - 1) // COLUMNS + 1
return b
README = """# Field Guide chapters for FTB Quests (generated, not installed)
Made by `python3 tools/fieldguide/build.py` from `build/field-guide/fieldguide.json`.
Format: FTB Quests {version} for NeoForge 1.21.1, read from its source.
Contents (paths relative to the game or server folder):
config/ftbquests/quests/chapters/field_guide_wild.snbt wild Pokémon by location
config/ftbquests/quests/chapters/field_guide_other.snbt Pokémon you get another way
config/ftbquests/quests/chapters/field_guide_items.snbt Cobblemon items: how to get, what for
config/ftbquests/quests/lang/en_us.snbt titles and descriptions (only these chapters' keys)
All entries are reference quests with one checkmark task, no dependencies and no rewards, so nothing locks.
Ids are hashes of names and stay the same between runs.
## Installing (your decision)
1. Stop the game or server (FTB Quests rewrites these files when it saves).
2. Copy the three chapter files into `config/ftbquests/quests/chapters/`.
3. Titles and descriptions: if `config/ftbquests/quests/lang/en_us.snbt` does not exist yet, copy ours.
If it exists (for example after Phase 8), merge: put every line of ours inside the existing top-level
`{{ }}`. Keys never collide with other chapters' keys because they use these chapters' ids.
4. For the pack: the same files go under `pack/config/ftbquests/quests/` and are refreshed with packwiz.
5. Start the game and open the quest book. `/ftbquests reload` re-reads the files on a running server.
When regenerating, replace the three chapter files and our keys in the lang file; FTB Quests keeps
manual edits (positions, extra quests) only in its own saved copy, so regenerate before editing by hand.
"""
def write(data, out, ftb_version="2101.1.37"):
b = build(data)
base = out / "ftbquests"
qdir = base / "config/ftbquests/quests"
(qdir / "chapters").mkdir(parents=True, exist_ok=True)
(qdir / "lang").mkdir(parents=True, exist_ok=True)
for ch in b.chapters:
text = snbt(ch) + "\n"
if parse_snbt(text) != ch: # round trip through an FTB-style reader
raise SystemExit(f"SNBT round trip failed for {ch['filename']}")
(qdir / "chapters" / f"{ch['filename']}.snbt").write_text(text)
text = snbt(b.lang) + "\n"
if parse_snbt(text) != b.lang:
raise SystemExit("SNBT round trip failed for lang/en_us.snbt")
(qdir / "lang/en_us.snbt").write_text(text)
(base / "README.md").write_text(README.format(version=ftb_version))
return {ch["filename"]: len(ch["quests"]) for ch in b.chapters}
+72
View File
@@ -0,0 +1,72 @@
"""Pokédex text override: Cobblemon's description keys with "Found in: …" appended.
The keys are the ones each species JSON lists under "pokedex" (for example
cobblemon.species.eevee.desc, plus form keys such as cobblemon.species.eevee-gmax.desc
for non-battle forms). Values are Cobblemon's own English text from its jar
with one sentence added, written as kubejs/assets/cobblemon/lang/en_us.json,
which KubeJS serves as a resource pack. Language files merge key by key, so
only these keys are overridden.
"""
import json
MAX_PLACES = 4
def suffix(sp):
if sp["wild"] == "available" and sp["locations"]:
natural = [l for l in sp["locations"] if not l.get("special")] or sp["locations"]
locs = sorted(natural, key=lambda l: (min(_rarity(b) for b in l["buckets"]), l["name"]))
parts = [f"{l['name']} (Lv {l['level'][0]}-{l['level'][1]})" for l in locs[:MAX_PLACES]]
more = f" and {len(locs) - MAX_PLACES} more" if len(locs) > MAX_PLACES else ""
return f"Found in: {', '.join(parts)}{more}."
if sp["wild"] == "conditional" and sp["locations"]:
return "Found in: " + ", ".join(l["name"] for l in sp["locations"][:MAX_PLACES]) + " (the End)."
ways = [s["text"] for s in sp["sources"] if s["status"] != "unavailable"]
if ways:
return f"Not wild on this island. {ways[0]}."
return "Cannot be obtained on this island." if sp["implemented"] else ""
def _rarity(bucket):
return {"common": 0, "uncommon": 1, "rare": 2, "ultra-rare": 3, "boss": 4}.get(bucket, 5)
def build(data, lang):
out, missing = {}, []
for sid, sp in data["species"].items():
add = suffix(sp)
if not add:
continue
for key in sp["pokedex_keys"]:
if key not in lang:
missing.append(key)
continue
out[key] = f"{lang[key].rstrip()} {add}"
return dict(sorted(out.items())), missing
README = """# Pokédex "Found in" text (generated, not installed)
`kubejs/assets/cobblemon/lang/en_us.json` overrides {count} of Cobblemon's Pokédex description keys
(`cobblemon.species.<name>.desc` and form keys) with the original English text plus one sentence:
"Found in: Plains (Lv 5-32), …", or how to get the Pokémon if it is not wild on this island.
## Installing (your decision)
1. Copy `kubejs/assets/cobblemon/lang/en_us.json` to `pack/kubejs/assets/cobblemon/lang/en_us.json`
(or to `kubejs/assets/...` in an instance). KubeJS loads `kubejs/assets` as a resource pack on the client.
2. If that file already exists, merge the keys (later keys win; nothing else uses these keys).
3. Restart the client or press F3+T, then open the Pokédex on a Pokémon.
Regenerate after a Cobblemon update or a spawn change: the file holds a copy of Cobblemon's own text.
Other languages keep Cobblemon's text; only en_us is generated.
"""
def write(data, lang, out):
entries, missing = build(data, lang)
dst = out / "kubejs/assets/cobblemon/lang/en_us.json"
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_text(json.dumps(entries, ensure_ascii=False, indent=2) + "\n")
(out / "kubejs/README.md").write_text(README.format(count=len(entries)))
return len(entries), missing
+299
View File
@@ -0,0 +1,299 @@
"""Turn the model into fieldguide.json, the single file every renderer reads.
Schema (version 1), all ids are namespaced ("cobblemon:eevee"):
meta generator info, layers read, status legend, rarity bucket chances, kubejs_export
(when the KubeJS recipe export was made, whether it is stale, what it removed/added)
island biomes (kind, status), dimensions, structures (status, reason)
locations [{id, name, kind, status, spawns: [spawn index]}] in display order
spawns [{id, species members, level, bucket, weight, position, conditions, status, biomes, zones}];
zones: the companion mod's zone condition (ids from world/zones.json); species locations
carry the zone names too
hidden_spawns same shape, for spawn pools a pack.mcmeta filter hides (Cobblemon's own pools, replaced
by the zone pools): never in the game, but resolved against the island the same way
(status = what it would be if active); tools/zone_spawns.py plans the zones from them
species {id: {name, dex, types, status, wild, locations, sources, evolutions, drops, pokedex_keys}}
items {id: {name, mod, category, tooltip, status, sources, uses, icon, technical}} (mod items)
vanilla {id: {name, status, icon}} for vanilla items the guide mentions
recipes {id: {type, label, result, count, grid, slots, script?}}; script ("server_scripts:x.js:12")
marks a recipe a pack KubeJS script added or changed
problems data the generator could not resolve
A runtime exporter or KubeJS dump can replace or extend any section; renderers
only read this file.
"""
import datetime
import json
from .datasource import split_id
from .model import CATEGORIES, TECHNICAL
from .world import RANK
KIND_ORDER = {"surface": 0, "cave": 1, "water": 2, "end": 3, "nether": 4}
def build(model, icons=None):
m, isl = model, model.island
st = model.get_status
icons = icons or {}
locations = []
loc_spawns = {b: [] for b in isl.biomes}
real_biomes = set(m.stack.ids("worldgen/biome")) # tags also name biomes of mods that are not installed
spawns = [_spawn(m, i, e, "spawn", real_biomes) for i, e in enumerate(m.spawns.entries)]
for i, e in enumerate(m.spawns.entries):
for b in e["biomes"]:
loc_spawns.setdefault(b, []).append(i)
for b, info in sorted(isl.biomes.items(), key=lambda kv: (KIND_ORDER.get(kv[1]["kind"], 9), kv[0])):
if info["kind"] == "nether":
continue
idx = [i for i in loc_spawns.get(b, []) if spawns[i]["status"] != "unavailable"]
locations.append({"id": b, "name": m.biome_name(b), "kind": info["kind"], "status": info["status"],
"reason": info.get("reason", ""), "spawns": idx})
species = {}
for sid, sp in sorted(m.species.items(), key=lambda kv: (kv[1]["dex"], kv[0])):
srcs = m.sources.get(f"species:{sid}", [])
wild = [s for s in srcs if s["kind"] == "wild"]
wild_status = max((s["status"] for s in wild), key=RANK.get, default="unavailable")
locs = {}
for s in wild:
if RANK[s["status"]]:
e = spawns[s["spawn"]]
for b in e["biomes"]:
lv = next((mm["level"] for mm in e["members"] if mm["species"] == sid), e["level"])
cur = locs.setdefault(b, {"level": None, "alpha": None, "buckets": set(), "special": True,
"zones": set()})
cur["zones"] |= set(e["zones"])
k = "alpha" if e["bucket"] == "boss" else "level"
cur[k] = list(lv) if cur[k] is None else [min(cur[k][0], lv[0]), max(cur[k][1], lv[1])]
cur["buckets"].add(e["bucket"])
cur["special"] = cur["special"] and e["needs_blocks"]
other = [_source(m, s) for s in srcs if s["kind"] != "wild"]
species[sid] = {
"name": sp["name"], "dex": sp["dex"], "types": sp["types"], "implemented": sp["implemented"],
"labels": sp["labels"], "status": st(f"species:{sid}"), "wild": wild_status,
"locations": [{"biome": b, "name": m.biome_name(b), "level": v["level"] or v["alpha"], "alpha_level": v["alpha"],
"buckets": sorted(v["buckets"]),
"zones": [m.spawns.zones.get(z, {}).get("name", z) for z in
sorted(v["zones"], key=lambda z: m.spawns.zones.get(z, {}).get("levels") or [0])],
"special": v["special"]}
for b, v in sorted(locs.items(), key=lambda kv: (kv[1]["special"], m.biome_name(kv[0])))],
"spawns": [s["spawn"] for s in wild],
"sources": sorted(other, key=lambda s: -RANK[s["status"]]),
"evolutions": [{"to": ev["to"], "name": m.species_name(ev["to"]), "how": ev["how"], "form": ev["form"]}
for ev in m.evo_from.get(sid, [])],
"pre": sp["pre"], "drops": _species_drops(sp.get("drops")),
"pokedex_keys": sp["pokedex"] + [k for f in sp["forms"] for k in f["pokedex"] if not f["battle_only"]],
"spawn_biomes_elsewhere": sorted({n for i in m.spawn_by_species.get(sid, [])
for n in spawns[i]["all_biomes"]})[:20],
"why_not_wild": sorted({w for i in m.spawn_by_species.get(sid, []) for w in spawns[i]["why_not"]})[:6]
if wild_status == "unavailable" else [],
}
items, vanilla = {}, {}
mentioned = set()
for iid in m.mod_items:
srcs = [_source(m, s) for s in _useful_sources(m, iid)]
uses = _dedupe(m.uses.get(iid, []))
for s in srcs:
mentioned.update(s.get("mention", []))
items[iid] = {
"name": m.item_name(iid), "mod": split_id(iid)[0], "category": _category(m, iid),
"tooltip": m.tooltip(iid), "status": st(f"item:{iid}"), "technical": iid in TECHNICAL,
"sources": sorted(srcs, key=lambda s: (-RANK[s["status"]], s["kind"])), "uses": uses,
"icon": icons.get(iid), "tags": sorted(t for t in m.itags.all_tags() if iid in m.itags.resolve(t))[:12],
"reason": _reason(m, iid, srcs),
}
recipes = {}
for rid, r in m.recipes.items():
if r["result"] in items or any(i in items for s in r["slots"] for i in s["items"]):
recipes[rid] = {k: r[k] for k in ("type", "label", "result", "count", "grid", "slots", "script") if k in r}
recipes[rid]["status"] = _recipe_status(m, rid)
mentioned.add(r["result"])
for s in r["slots"]:
mentioned.update(s["items"][:8])
for iid in sorted(mentioned):
if iid not in items and iid in m.items:
vsrcs = [_source(m, s) for s in _useful_sources(m, iid)]
vanilla[iid] = {"name": m.item_name(iid), "status": st(f"item:{iid}"), "icon": icons.get(iid),
"sources": sorted(vsrcs, key=lambda s: -RANK[s["status"]])[:12], "reason": _reason(m, iid, vsrcs)}
missing_vanilla = sorted(i for i in m.vanilla_items if st(f"item:{i}") == "unavailable")
spawner = m.stack.get("spawning", "cobblemon:best-spawner-config") or {}
return {
"meta": {
"schema": 1, "generated": datetime.datetime.now().astimezone().isoformat(timespec="seconds"),
"layers": [layer.name for layer in m.stack.layers], "world_type": isl.world_type,
"statuses": {"available": "obtainable on the island", "conditional": "needs something that may not "
"exist (the End, a stronghold inside the border)", "unavailable": "no source on the island"},
"buckets": spawner.get("worldBuckets", {}),
"kubejs_export": m.kubejs and {k: m.kubejs[k] for k in ("exported", "kubejs", "stale", "removed")}
| {"added_or_changed": sorted(m.kubejs["recipes"])},
},
"island": {
"dimensions": isl.dimensions,
"biomes": [{"id": b, "name": m.biome_name(b), **{k: v for k, v in info.items()}} for b, info in isl.biomes.items()],
"structures": [{"id": s, "name": m.structure_name(s), "status": v["status"], "reason": v["reason"],
"biomes": v["biomes"]} for s, v in sorted(isl.structures.items())],
"notes": isl.notes,
},
"locations": locations, "spawns": spawns, "species": species, "items": items, "vanilla": vanilla,
"hidden_spawns": [_spawn(m, i, e, "hidden_spawn", real_biomes) for i, e in enumerate(m.hidden_spawns.entries)],
"recipes": recipes,
"problems": {
**{k: sorted(v) for k, v in m.unresolved.items()},
"vanilla items with no source on the island": missing_vanilla,
"unknown tags": sorted(set().union(*(t.missing for t in m.tags.values()))),
"unreadable files": m.stack.errors,
"spawn pools skipped": [f"{a} ({b})" for a, b in m.spawns.skipped],
"zones in spawn conditions but not in world/zones.json": sorted(m.spawns.unknown_zones | m.hidden_spawns.unknown_zones),
},
}
def _spawn(m, i, e, prefix, real_biomes):
isl = m.island
status = m.get_status(f"{prefix}:{i}")
srcs = m.sources.get(f"{prefix}:{i}", [])
conds = []
for c, src in zip(e["conditions"], srcs):
conds.append({"text": c["text"], "biomes": c["biomes"], "status": src.get("status"),
"structures": c["structures"]["present"], "blocked": _names(m, src.get("blocked_by"))})
return {
"i": i, "id": e["id"], "pool": e["pool"], "type": e["type"], "bucket": e["bucket"], "weight": e["weight"],
"level": e["level"], "position": e["position"], "max_herd": e["max_herd"],
"members": [{"species": mm["species"], "name": m.species_name(mm["species"]), "props": mm["props"],
"level": mm["level"], "role": mm["role"], "weight": mm.get("weight")} for mm in e["members"]],
"conditions": conds, "anticonditions": [a["text"] for a in e["anticonditions"] if a["text"]],
"multipliers": e["multipliers"], "status": status, "biomes": e["biomes"], "zones": e["zones"],
# every alternative needs particular blocks nearby (often player-built: concrete, pyramid blocks)
"needs_blocks": all(any(t.startswith("near ") and "leaves" not in t for t in c["text"])
for c in e["conditions"]),
"all_biomes": sorted({m.biome_name(b) for c in e["conditions"] for b in c.get("all_ids", [])
if b in real_biomes and b not in isl.biomes}),
"why_not": _why_not(e, srcs, isl, m, real_biomes) if status == "unavailable" else [],
"drops": e.get("drops"),
}
def _why_not(e, srcs, isl, m, real_biomes):
"""Short reasons a spawn entry cannot happen on the island."""
out = []
for c, src in zip(e["conditions"], srcs):
if c["structures"]["refs"] and not c["structures"]["present"]:
out.append("needs a structure that does not generate here: "
+ ", ".join(r.lstrip("#").split(":")[-1].replace("_", " ") for r in c["structures"]["refs"]))
elif not c["biomes"]:
names = sorted({m.biome_name(b) for b in c.get("all_ids", []) if b in real_biomes})
out.append("biomes not on the island: " + ", ".join(names[:5]) + (" …" if len(names) > 5 else "")
if names else "no biome on the island")
elif src.get("blocked_by"):
out.append("needs " + "; ".join(_names(m, src["blocked_by"])))
return out
def _names(m, groups):
out = []
for g in groups or []:
if not g:
out.append("an ingredient no item matches (empty tag)")
continue
names = []
for k in g[:4]:
kind, rid = k.split(":", 1)
names.append({"item": m.item_name, "block": m.item_name, "mob": m.entity_name,
"species": m.species_name}.get(kind, lambda x: x)(rid) if kind != "spawn" else "a wild spawn")
out.append(" or ".join(dict.fromkeys(names)) + (" …" if len(g) > 4 else ""))
return out
def _source(m, s):
out = {"kind": s["kind"], "text": s["text"], "status": s.get("status", "unavailable")}
for k in ("chance", "recipe", "places", "biomes", "species", "block", "mob", "table", "fossils", "parents"):
if s.get(k) is not None:
out[k] = s[k]
if s.get("blocked_by"):
out["blocked"] = _names(m, s["blocked_by"])
mention = [k.split(":", 1)[1] for g in s.get("needs", []) for k in g[:3] if k.startswith("item:")]
if mention:
out["mention"] = mention
if out.get("biomes"):
out["biome_names"] = [m.biome_name(b) for b in out["biomes"]]
return out
def _reason(m, iid, srcs):
"""One line on why an item has no source on the island (empty if it has one)."""
if m.get_status(f"item:{iid}") != "unavailable":
return ""
if iid in m.island.removed_blocks:
return "ore: its world generation is switched off on this island"
blocked = [s for s in srcs if s.get("blocked")]
if blocked:
return "needs " + blocked[0]["blocked"][0]
if srcs:
text = srcs[0]["text"]
return text if text.endswith(")") else text + " (not on the island)"
raw = m.sources.get(f"item:{iid}", [])
if raw:
return "only from its own block, which never generates here"
return "no recipe, loot table, drop or worldgen feature in the data"
def _useful_sources(m, iid):
"""Drop circular 'break the placed block' sources and duplicates."""
out, seen = [], set()
for s in m.sources.get(f"item:{iid}", []):
if s["kind"] == "block" and s.get("block") == iid and m.island.natural_status(iid)[0] == "unavailable":
continue
key = (s["kind"], s["text"], s.get("recipe"), s.get("chance"))
if key in seen:
continue
seen.add(key)
out.append(s)
return out
def _dedupe(uses):
out, seen = [], set()
for u in uses:
key = (u["kind"], u["text"])
if key not in seen:
seen.add(key)
out.append(u)
return out
def _category(m, iid):
for name, tags in CATEGORIES:
if any(iid in m.itags.resolve(t) for t in tags):
return name
if iid in m.blocks:
return "Blocks"
return "Other"
def _recipe_status(m, rid):
for s in m.sources.get(f"item:{m.recipes[rid]['result']}", []):
if s.get("recipe") == rid:
return s["status"]
return "unavailable"
def _species_drops(drops):
out = []
for e in (drops or {}).get("entries", []):
chance = f"{e['percentage']:g}%" if "percentage" in e else f"×{e.get('quantityRange', e.get('quantity', 1))}"
out.append({"item": e["item"] if ":" in e["item"] else "minecraft:" + e["item"], "chance": chance})
return out
def write(data, path):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False, indent=1, default=_default) + "\n")
def _default(o):
if isinstance(o, set):
return sorted(o)
raise TypeError(type(o))
+144
View File
@@ -0,0 +1,144 @@
"""Item icons rendered from the jars' item models into build/field-guide/icons/.
Flat items composite their layer textures; block items get a small isometric
cube from their block model's faces; cross-shaped plants use their texture.
The PNGs are third-party assets (Cobblemon's assets are not MPL-licensed), so
they only ever go to build/ and are never committed.
"""
import io
from PIL import Image, ImageEnhance
from .datasource import split_id
S = 16
FACES = { # texture keys tried for each visible face of a cube
"top": ("up", "top", "end", "all", "texture", "side", "particle"),
"left": ("south", "front", "side", "all", "texture", "north", "particle"),
"right": ("east", "side", "all", "texture", "west", "particle"),
}
FLAT_PARENTS = ("item/generated", "item/handheld", "item/handheld_rod", "builtin/generated")
CROSS_PARENTS = ("block/cross", "block/tinted_cross", "block/flower_pot_cross", "block/crop")
FOLIAGE = (72, 181, 24)
class Icons:
def __init__(self, stack, out_dir):
self.stack, self.out = stack, out_dir
self.models = {}
self.missing = []
def model(self, mid):
"""Merged model: (parent chain ids, textures, elements)."""
if mid in self.models:
return self.models[mid]
ns, path = split_id(mid)
d = self.stack.json(f"assets/{ns}/models/{path}.json") or {}
chain, textures, elements = [f"{ns}:{path}"], {}, d.get("elements")
parent = d.get("parent")
if parent and len(chain) < 20:
pchain, ptex, pel = self.model(parent if ":" in parent else "minecraft:" + parent)
chain += pchain
textures.update(ptex)
elements = elements or pel
textures.update(d.get("textures", {}))
self.models[mid] = (chain, textures, elements)
return self.models[mid]
def texture(self, ref, textures, depth=0):
while isinstance(ref, str) and ref.startswith("#") and depth < 10:
ref, depth = textures.get(ref[1:]), depth + 1
if not isinstance(ref, str):
return None
ns, path = split_id(ref)
raw = self.stack.read(f"assets/{ns}/textures/{path}.png")
if raw is None:
return None
img = Image.open(io.BytesIO(raw)).convert("RGBA")
if img.height > img.width: # animated strip: first frame
img = img.crop((0, 0, img.width, img.width))
return img.resize((S, S), Image.NEAREST) if img.size != (S, S) else img
def render(self, iid):
ns, path = split_id(iid)
chain, tex, elements = self.model(f"{ns}:item/{path}")
if len(chain) == 1 and not tex:
chain, tex, elements = self.model(f"{ns}:block/{path}")
if len(chain) == 1 and not tex: # no model at all: KubeJS items get item/generated with their own texture
chain, tex = [f"{ns}:item/{path}", "minecraft:item/generated"], {"layer0": f"{ns}:item/{path}"}
names = [c.split(":", 1)[1] for c in chain]
if any(n in FLAT_PARENTS for n in names) or any(k.startswith("layer") for k in tex):
layers = sorted((k for k in tex if k.startswith("layer")), key=lambda k: int(k[5:] or 0))
img = None
for k in layers:
t = self.texture(tex[k], tex)
if t is not None:
img = t if img is None else Image.alpha_composite(img, t)
return img.resize((32, 32), Image.NEAREST) if img else None
if any(n in CROSS_PARENTS for n in names):
t = self.texture(tex.get("cross") or tex.get("crop") or tex.get("plant") or tex.get("particle"), tex)
return t.resize((32, 32), Image.NEAREST) if t else None
faces = {}
for face, keys in FACES.items():
for k in keys:
if k in tex:
faces[face] = self.texture(tex[k], tex)
if faces[face] is not None:
break
if not any(v is not None for v in faces.values()):
return None
tinted = any(n in ("block/leaves",) for n in names) or path.endswith("leaves")
return iso_cube(faces, tinted)
def write_all(self, ids):
out = {}
for iid in sorted(ids):
try:
img = self.render(iid)
except (OSError, ValueError) as e:
self.missing.append(f"{iid}: {e}")
img = None
if img is None:
self.missing.append(iid)
continue
ns, path = split_id(iid)
rel = f"icons/{ns}/{path}.png"
dst = self.out / rel
dst.parent.mkdir(parents=True, exist_ok=True)
img.save(dst)
out[iid] = rel
return out
def _tint(img, rgb):
r, g, b, a = img.split()
grey = img.convert("L")
return Image.merge("RGBA", [grey.point(lambda v, c=c: v * c // 255) for c in rgb] + [a])
def iso_cube(faces, tinted=False):
"""32x32 isometric block icon from top/left/right 16x16 face textures."""
canvas = Image.new("RGBA", (32, 32), (0, 0, 0, 0))
geometry = { # origin, u axis, v axis (output pixels per texture pixel)
"top": ((16, 0), (1, 0.5), (-1, 0.5)),
"left": ((0, 8), (1, 0.5), (0, 1)),
"right": ((16, 16), (1, -0.5), (0, 1)),
}
shade = {"top": 1.0, "left": 0.8, "right": 0.62}
for face in ("top", "left", "right"):
tex = faces.get(face) or faces.get("top") or next(v for v in faces.values() if v is not None)
if tinted:
tex = _tint(tex, FOLIAGE)
(ox, oy), (ux, uy), (vx, vy) = geometry[face]
det = ux * vy - uy * vx
# inverse affine: texture coords from output coords
a, b = vy / det, -vx / det
d, e = -uy / det, ux / det
c, f = -(a * ox + b * oy), -(d * ox + e * oy)
layer = tex.transform((32, 32), Image.AFFINE, (a, b, c, d, e, f), resample=Image.NEAREST,
fillcolor=(0, 0, 0, 0))
if shade[face] != 1.0:
rgb = ImageEnhance.Brightness(layer.convert("RGB")).enhance(shade[face])
layer = Image.merge("RGBA", (*rgb.split(), layer.split()[3]))
canvas = Image.alpha_composite(canvas, layer)
return canvas
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""The pack's own recipes, as KubeJS really applies them: read from the running test server.
pack/kubejs/server_scripts are code (event.remove with filters, event.shaped, replaceInput, ...),
so the guide does not parse them. Instead this boots the test server (build/server) once on a
throwaway void world and runs `/kubejs export debug` (KubeJS 2101), which reloads the data and
writes local/kubejs/export/:
recipes/<ns>/<path>.json every recipe after the scripts ran; the ones a script added or
changed carry "_kubejs_changed_marker": {source, line}
removed_recipes/<ns>/<path>.json every recipe a script removed (from mod jars and data packs alike)
registries/item.json the item registry, including items KubeJS startup scripts register
The changed recipes, the removed ids and the item ids are copied to build/fieldguide-cache/kubejs-export/
with a hash of the inputs (pack/kubejs scripts and data, pack/mods, datapacks/core).
tools/fieldguide/build.py runs this by itself when that hash no longer matches the pack.
Usage: tools/fieldguide/kubejs_export.py [--accept-eula] (run tools/build.py first)
"""
import argparse
import hashlib
import json
import os
import shutil
import sys
import time
from pathlib import Path
sys.dont_write_bytecode = True
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import mcserver # noqa: E402
ROOT = mcserver.ROOT
SERVER = mcserver.SERVER
EXPORT = SERVER / "local/kubejs/export"
CACHE = ROOT / "build/fieldguide-cache/kubejs-export"
LEVEL = "fieldguide-export"
MARKER = "_kubejs_changed_marker"
# Inputs that decide the recipe set KubeJS ends up with. Assets (textures, lang) do not.
INPUTS = [ROOT / "pack/kubejs/server_scripts", ROOT / "pack/kubejs/startup_scripts", ROOT / "pack/kubejs/data",
ROOT / "pack/mods", ROOT / "datapacks/core"]
def input_hash():
h = hashlib.sha256()
for base in INPUTS:
for p in sorted(base.rglob("*")) if base.exists() else []:
if p.is_file():
h.update(p.relative_to(ROOT).as_posix().encode() + b"\0" + p.read_bytes() + b"\0")
return h.hexdigest()
def load():
"""The cached export, or None: {"recipes": {id: json}, "removed": [id], "items": [id], "stale": bool, ...}."""
path = CACHE / "export.json"
if not path.exists():
return None
data = json.loads(path.read_text())
data["stale"] = data.pop("inputs") != input_hash()
return data
def server_busy():
"""True if some process is already running in build/server (another tool's test server)."""
for proc in Path("/proc").glob("[0-9]*"):
try:
if Path(os.readlink(proc / "cwd")) == SERVER.resolve():
return True
except OSError:
continue
return False
def check_scripts():
"""The server runs build/stage/full's copy of pack/kubejs; refuse to export an older one."""
differ = []
for sub in ("server_scripts", "startup_scripts", "data"):
base = ROOT / "pack/kubejs" / sub
for p in sorted(base.rglob("*")) if base.exists() else []:
if p.is_file():
rel = p.relative_to(ROOT / "pack")
served = SERVER / rel
if not served.is_file() or served.read_bytes() != p.read_bytes():
differ.append(rel.as_posix())
if differ:
raise SystemExit("build/server has an older copy of " + ", ".join(differ) + "; run tools/build.py first")
def export(log=print):
"""Boot the test server, run the KubeJS export, keep the recipe part in the cache. Returns load()."""
if not mcserver.eula_accepted():
raise SystemExit("build/server/eula.txt does not accept the Minecraft EULA yet "
"(tools/selftest.py --accept-eula writes it if you accept it)")
if server_busy():
raise SystemExit("a test server is already running in build/server; try again when it has stopped")
inputs = input_hash()
args_file = mcserver.prepare()
check_scripts()
shutil.rmtree(SERVER / LEVEL, ignore_errors=True)
shutil.rmtree(EXPORT, ignore_errors=True)
flat = '{"layers":[{"block":"minecraft:bedrock","height":1}],"biome":"minecraft:the_void"}'
props = {"level-name": LEVEL, "level-type": "minecraft:flat", "generator-settings": flat,
"generate-structures": "false", "spawn-monsters": "false", "spawn-animals": "false"}
started = time.time()
with mcserver.Server(args_file, props, stdout_name="fieldguide-export-stdout.log") as server:
log(f"test server started in {server.wait_ready():.0f}s")
reply = server.command("kubejs export debug").strip()
if "exporting" not in reply.lower():
raise SystemExit(f"/kubejs export debug answered {reply!r}")
_wait_for_export(server)
server.stop()
shutil.rmtree(SERVER / LEVEL, ignore_errors=True)
recipes = {}
for p in sorted((EXPORT / "recipes").rglob("*.json")):
d = json.loads(p.read_text())
if MARKER in d:
recipes[_id(p, "recipes")] = d
removed = sorted(_id(p, "removed_recipes") for p in (EXPORT / "removed_recipes").rglob("*.json"))
items = sorted(json.loads((EXPORT / "registries/item.json").read_text()))
kubejs = next((p.name for p in sorted((SERVER / "mods").glob("kubejs-*.jar"))), None)
CACHE.mkdir(parents=True, exist_ok=True)
(CACHE / "export.json").write_text(json.dumps({
"inputs": inputs, "kubejs": kubejs, "exported": time.strftime("%Y-%m-%d %H:%M"),
"removed": removed, "recipes": recipes, "items": items}, indent=1) + "\n")
log(f"KubeJS recipes: {len(recipes)} added or changed, {len(removed)} removed ({time.time() - started:.0f}s)")
return load()
def _id(path, sub):
"""recipes/cobblemon/foo/bar.json -> cobblemon:foo/bar"""
return path.relative_to(EXPORT / sub).as_posix()[:-5].replace("/", ":", 1)
def _wait_for_export(server, timeout=300):
"""The export reloads first and writes the files afterwards; wait for index.json and every file it lists
(the index names them by id, cobblemon:foo.json, and the file is cobblemon/foo.json)."""
deadline = time.time() + timeout
while time.time() < deadline:
try:
listed = json.loads((EXPORT / "index.json").read_text())
if all((EXPORT / f.replace(":", "/")).exists() for f in listed):
return
except (OSError, ValueError):
pass
if server.proc.poll() is not None:
raise SystemExit(f"the test server stopped during the export; see {server.log}")
time.sleep(1)
raise SystemExit(f"/kubejs export debug wrote nothing in {timeout}s; see {server.log}")
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("--accept-eula", action="store_true", help="write eula=true for the test server")
args = ap.parse_args()
if args.accept_eula:
(SERVER / "eula.txt").parent.mkdir(parents=True, exist_ok=True)
(SERVER / "eula.txt").write_text("eula=true\n")
data = export()
for rid in data["removed"]:
print(f" - {rid}")
for rid, d in sorted(data["recipes"].items()):
print(f" + {rid} ({d[MARKER].get('source')}:{d[MARKER].get('line')})")
print(f"Wrote {CACHE / 'export.json'}")
if __name__ == "__main__":
main()
+138
View File
@@ -0,0 +1,138 @@
"""Loot tables flattened to (item, approximate chance, count, notes).
Chances are estimates for a reader (expected rolls x weight share x
random_chance conditions); conditions that are not random become notes.
"""
from .datasource import norm_id
def number(v, default=1.0):
"""A loot number provider -> (min, max)."""
if v is None:
return default, default
if isinstance(v, (int, float)):
return float(v), float(v)
t = v.get("type", "minecraft:uniform")
if "min" in v or "max" in v:
lo, hi = number(v.get("min"), 0)[0], number(v.get("max"), 0)[1]
return lo, hi
if t.endswith("binomial"):
n = number(v.get("n"))[1]
return 0.0, n
if t.endswith("constant"):
return float(v.get("value", 1)), float(v.get("value", 1))
return default, default
def condition_effect(conds):
"""(chance factor, notes) of a list of loot conditions."""
factor, notes = 1.0, []
for c in conds or []:
t = c.get("condition", "")
if t.endswith("random_chance"):
ch = c.get("chance", 1)
factor *= ch if isinstance(ch, (int, float)) else number(ch)[1]
elif t.endswith("random_chance_with_enchanted_bonus"):
ch = c.get("unenchanted_chance", 0)
factor *= ch if isinstance(ch, (int, float)) else 0.05
notes.append("Looting raises it")
elif t.endswith("random_chance_with_looting"):
factor *= c.get("chance", 1)
elif t.endswith("table_bonus"):
factor *= (c.get("chances") or [1])[0]
elif t.endswith("killed_by_player"):
notes.append("player kill")
elif t.endswith("match_tool"):
pred = c.get("predicate", {})
enchant = str(pred.get("predicates", {}).get("minecraft:enchantments", "")) + str(pred.get("enchantments", ""))
if "silk_touch" in enchant:
notes.append("Silk Touch")
elif pred.get("items"):
items = pred["items"] if isinstance(pred["items"], list) else [pred["items"]]
notes.append("with " + ", ".join(i.split(":")[-1].replace("_", " ") for i in items))
else:
notes.append("specific tool")
elif t.endswith("block_state_property"):
props = c.get("properties", {})
if "age" in props or "stage" in props:
notes.append("when fully grown" if str(props.get("age", props.get("stage"))) not in ("0",) else "when young")
elif t.endswith("inverted"):
_, inner_n = condition_effect([c.get("term", {})])
if "Silk Touch" in inner_n:
notes.append("without Silk Touch")
elif t.endswith("entity_properties") and "on_fire" in str(c):
notes.append("cooked if on fire")
elif t.endswith("any_of") or t.endswith("alternative"):
pass
return factor, notes
class Loot:
def __init__(self, stack, item_tags):
self.stack, self.item_tags = stack, item_tags
self.ids = stack.ids("loot_table")
self.cache = {}
def table(self, tid):
return self.stack.json(self.ids[tid]) if tid in self.ids else None
def flatten(self, tid, _depth=0):
"""[{item, p, count:[lo,hi], notes}] for a loot table id or inline table."""
if isinstance(tid, str):
tid = norm_id(tid)
if tid in self.cache:
return self.cache[tid]
data = self.table(tid)
else:
data = tid
out = []
if data and _depth < 8:
for pool in data.get("pools", []):
lo, hi = number(pool.get("rolls", 1))
rolls = (lo + hi) / 2 # bonus_rolls only apply with Luck
pf, pnotes = condition_effect(pool.get("conditions"))
entries = list(self._entries(pool.get("entries", [])))
total = sum(w for _, w in entries) or 1
for e, w in entries:
ef, enotes = condition_effect(e.get("conditions"))
share = rolls * pf * ef * w / total
for d in self._entry_items(e, _depth):
out.append({"item": d["item"], "p": min(1.0, share * d["p"]),
"count": d["count"], "notes": sorted(set(pnotes + enotes + d["notes"]))})
if isinstance(tid, str):
self.cache[tid] = out
return out
def _entries(self, entries):
"""(entry, weight) pairs; composite entries are opened up."""
for e in entries:
t = e.get("type", "")
if t.endswith(("alternatives", "group", "sequence")):
for child, w in self._entries(e.get("children", [])):
yield child, w
else:
yield e, e.get("weight", 1)
def _entry_items(self, e, depth):
t = e.get("type", "")
lo, hi = 1.0, 1.0
notes = []
for f in e.get("functions", []):
fn = f.get("function", "")
if fn.endswith("set_count"):
lo, hi = number(f.get("count"))
elif fn.endswith(("furnace_smelt",)):
notes.append("cooked if on fire")
count = [lo, hi]
if t.endswith(":item"):
return [{"item": norm_id(e["name"]), "p": 1.0, "count": count, "notes": notes}]
if t.endswith(":tag"):
items = sorted(self.item_tags.resolve("#" + e["name"]))
if e.get("expand"):
return [{"item": i, "p": 1.0 / max(1, len(items)), "count": count, "notes": notes} for i in items]
return [{"item": i, "p": 1.0, "count": count, "notes": notes + ["all items of a tag"]} for i in items]
if t.endswith("loot_table"):
ref = e.get("value", e.get("name"))
nested = self.flatten(ref, depth + 1)
return [dict(d, notes=d["notes"] + notes) for d in nested]
return []
+21
View File
@@ -0,0 +1,21 @@
{
"_comment": "Item sources that Minecraft decides in code, so no data file shows them. Each entry: item, how (shown in the guide), needs (list of groups; each group is satisfied by any one of its keys: item:<id>, mob:<id>, block:<id>). Keep this list short and add only well-known mechanics; Phase 4 sources (ore veins, KubeJS recipes) go in an --extra file instead (see docs/field-guide.md).",
"sources": [
{"item": "minecraft:emerald", "how": "Trade with villagers", "needs": [["mob:minecraft:villager"]]},
{"item": "minecraft:milk_bucket", "how": "Milk a cow or goat with a bucket", "needs": [["mob:minecraft:cow", "mob:minecraft:goat", "mob:minecraft:mooshroom"], ["item:minecraft:bucket"]]},
{"item": "minecraft:egg", "how": "Laid by chickens", "needs": [["mob:minecraft:chicken"]]},
{"item": "minecraft:white_wool", "how": "Shear a sheep", "needs": [["mob:minecraft:sheep"], ["item:minecraft:shears"]]},
{"item": "minecraft:honeycomb", "how": "Shear a full bee nest", "needs": [["block:minecraft:bee_nest", "block:minecraft:beehive"], ["item:minecraft:shears"]]},
{"item": "minecraft:honey_bottle", "how": "Use a glass bottle on a full bee nest", "needs": [["block:minecraft:bee_nest", "block:minecraft:beehive"], ["item:minecraft:glass_bottle"]]},
{"item": "minecraft:turtle_scute", "how": "Dropped when a baby turtle grows up", "needs": [["mob:minecraft:turtle"]]},
{"item": "minecraft:water_bucket", "how": "Fill a bucket with water", "needs": [["item:minecraft:bucket"]]},
{"item": "minecraft:lava_bucket", "how": "Fill a bucket with lava", "needs": [["item:minecraft:bucket"], ["block:minecraft:lava"]]},
{"item": "minecraft:obsidian", "how": "Pour water on a lava source", "needs": [["item:minecraft:water_bucket"], ["block:minecraft:lava"]]},
{"item": "minecraft:dragon_breath", "how": "Collect the Ender Dragon's breath with a glass bottle", "needs": [["mob:minecraft:ender_dragon"], ["item:minecraft:glass_bottle"]]},
{"item": "minecraft:dragon_egg", "how": "Left by the first Ender Dragon", "needs": [["mob:minecraft:ender_dragon"]]},
{"item": "minecraft:pumpkin_seeds", "how": "Craft from a pumpkin", "needs": [["item:minecraft:pumpkin"]]},
{"item": "minecraft:mud", "how": "Use a water bottle on dirt", "needs": [["item:minecraft:glass_bottle"]]},
{"item": "minecraft:ominous_bottle", "how": "Dropped by raid captains", "needs": [["mob:minecraft:pillager"]]}
],
"concrete_from_powder": true
}
+793
View File
@@ -0,0 +1,793 @@
"""The field guide model: items, recipes, loot, Pokémon and what is obtainable on the island.
Every way to get something is a *source* with a base status (from the island
model) and *needs*: groups of node keys (item:, block:, mob:, species:,
spawn:) of which at least one per group must be obtainable. A fixpoint over
all nodes then decides what the island can provide, so an item whose only
recipe needs Nether quartz is flagged even though the recipe exists.
"""
import json
from collections import defaultdict
from .datasource import BUILD, norm_id, split_id
from .loot import Loot
from .spawns import Spawns, parse_props
from .tags import Tags
from .world import CODE_LOOT, RANK, Island
STATUS = {v: k for k, v in RANK.items()}
REGISTRIES = ("worldgen/biome", "item", "block", "worldgen/structure", "worldgen/placed_feature", "entity_type", "fluid")
# Recipe types -> the station item that must exist (crafting tables and furnaces are always reachable).
STATIONS = {
"minecraft:blasting": "minecraft:blast_furnace", "minecraft:smoking": "minecraft:smoker",
"minecraft:campfire_cooking": "minecraft:campfire", "minecraft:stonecutting": "minecraft:stonecutter",
"minecraft:smithing_transform": "minecraft:smithing_table", "minecraft:smithing_trim": "minecraft:smithing_table",
"cobblemon:cooking_pot": "#cobblemon:campfire_pots", "cobblemon:cooking_pot_shapeless": "#cobblemon:campfire_pots",
"cobblemon:brewing_stand": "minecraft:brewing_stand",
}
RECIPE_LABEL = {
"crafting_shaped": "Crafting", "crafting_shapeless": "Crafting (shapeless)", "smelting": "Smelting",
"blasting": "Blast furnace", "smoking": "Smoker", "campfire_cooking": "Campfire", "stonecutting": "Stonecutter",
"smithing_transform": "Smithing", "cooking_pot": "Cooking Pot", "cooking_pot_shapeless": "Cooking Pot (shapeless)",
"brewing_stand": "Brewing stand",
}
# KubeJS's own crafting types (used when a recipe needs KubeJS features) read like vanilla's.
KUBEJS_TYPES = {"kubejs:shaped": "minecraft:crafting_shaped", "kubejs:shapeless": "minecraft:crafting_shapeless"}
# Creative-only or operator items: listed, but never flagged as "no source".
TECHNICAL = {"cobblemon:pokemon_model", "cobblemon:npc_editor", "cobblemon:habitat_block",
"ftblibrary:fluid_container", "ftblibrary:icon_item", "ftbquests:barrier", "ftbquests:stage_barrier", "ftbquests:detector",
"ftbquests:custom_icon", "ftbquests:missing_item", "ftbquests:lootcrate", "ftbquests:banner",
"ftbquests:loot_crate_opener", "ftbquests:screen_1", "ftbquests:screen_3", "ftbquests:screen_5",
"ftbquests:screen_7", "ftbquests:task_screen_configurator", "ftbquests:progress_detector"}
CATEGORIES = [ # first match wins
("Poké Balls", ["#cobblemon:poke_balls"]), ("Poké Rods", ["#cobblemon:poke_rods"]),
("Evolution items", ["#cobblemon:evolution_items", "#cobblemon:evolution_stones"]),
("Fossils", ["#cobblemon:fossils"]), ("Berries", ["#cobblemon:berries"]),
("Apricorns", ["#cobblemon:apricorns", "#cobblemon:apricorn_sprouts"]), ("Mints", ["#cobblemon:mints", "#cobblemon:mint_leaves", "#cobblemon:mint_seeds"]),
("Medicine", ["#cobblemon:potions", "#cobblemon:remedies", "#cobblemon:revives", "#cobblemon:ethers",
"#cobblemon:full_heal_bottles", "#cobblemon:restores"]),
("Vitamins & candy", ["#cobblemon:vitamins", "#cobblemon:candies", "#cobblemon:experience_candies",
"#cobblemon:iv_candies", "#cobblemon:mochis", "#cobblemon:feathers", "#cobblemon:ability_changers"]),
("Held items", ["#cobblemon:held/is_held_item"]), ("Type gems", ["#cobblemon:type_gems"]),
("Food & cooking", ["#cobblemon:poke_food", "#cobblemon:regional_delicacies", "#cobblemon:sweets",
"#cobblemon:aprijuices", "#cobblemon:teacups", "#cobblemon:herbs", "#cobblemon:seeds"]),
("Machines", ["#cobblemon:machines", "#cobblemon:fossil_machine_parts", "#cobblemon:pokedex"]),
]
class Model:
def __init__(self, stack, log=print, extras=(), kubejs=None):
self.stack, self.log, self.kubejs = stack, log, kubejs
self.tags = {r: Tags(stack, r) for r in REGISTRIES}
self.itags = self.tags["item"]
log("island: biomes, structures, natural blocks, mobs")
self.island = Island(stack, self.tags, log)
log("spawns")
self.spawns = Spawns(stack, self.tags, self.island)
# Spawn pools a pack.mcmeta filter hides (Cobblemon's own, replaced by the zone pools): not in the game,
# but resolved the same way for tools that plan from them (tools/zone_spawns.py).
self.hidden_spawns = Spawns(stack, self.tags, self.island, pools=stack.hidden("spawn_pool_world"))
self.loot = Loot(stack, self.itags)
self.lang = self._lang()
self.sources = defaultdict(list) # node key -> [source]
self.uses = defaultdict(list) # item -> [use]
self.recipes = {}
self.pack_recipes = set() # recipe ids added or changed by the pack's KubeJS scripts
self.referenced = set()
self.unresolved = defaultdict(set)
self._registry_vanilla()
log("species")
self._species()
log("recipes")
self._recipes()
self._kubejs(kubejs)
log("loot tables")
self._loot_sources()
self._drops()
self._berries()
self._fossils()
self._interactions()
self._manual()
for extra in extras:
self._extra(extra)
self._registry_mods()
self._world_nodes()
self._spawn_nodes()
self._evolutions()
self._uses()
log("solving availability")
self._solve()
# --- names --------------------------------------------------------------------------------
def _lang(self):
lang = {}
for rel, idxs in self.stack.index.items():
if rel.endswith("/lang/en_us.json") and rel.startswith("assets/"):
for d in self.stack.all_json(rel):
lang.update(d)
return lang
def item_name(self, iid):
ns, path = split_id(iid)
for key in (f"item.{ns}.{path}", f"block.{ns}.{path}"):
if key in self.lang:
return self.lang[key]
return path.replace("_", " ").title()
def biome_name(self, b):
ns, path = split_id(b)
return self.lang.get(f"biome.{ns}.{path}", path.replace("_", " ").title())
def entity_name(self, e):
ns, path = split_id(e)
return self.lang.get(f"entity.{ns}.{path}", path.replace("_", " ").title())
def structure_name(self, s):
ns, path = split_id(s)
return path.split("/")[-1].replace("_", " ").title() + ("" if ns == "minecraft" else f" ({ns})")
def tooltip(self, iid):
ns, path = split_id(iid)
lines = []
for suffix in (".tooltip", ".tooltip_1", ".tooltip_2", ".desc"):
for kind in ("item", "block"):
if f"{kind}.{ns}.{path}{suffix}" in self.lang:
lines.append(self.lang[f"{kind}.{ns}.{path}{suffix}"])
return lines
# --- registries ---------------------------------------------------------------------------
def _registry_vanilla(self):
rep = BUILD / ".cache/datagen-1.21.1/reports/registries.json"
if rep.exists():
reg = json.loads(rep.read_text())
self.vanilla_items = set(reg["minecraft:item"]["entries"])
self.vanilla_blocks = set(reg["minecraft:block"]["entries"])
self.vanilla_entities = set(reg["minecraft:entity_type"]["entries"])
else:
self.log(f"note: {rep} missing; vanilla registries guessed from the language file")
self.vanilla_items = {"minecraft:" + k.split(".", 2)[2] for k in self.lang
if k.startswith(("item.minecraft.", "block.minecraft.")) and k.count(".") == 2}
self.vanilla_blocks = {"minecraft:" + k.split(".", 2)[2] for k in self.lang
if k.startswith("block.minecraft.") and k.count(".") == 2}
self.vanilla_entities = set()
self.blocks = set(self.vanilla_blocks) | {k for k in self.stack.ids("blockstates", root="assets")
if not k.startswith("minecraft:")}
def _registry_mods(self):
"""Mod items: item models that have a name in the language file or are used as an item in data."""
models = {k for k in self.stack.ids("models/item", root="assets") if not k.startswith("minecraft:")}
named = set()
for k in self.lang:
parts = k.split(".")
if len(parts) == 3 and parts[0] in ("item", "block") and parts[1] != "minecraft":
named.add(f"{parts[1]}:{parts[2]}")
mods = {m for m in models if m in named or m in self.referenced}
# Items a KubeJS startup script registers have no model in any jar (KubeJS makes it at runtime);
# the server's item registry from the KubeJS export has them.
mods |= {i for i in (self.kubejs or {}).get("items", []) if not i.startswith("minecraft:")}
self.mod_items = sorted(mods)
self.items = self.vanilla_items | set(self.mod_items)
missing = {r for r in self.referenced if r not in self.items and not r.startswith("#")
and split_id(r)[0] in self.stack.modids}
for r in sorted(missing):
self.unresolved["item ids used in data but not registered (mod not installed?)"].add(r)
# --- species --------------------------------------------------------------------------------
def _species(self):
self.species = {}
for sid, rel in self.stack.ids("species").items():
d = self.stack.json(rel) or {}
ns, path = split_id(sid)
key = f"{ns}:{path.split('/')[-1]}"
forms = d.get("forms", [])
self.species[key] = {
"id": key, "name": self.lang.get(f"{ns}.species.{key.split(':')[1]}.name", d.get("name", key)),
"dex": d.get("nationalPokedexNumber", 0), "types": [t for t in (d.get("primaryType"), d.get("secondaryType")) if t],
"implemented": d.get("implemented", False) is True, "labels": d.get("labels", []),
"pre": norm_id("cobblemon:" + d["preEvolution"].split()[0]) if d.get("preEvolution") else None,
"evolutions": [dict(e, _form=None) for e in d.get("evolutions", [])] +
[dict(e, _form=f.get("name")) for f in forms for e in (f.get("evolutions") or [])],
"drops": d.get("drops"), "pokedex": d.get("pokedex", []),
"forms": [{"name": f.get("name"), "aspects": f.get("aspects", []), "pokedex": f.get("pokedex", []),
"battle_only": f.get("battleOnly", False)} for f in forms],
}
for e in (d.get("drops") or {}).get("entries", []):
self.referenced.add(norm_id(e["item"]))
def species_name(self, sid):
return self.species.get(sid, {}).get("name") or sid.split(":")[-1].title()
# --- recipes --------------------------------------------------------------------------------
def _conditions_ok(self, d):
conds = d.get("neoforge:conditions")
return all(self._cond(c) for c in conds) if conds else True
def _cond(self, c):
t = c.get("type", "")
if t == "neoforge:mod_loaded":
return c.get("modid") in self.stack.modids
if t == "neoforge:not":
return not self._cond(c.get("value", {}))
if t == "neoforge:and":
return all(self._cond(v) for v in c.get("values", []))
if t == "neoforge:or":
return any(self._cond(v) for v in c.get("values", []))
if t == "neoforge:false":
return False
if t == "neoforge:tag_empty":
return not self.itags.resolve("#" + c.get("tag", ""))
if t == "neoforge:item_exists":
return True
return True
def ingredient(self, ing):
"""Ingredient JSON -> {"items": [...], "tag": label or None}."""
if ing is None:
return None
if isinstance(ing, list):
items, tags = [], []
for i in ing:
r = self.ingredient(i)
if r:
items += r["items"]
tags.append(r["tag"])
return {"items": sorted(set(items)), "tag": " / ".join(t for t in tags if t) or None}
if isinstance(ing, str):
ing = {"tag": ing[1:]} if ing.startswith("#") else {"item": ing}
t = ing.get("type", "")
if "item" in ing:
iid = norm_id(ing["item"])
self.referenced.add(iid)
return {"items": [iid], "tag": None}
if "tag" in ing:
items = sorted(self.itags.resolve("#" + ing["tag"]))
if not items:
self.unresolved["empty item tags in recipes"].add("#" + ing["tag"])
return {"items": items, "tag": "#" + norm_id(ing["tag"])}
if t in ("neoforge:compound",):
return self.ingredient(ing.get("children", []))
if t == "neoforge:difference":
base, sub = self.ingredient(ing.get("base")), self.ingredient(ing.get("subtracted"))
return {"items": sorted(set(base["items"]) - set(sub["items"] if sub else [])), "tag": base["tag"]}
if t == "neoforge:intersection":
parts = [self.ingredient(c) for c in ing.get("children", [])]
items = set(parts[0]["items"]) if parts else set()
for p in parts[1:]:
items &= set(p["items"])
return {"items": sorted(items), "tag": None}
if "items" in ing:
return self.ingredient(ing["items"])
self.unresolved["unknown ingredient types"].add(t or json.dumps(ing)[:60])
return {"items": [], "tag": None}
def _recipes(self):
for rid, rel in sorted(self.stack.ids("recipe").items()):
d = self.stack.json(rel)
if d and self._conditions_ok(d):
self._recipe(rid, d)
def _recipe(self, rid, d, script=None):
"""One recipe JSON -> self.recipes[rid] and a source for its result. script: the KubeJS script line
that added or changed it."""
rtype = norm_id(d.get("type", ""))
rtype = KUBEJS_TYPES.get(rtype, rtype)
res = d.get("result")
if isinstance(res, str):
res = {"id": res}
if not isinstance(res, dict) or not (res.get("id") or res.get("item")):
return # special recipes (dyeing, map cloning, trims) have no fixed result
out = norm_id(res.get("id") or res.get("item"))
count = res.get("count", 1)
grid, slots, extra = None, [], {}
if rtype.endswith(("crafting_shaped", "cooking_pot")):
keys = {k: self.ingredient(v) for k, v in d.get("key", {}).items()}
pattern = d.get("pattern", [])
grid = [[keys.get(ch) if ch != " " else None for ch in row] for row in pattern]
for row in grid:
slots += [s for s in row if s]
elif rtype.endswith(("crafting_shapeless", "cooking_pot_shapeless")):
slots = [self.ingredient(i) for i in d.get("ingredients", [])]
elif rtype.endswith(("smelting", "blasting", "smoking", "campfire_cooking", "stonecutting")):
slots = [self.ingredient(d.get("ingredient"))]
extra = {"time": d.get("cookingtime")}
elif rtype.endswith("smithing_transform"):
slots = [self.ingredient(d.get(k)) for k in ("template", "base", "addition")]
elif rtype == "cobblemon:brewing_stand":
slots = [self.ingredient(d.get("input")), self.ingredient(d.get("bottle"))]
else:
for k in ("ingredient", "ingredients", "input", "base", "addition", "template", "bottle"):
if k in d:
v = self.ingredient(d[k])
slots += [v] if v else []
if "key" in d:
slots += [self.ingredient(v) for v in d["key"].values()]
self.unresolved["recipe types read generically"].add(rtype)
slots = [s for s in slots if s]
self.referenced.add(out)
label = RECIPE_LABEL.get(rtype.split(":")[1], rtype) + (" (pack recipe)" if script else "")
recipe = {"id": rid, "type": rtype, "label": label, "result": out, "count": count, "grid": grid, "slots": slots,
**extra, **({"script": script} if script else {})}
self.recipes[rid] = recipe
needs = [s["items"] and [f"item:{i}" for i in s["items"]] or [] for s in slots]
station = STATIONS.get(rtype)
if station:
needs.append([f"item:{i}" for i in self.itags.resolve(station)])
self.add(f"item:{out}", "recipe", f"{recipe['label']}", needs=needs, recipe=rid)
def _remove_recipe(self, rid):
r = self.recipes.pop(rid, None)
if r:
key = f"item:{r['result']}"
self.sources[key] = [s for s in self.sources[key] if s.get("recipe") != rid]
return r
def _kubejs(self, export):
"""What the pack's KubeJS scripts did to the recipes, read from the test server (kubejs_export.py)."""
if not export:
self.unresolved["KubeJS recipes not read (no export; see docs/field-guide.md)"].add("pack/kubejs/server_scripts")
return
for rid in export["removed"]:
if not self._remove_recipe(rid):
self.unresolved["recipes KubeJS removed that the data does not have"].add(rid)
for rid, d in sorted(export["recipes"].items()):
self._remove_recipe(rid) # changed in place (replaceInput, ...): the export has the final JSON
marker = d.get("_kubejs_changed_marker", {})
self._recipe(rid, d, script=f"{marker.get('source')}:{marker.get('line')}")
self.pack_recipes.add(rid)
if export["stale"]:
self.unresolved["KubeJS recipe export older than the pack (rerun tools/fieldguide/build.py)"].add(
f"exported {export['exported']}")
# --- loot -------------------------------------------------------------------------------
def _loot_places(self):
"""loot table -> [(place text, status, needs)] for chests, archaeology and features."""
places = defaultdict(list)
for sid, s in self.island.structures.items():
if not RANK[s["status"]] and not s["loot"]:
s["loot"] = self.island.walk(s["data"], start=sid)["loot"] | {
f"minecraft:{p}" for p in CODE_LOOT.get(s["type"], [])}
label = self.structure_name(sid) + ("" if RANK[s["status"]] else " (not on the island)")
for t in s["loot"]:
places[t].append((label, s["status"], sid))
for t, f in self.island.feature_loot.items():
st = self.island.biome_status(f["biomes"])
if RANK[st]:
via = sorted(f["via"])[0]
places[t].append((via.split(":")[-1].split("/")[-1].replace("_", " ").title() + " (worldgen feature)",
st, via))
for tid in list(self.loot.ids):
ns, path = split_id(tid)
if ns == "cobblemon" and path.startswith("injection/"):
target = path[len("injection/"):]
targets = ([f"minecraft:chests/village/village_{v}_house" for v in
("desert", "plains", "savanna", "snowy", "taiga")]
if target == "chests/village_house" else [f"minecraft:{target}"])
for tg in targets:
for p in places.get(tg, []):
places[tid].append((p[0] + " (Cobblemon extra loot)", p[1], p[2]))
return places
def _loot_sources(self):
places = self._loot_places()
alpha_spawns = None
for tid in sorted(self.loot.ids):
ns, path = split_id(tid)
if ns not in self.stack.modids:
continue # data for a mod that is not installed (Cobblemon ships some for Adorn, Botany Pots)
data = self.loot.table(tid) or {}
drops = self.loot.flatten(tid)
if not drops:
continue
for d in drops:
self.referenced.add(d["item"])
ltype = data.get("type", "")
common = {"table": tid}
if path.startswith("blocks/"):
block = f"{ns}:{path[7:]}"
for d in drops:
self.add(f"item:{d['item']}", "block", f"Break {self.item_name(block)}",
needs=[[f"block:{block}"]], block=block, **_chance(d), **common)
elif path.startswith("entities/"):
mob = f"{ns}:{path[9:].split('/')[0]}"
for d in drops:
self.add(f"item:{d['item']}", "mob", f"Drop from {self.entity_name(mob)}",
needs=[[f"mob:{mob}"]], mob=mob, **_chance(d), **common)
elif tid in places:
ps = places[tid]
best = max((p[1] for p in ps), key=RANK.get)
needs = [["item:minecraft:brush"]] if ltype == "minecraft:archaeology" else []
kind = "archaeology" if ltype == "minecraft:archaeology" else "chest"
where = sorted({p[0] for p in ps if p[1] == best})
for d in drops:
self.add(f"item:{d['item']}", kind,
("Brush suspicious blocks in " if kind == "archaeology" else "Loot in ") + _join(where),
base=best, needs=needs, places=where, **_chance(d), **common)
elif path.startswith(("gameplay/fishing", "fishing/pokerod", "injection/gameplay/fishing")):
rod = (["item:" + i for i in self.itags.resolve("#cobblemon:poke_rods")] if path.startswith("fishing/")
else ["item:minecraft:fishing_rod"])
if path.startswith("gameplay/fishing/") and path != "gameplay/fishing":
continue # reached through gameplay/fishing
for d in drops:
self.add(f"item:{d['item']}", "fishing", "Fishing" + (" with a Poké Rod" if path.startswith("fishing/") else ""),
needs=[rod], **_chance(d), **common)
elif path.startswith("alpha/"):
if alpha_spawns is None:
alpha_spawns = self._alpha_spawns()
typ = path.split("/")[2].split("_")[0] if path.startswith("alpha/types/") else None
keys = [f"spawn:{i}" for i, t in alpha_spawns if typ is None or typ in t]
for d in drops:
self.add(f"item:{d['item']}", "alpha", "Defeat a wild Alpha Pokémon" + (f" ({typ} type)" if typ else ""),
needs=[keys], **_chance(d), **common)
elif ns == "minecraft" and path.startswith(("gameplay/", "shearing/")):
mob = {"cat_morning_gift": "cat", "hero_of_the_village": "villager", "piglin_bartering": "piglin",
"sniffer_digging": "sniffer", "panda_sneeze": "panda", "armadillo_shed": "armadillo",
"bogged": "bogged"}
key = next((v for k, v in mob.items() if k in path), None)
if key is None:
self.unresolved["loot tables with no known place"].add(tid)
continue
for d in drops:
self.add(f"item:{d['item']}", "gameplay", path.split("/")[-1].replace("_", " ").capitalize(),
needs=[[f"mob:minecraft:{key}"]], **_chance(d), **common)
elif not self._is_nested(tid):
self.unresolved["loot tables with no known place"].add(tid)
def _is_nested(self, tid):
"""Is this table only used from inside other tables (e.g. cobblemon:sets/...)?"""
if not hasattr(self, "_nested"):
self._nested = {norm_id(s) for other in self.loot.ids for s in _strings(self.loot.table(other) or {})
if ":" in s and norm_id(s) in self.loot.ids and norm_id(s) != other}
return tid in self._nested
def _alpha_spawns(self):
out = []
for i, e in enumerate(self.spawns.entries):
for m in e["members"]:
if any(p == "alpha=true" for p in m["props"]):
out.append((i, self.species.get(m["species"], {}).get("types", [])))
return out
def _drops(self):
"""Pokémon drops: species JSON 'drops', overridden per spawn by a spawn's own 'drops'."""
by_species = defaultdict(list)
override = defaultdict(list)
for i, e in enumerate(self.spawns.entries):
for m in e["members"]:
(override if e.get("drops") else by_species)[m["species"]].append(i)
if e.get("drops"):
for d in _drop_entries(e["drops"]):
self.add(f"item:{d['item']}", "pokemon", f"Defeat wild {self.species_name(m['species'])} (spawn {e['id']})",
needs=[[f"spawn:{i}"]], species=m["species"], chance=d["chance"])
self.referenced.add(d["item"])
for sid, sp in self.species.items():
for d in _drop_entries(sp.get("drops")):
keys = [f"spawn:{i}" for i in by_species.get(sid, [])]
if keys:
self.add(f"item:{d['item']}", "pokemon", f"Defeat wild {sp['name']}",
needs=[keys], species=sid, chance=d["chance"])
else:
self.add(f"item:{d['item']}", "pokemon", f"Defeat wild {sp['name']} (it has no wild spawn)",
base="unavailable", species=sid, chance=d["chance"])
def _berries(self):
groves = self.btags.resolve("#minecraft:is_overworld") # berry groves are added to these biomes
for bid, rel in self.stack.ids("berries").items():
d = self.stack.json(rel) or {}
biomes = set()
for sc in d.get("spawnConditions", []):
v = sc.get("variant", "")
if v.endswith("all_biome"):
biomes |= groves
elif v.endswith("preferred_biome"):
biomes |= self.tags["worldgen/biome"].resolve_all(["#" + t for t in d.get("preferredBiomeTags", [])]) & groves
elif v.endswith("specific_biome"):
biomes |= self.tags["worldgen/biome"].resolve_all(["#" + sc["biome"]]) & groves
here = self.island.island_biomes(biomes)
if biomes:
self.add(f"item:{bid}", "berry", "Wild berry trees in " + _join([self.biome_name(b) for b in here] or ["(no island biome)"]),
base=self.island.biome_status(here), biomes=here)
for partner, result in d.get("mutations", {}).items():
if norm_id(bid) < norm_id(partner): # each pair is listed in both berries
self.add(f"item:{norm_id(result)}", "mutation",
f"Berry mutation: plant {self.item_name(bid)} next to {self.item_name(partner)}",
needs=[[f"item:{bid}"], [f"item:{norm_id(partner)}"]], parents=[bid, norm_id(partner)])
def _fossils(self):
parts = [[f"item:{i}"] for i in sorted(self.itags.resolve("#cobblemon:fossil_machine_parts"))]
for fid, rel in self.stack.ids("fossils").items():
d = self.stack.json(rel) or {}
sp, props = parse_props(d.get("result", ""))
items = [norm_id(f) for f in d.get("fossils", [])]
self.add(f"species:{sp}", "fossil", "Revive from " + " + ".join(self.item_name(i) for i in items),
needs=[[f"item:{i}"] for i in items] + parts, fossils=items)
for i in items:
self.uses[i].append({"kind": "fossil", "text": f"Revives {self.species_name(sp)} (Fossil Analyzer + Restoration Tank)",
"species": sp})
def _interactions(self):
for pid, rel in self.stack.ids("pokemon_interactions").items():
d = self.stack.json(rel) or {}
targets = [parse_props(r.get("target", ""))[0] for r in d.get("requirements", []) if r.get("variant") == "properties"]
for inter in d.get("interactions", []):
held = [r.get("itemCondition") for r in inter.get("requirements", []) if r.get("variant") == "owner_held_item"]
tool = sorted(self.itags.resolve_all(held)) if held else []
for eff in inter.get("effects", []):
if eff.get("variant") not in ("drop_item", "give_item") or not eff.get("item"):
continue
item = norm_id(eff["item"])
self.referenced.add(item)
for sp in targets:
how = f"Use {_join([h.lstrip('#') for h in held]) or 'an item'} on your {self.species_name(sp)}"
self.add(f"item:{item}", "interaction", how,
needs=[[f"species:{sp}"]] + ([[f"item:{t}" for t in tool]] if tool else []), species=sp)
def _manual(self):
from pathlib import Path
cfg = json.loads((Path(__file__).parent / "manual_sources.json").read_text())
for s in cfg["sources"]:
self.add(f"item:{s['item']}", "manual", s["how"], needs=s.get("needs", []))
axes = [f"item:{i}" for i in self.itags.resolve("#minecraft:axes")]
for item in sorted(self.vanilla_items | {k for k in self.stack.ids("models/item", root="assets")}):
ns, path = split_id(item)
if path.startswith("stripped_"):
self.add(f"item:{item}", "manual", "Strip with an axe", needs=[[f"item:{ns}:{path[9:]}"], axes])
if cfg.get("concrete_from_powder"):
for b in sorted(self.vanilla_items):
if b.endswith("_concrete_powder"):
self.add(f"item:{b[:-7]}", "manual", "Place concrete powder in water", needs=[[f"item:{b}"]])
def _extra(self, path):
"""Extra sources from a JSON file (ore veins, trades, ...); KubeJS recipes come from _kubejs."""
d = json.loads(open(path).read())
for s in d.get("sources", []):
self.add(f"item:{norm_id(s['item'])}", s.get("kind", "extra"), s["how"], base=s.get("status", "available"),
needs=s.get("needs", []))
for rid in d.get("remove_recipes", []):
self._remove_recipe(rid)
# --- world nodes: blocks, mobs ---------------------------------------------------------------
def _world_nodes(self):
for blk in self.blocks | set(self.island.natural):
st, biomes = self.island.natural_status(blk)
n = self.island.natural.get(blk)
if n:
where = [self.biome_name(b) for b in biomes] or []
structs = [self.structure_name(v[10:]) for v in n["via"] if v.startswith("structure:")]
self.add(f"block:{blk}", "natural", "Generates naturally", base=st, biomes=biomes, structures=sorted(set(structs)),
where=where)
if blk in self.items:
self.add(f"block:{blk}", "placed", "Placed by a player", needs=[[f"item:{blk}"]])
for mob, entries in self.island.mobs.items():
for e in entries:
where = sorted(e["where"])
text = {"biome": "Spawns in " + _join([self.biome_name(b) for b in where]),
"structure": "Spawns in " + _join([self.structure_name(s) for s in where]),
"special": _join(where).capitalize()}[e["kind"]]
self.add(f"mob:{mob}", e["kind"], text, base=e["status"])
for mob, (item, note) in self.island.item_mobs.items():
self.add(f"mob:{mob}", "item", note.capitalize(), needs=[[f"item:{item}"]])
def _spawn_nodes(self):
self.spawn_by_species = defaultdict(list)
self._spawn_sources("spawn", self.spawns.entries)
self._spawn_sources("hidden_spawn", self.hidden_spawns.entries) # no species source: they never spawn
for i, e in enumerate(self.spawns.entries):
for m in e["members"]:
self.spawn_by_species[m["species"]].append(i)
self.add(f"species:{m['species']}", "wild", f"Wild spawn {e['id']}", needs=[[f"spawn:{i}"]], spawn=i)
for sid, sp in self.species.items():
if "starter" in sp["labels"]:
self.add(f"species:{sid}", "starter", "Starter Pokémon (chosen once per player)")
def _spawn_sources(self, prefix, entries):
rods = [f"item:{i}" for i in self.itags.resolve("#cobblemon:poke_rods")]
for i, e in enumerate(entries):
for c in e["conditions"]:
base = self.island.biome_status(c["biomes"])
if c["structures"]["refs"]:
base = STATUS[min(RANK[base], RANK[c["structures"]["status"] or "unavailable"])]
needs = []
if c["near"]:
needs.append([f"block:{b}" for grp in c["near"] for b in grp])
if c["base"]:
needs.append([f"block:{b}" for grp in c["base"] for b in grp])
if c["poke_snack"]:
needs.append(["item:cobblemon:poke_snack"])
if e["position"] == "fishing":
needs.append(rods)
self.add(f"{prefix}:{i}", "spawn", " · ".join(c["text"]) or "anywhere", base=base, needs=needs)
def _evolutions(self):
self.evo_from = defaultdict(list)
for sid, sp in self.species.items():
for e in sp["evolutions"]:
target, props = parse_props(e.get("result", ""))
if not target:
continue
how, needs, base = [], [[f"species:{sid}"]], "available"
variant = e.get("variant")
ctx = e.get("requiredContext")
if variant == "item_interact" and ctx:
items = sorted(self.itags.resolve(ctx)) if str(ctx).startswith("#") else [norm_id(ctx)]
how.append("use " + _join([self.item_name(i) for i in items]))
needs.append([f"item:{i}" for i in items])
for i in items:
self.referenced.add(i)
self.uses[i].append({"kind": "evolution", "text": f"Evolves {sp['name']} into {self.species_name(target)}",
"species": target, "from": sid})
elif variant == "trade":
how.append("trade" + (f" for {self.species_name(norm_id('cobblemon:' + ctx))}" if ctx else ""))
for r in e.get("requirements", []):
text, rneeds, rbase = self._requirement(r, sp, target)
if text:
how.append(text)
needs += rneeds
base = STATUS[min(RANK[base], RANK[rbase])]
desc = f"Evolve {sp['name']}" + (f" ({e['_form']} form)" if e.get("_form") else "") + (": " + ", ".join(how) if how else "")
self.add(f"species:{target}", "evolution", desc, base=base, needs=needs, species=sid,
props=props, how=how)
self.evo_from[sid].append({"to": target, "how": how, "props": props, "form": e.get("_form")})
def _requirement(self, r, sp, target):
"""Evolution requirement -> (text, needs, base status)."""
v = r.get("variant", "")
if v == "level":
return f"level {r.get('minLevel', '?')}" + (f"–{r['maxLevel']}" if r.get("maxLevel") else ""), [], "available"
if v == "held_item":
cond = r.get("itemCondition", "")
items = sorted(self.itags.resolve(cond)) if str(cond).startswith("#") else [norm_id(cond)]
for i in items:
self.referenced.add(i)
self.uses[i].append({"kind": "evolution", "text": f"Held by {sp['name']} to evolve into {self.species_name(target)}",
"species": target, "from": sp["id"]})
return "holding " + _join([self.item_name(i) for i in items]), [[f"item:{i}" for i in items]], "available"
if v == "friendship":
return f"friendship {r.get('amount')}", [], "available"
if v == "time_range":
return r.get("range", ""), [], "available"
if v == "biome":
biomes = self.tags["worldgen/biome"].resolve(r.get("biomeCondition", "")) if r.get("biomeCondition") else set()
here = self.island.island_biomes(biomes)
text = "in " + (_join([self.biome_name(b) for b in here]) if here else r.get("biomeCondition", "") + " (not on the island)")
return text, [], self.island.biome_status(here) if here else "unavailable"
if v == "structure":
st, which = self.island.structure_status([r.get("structureCondition", "")])
return f"near {r.get('structureCondition', '').lstrip('#')}", [], st
if v == "negate":
inner, _, _ = self._requirement(r.get("inner", {}), sp, target)
return f"not ({inner})", [], "available"
if v == "weather":
return "raining" if r.get("isRaining") else "thunderstorm" if r.get("isThundering") else "clear weather", [], "available"
if v == "moon_phase":
return f"moon {str(r.get('moonPhase', '')).lower().replace('_', ' ')}", [], "available"
if v == "has_move":
return f"knows {r.get('move')}", [], "available"
if v == "has_move_type":
return f"knows a {r.get('type')} move", [], "available"
if v == "properties":
return r.get("target", ""), [], "available"
if v == "party_member":
return f"party has {r.get('target')}", [], "available"
if v == "advancement":
return f"advancement {r.get('requiredAdvancement')}", [], "available"
if v in ("blocks_traveled", "damage_taken", "recoil", "defeat", "use_move", "battle_critical_hits"):
return f"{v.replace('_', ' ')} {r.get('amount', '')} {r.get('move', '') or r.get('target', '')}".strip(), [], "available"
if v in ("stat_compare", "stat_equal"):
return " ".join(str(x) for x in r.values() if x != v), [], "available"
if v == "property_range":
return f"{r.get('feature')} {r.get('range')}", [], "available"
self.unresolved["evolution requirement variants shown raw"].add(v)
return v, [], "available"
# --- uses -----------------------------------------------------------------------------------
def _uses(self):
for rid, r in self.recipes.items():
seen = set()
for s in r["slots"]:
for i in s["items"]:
if i not in seen:
seen.add(i)
self.uses[i].append({"kind": "recipe", "recipe": rid, "text": f"{r['label']}: {self.item_name(r['result'])}",
"result": r["result"]})
held = self.itags.resolve("#cobblemon:held/is_held_item")
for i in held:
self.uses[i].append({"kind": "held", "text": "Held item" + (": " + " ".join(self.tooltip(i)) if self.tooltip(i) else "")})
for eid, rel in self.stack.ids("spawn_bait_effects").items():
d = self.stack.json(rel) or {}
item = norm_id(d.get("item", eid))
effects = [e.get("type", "").split(":")[-1].replace("_", " ") for e in d.get("effects", [])]
self.uses[item].append({"kind": "bait", "text": "Bait / Poké Snack effect: " + _join(sorted(set(effects)))})
for i, e in enumerate(self.spawns.entries):
for c in e["conditions"]:
refs = [r for r in c["near_refs"] + c["base_refs"] if not r.startswith("#")]
for ref in refs:
for m in e["members"]:
self.uses[norm_id(ref)].append({"kind": "spawn", "text": f"Nearby block that attracts {self.species_name(m['species'])}",
"species": m["species"]})
# --- solving ----------------------------------------------------------------------------------
def add(self, key, kind, text, base="available", needs=(), **data):
self.sources[key].append({"kind": kind, "text": text, "base": base, "needs": [list(g) for g in needs], **data})
def _solve(self):
status = defaultdict(int)
changed, passes = True, 0
while changed:
changed, passes = False, passes + 1
for key, srcs in self.sources.items():
best = status[key]
if best == 2:
continue
for src in srcs:
v = RANK[src["base"]]
for group in src["needs"]:
v = min(v, max((status[k] for k in group), default=0))
if v <= best:
break
best = max(best, v)
if best != status[key]:
status[key] = best
changed = True
self.status = status
self.passes = passes
for key, srcs in self.sources.items():
for src in srcs:
v, blocked = RANK[src["base"]], []
for group in src["needs"]:
g = max((status[k] for k in group), default=0)
if g < 2:
blocked.append(group)
v = min(v, g)
src["status"] = STATUS[v]
blocked = [g for g in blocked if max((status[k] for k in g), default=0) < RANK[src["base"]]]
blocked.sort(key=lambda g: max((status[k] for k in g), default=0)) # worst first
src["blocked_by"] = [g[:6] for g in blocked][:3]
def get_status(self, key):
return STATUS[self.status.get(key, 0)]
@property
def btags(self):
return self.tags["worldgen/biome"]
def _chance(d):
p = d["p"]
text = "always" if p >= 0.995 else f"≈{p * 100:.3g}%"
lo, hi = d["count"]
if (lo, hi) != (1, 1):
text += f", ×{int(lo)}" + (f"–{int(hi)}" if hi != lo else "")
if d["notes"]:
text += " (" + ", ".join(d["notes"]) + ")"
return {"chance": text}
def _drop_entries(drops):
out = []
for e in (drops or {}).get("entries", []):
if "percentage" in e:
text = f"{e['percentage']:g}%"
elif "quantityRange" in e:
text = f"×{e['quantityRange']}"
else:
text = "×" + str(e.get("quantity", 1))
out.append({"item": norm_id(e["item"]), "chance": text})
return out
def _join(items, limit=6):
items = list(items)
if len(items) > limit:
return ", ".join(items[:limit]) + f" and {len(items) - limit} more"
return ", ".join(items)
def _strings(obj):
if isinstance(obj, str):
yield obj
elif isinstance(obj, dict):
for v in obj.values():
yield from _strings(v)
elif isinstance(obj, list):
for v in obj:
yield from _strings(v)
+65
View File
@@ -0,0 +1,65 @@
"""Minimal reader for gzipped NBT structure templates (data/*/structure/*.nbt).
Only what the field guide needs: the palette, block-entity strings (loot
tables, jigsaw pools) and entity ids. Returns plain Python values.
"""
import gzip
import io
import struct
def _read(f, tag):
if tag == 1:
return struct.unpack(">b", f.read(1))[0]
if tag == 2:
return struct.unpack(">h", f.read(2))[0]
if tag == 3:
return struct.unpack(">i", f.read(4))[0]
if tag == 4:
return struct.unpack(">q", f.read(8))[0]
if tag == 5:
return struct.unpack(">f", f.read(4))[0]
if tag == 6:
return struct.unpack(">d", f.read(8))[0]
if tag == 7:
return f.read(struct.unpack(">i", f.read(4))[0])
if tag == 8:
return f.read(struct.unpack(">H", f.read(2))[0]).decode("utf-8", "replace")
if tag == 9:
inner, n = struct.unpack(">bi", f.read(5))
return [_read(f, inner) for _ in range(n)]
if tag == 10:
out = {}
while True:
t = f.read(1)[0]
if t == 0:
return out
name = f.read(struct.unpack(">H", f.read(2))[0]).decode("utf-8", "replace")
out[name] = _read(f, t)
if tag == 11:
n = struct.unpack(">i", f.read(4))[0]
return list(struct.unpack(f">{n}i", f.read(4 * n)))
if tag == 12:
n = struct.unpack(">i", f.read(4))[0]
return list(struct.unpack(f">{n}q", f.read(8 * n)))
raise ValueError(f"bad NBT tag {tag}")
def load(raw):
data = gzip.decompress(raw) if raw[:2] == b"\x1f\x8b" else raw
f = io.BytesIO(data)
tag = f.read(1)[0]
f.read(struct.unpack(">H", f.read(2))[0])
return _read(f, tag)
def strings(value):
"""Every string inside a decoded NBT value."""
if isinstance(value, str):
yield value
elif isinstance(value, dict):
for v in value.values():
yield from strings(v)
elif isinstance(value, list):
for v in value:
yield from strings(v)
+375
View File
@@ -0,0 +1,375 @@
"""HTML field guide from fieldguide.json: index.html, pokemon.html, items.html.
Pages share assets/guide.css, assets/guide.js and data.js (the JSON, slimmed,
as a script so the guide also works when opened from disk). No network access
and no external libraries; light and dark mode follow the system setting and
can be switched by hand.
"""
import html
import json
TYPE_COLORS = {
"normal": "#9fa19f", "fire": "#e62829", "water": "#2980ef", "electric": "#fac000", "grass": "#3fa129",
"ice": "#3dcef3", "fighting": "#ff8000", "poison": "#9141cb", "ground": "#915121", "flying": "#81b9ef",
"psychic": "#ef4179", "bug": "#91a119", "rock": "#afa981", "ghost": "#704170", "dragon": "#5060e1",
"dark": "#624d4e", "steel": "#60a1b8", "fairy": "#ef70ef",
}
CSS = r"""
:root{--bg:#f6f5f1;--panel:#fff;--panel2:#f0eee8;--text:#1d1d1b;--muted:#6b6a64;--line:#dcd9d0;--accent:#c0392b;
--ok:#2e7d32;--ok-bg:#e3f1e4;--warn:#9a6700;--warn-bg:#fdf1d6;--bad:#b3261e;--bad-bg:#fbe3e1;--link:#1f5fa8;--slot:#c6c6c6;--slot-line:#8b8b8b}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--bg:#17181a;--panel:#212226;--panel2:#2a2b30;--text:#e9e7e1;
--muted:#a3a19a;--line:#3a3b41;--accent:#ff6b5b;--ok:#7bd88f;--ok-bg:#1e3323;--warn:#f2c14e;--warn-bg:#3a3018;--bad:#ff8a80;
--bad-bg:#3d1f1d;--link:#8ab4f8;--slot:#3b3c42;--slot-line:#5a5b62}}
:root[data-theme="dark"]{--bg:#17181a;--panel:#212226;--panel2:#2a2b30;--text:#e9e7e1;--muted:#a3a19a;--line:#3a3b41;--accent:#ff6b5b;
--ok:#7bd88f;--ok-bg:#1e3323;--warn:#f2c14e;--warn-bg:#3a3018;--bad:#ff8a80;--bad-bg:#3d1f1d;--link:#8ab4f8;--slot:#3b3c42;--slot-line:#5a5b62}
*{box-sizing:border-box}html{-webkit-text-size-adjust:100%}
body{margin:0;background:var(--bg);color:var(--text);font:15px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
a{color:var(--link);text-decoration:none}a:hover{text-decoration:underline}
header.top{position:sticky;top:0;z-index:5;display:flex;gap:16px;align-items:center;padding:10px 16px;background:var(--panel);border-bottom:1px solid var(--line)}
header.top .brand{font-weight:700;letter-spacing:.2px;color:var(--text)}header.top nav{display:flex;gap:4px;flex-wrap:wrap}
header.top nav a{padding:4px 10px;border-radius:6px;color:var(--muted)}header.top nav a.on{background:var(--panel2);color:var(--text)}
header.top .sp{flex:1}button.theme{background:none;border:1px solid var(--line);color:var(--muted);border-radius:6px;padding:3px 8px;cursor:pointer}
main{max-width:1280px;margin:0 auto;padding:16px}
.layout{display:grid;grid-template-columns:240px minmax(0,1fr);gap:16px}
@media (max-width:820px){.layout{grid-template-columns:minmax(0,1fr)}aside.side{max-height:none;position:static;order:2}
header.top{flex-wrap:wrap;gap:6px 12px}header.top .brand{flex:1 1 100%}header.top .sp{display:none}main{padding:12px}}
aside.side{position:sticky;top:62px;align-self:start;max-height:calc(100vh - 80px);overflow:auto;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:10px}
aside.side h4{margin:12px 6px 4px;font-size:12px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted)}
aside.side a{display:flex;justify-content:space-between;gap:8px;padding:4px 8px;border-radius:6px;color:var(--text)}
aside.side a.on,aside.side a:hover{background:var(--panel2);text-decoration:none}aside.side a small{color:var(--muted)}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px 16px;margin-bottom:14px}
h1{font-size:24px;margin:4px 0 8px}h2{font-size:18px;margin:4px 0 10px}h3{font-size:15px;margin:14px 0 6px}
.muted{color:var(--muted)}.small{font-size:13px}
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px}
.stat{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px}.stat b{display:block;font-size:26px}
.grid-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px}
.loc{display:block;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:10px 12px;color:var(--text)}
.loc:hover{border-color:var(--accent);text-decoration:none}.loc .k{font-size:12px;color:var(--muted)}
.pill{display:inline-block;padding:1px 8px;border-radius:999px;font-size:12px;border:1px solid var(--line);background:var(--panel2);white-space:nowrap}
.st-available{color:var(--ok);background:var(--ok-bg);border-color:transparent}.st-conditional{color:var(--warn);background:var(--warn-bg);border-color:transparent}
.st-unavailable{color:var(--bad);background:var(--bad-bg);border-color:transparent}
.b-common{}.b-uncommon{color:#2e7d9a}.b-rare{color:#7b4bb7}.b-ultra-rare{color:#c2185b}.b-boss{color:var(--accent)}
.tbl{width:100%;border-collapse:collapse}.tbl th,.tbl td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);vertical-align:top}
.tbl th{font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);background:var(--panel)}
.tbl td.nw{white-space:nowrap}
.tblwrap{overflow-x:auto}
.mon{display:flex;align-items:center;gap:8px;min-width:150px}.dot{width:22px;height:22px;border-radius:50%;flex:none;display:grid;place-items:center;font-size:9px;font-weight:700;color:#fff;text-shadow:0 0 2px #0008}
.types span{display:inline-block;font-size:11px;padding:0 6px;border-radius:4px;color:#fff;margin-right:3px;text-shadow:0 0 2px #0006}
.chips span{display:inline-block;font-size:12px;padding:1px 6px;margin:1px 2px 1px 0;border-radius:4px;background:var(--panel2);border:1px solid var(--line)}
.chips .or{background:none;border:none;color:var(--muted)}
.toolbar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:10px}
.toolbar input,.toolbar select{background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:6px;padding:6px 8px;font:inherit}
.toolbar input{min-width:0;flex:1 1 200px}.toolbar input[type=checkbox]{flex:none}.toolbar label{font-size:13px;color:var(--muted);display:flex;gap:4px;align-items:center}
.items{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:6px}
.item{display:flex;gap:8px;align-items:center;padding:6px 8px;background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--text)}
.item:hover{border-color:var(--accent);text-decoration:none}.item .n{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ico{width:32px;height:32px;image-rendering:pixelated;flex:none}.ico.big{width:64px;height:64px}
.noico{width:32px;height:32px;border-radius:6px;background:var(--panel2);border:1px dashed var(--line);flex:none;display:grid;place-items:center;font-size:10px;color:var(--muted)}
.sdot{width:9px;height:9px;border-radius:50%;flex:none}.sdot.available{background:var(--ok)}.sdot.conditional{background:var(--warn)}.sdot.unavailable{background:var(--bad)}
.recipes{display:flex;flex-wrap:wrap;gap:10px}
.recipe{background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:8px 10px}
.recipe .lbl{font-size:12px;color:var(--muted);margin-bottom:6px;display:flex;gap:6px;align-items:center}
.craft{display:flex;align-items:center;gap:10px}
.cgrid{display:grid;gap:2px}.slot{width:38px;height:38px;background:var(--slot);border:1px solid var(--slot-line);display:grid;place-items:center;position:relative}
.slot img{width:32px;height:32px;image-rendering:pixelated}.slot .cnt{position:absolute;right:1px;bottom:-1px;font-size:11px;font-weight:700;color:#fff;text-shadow:1px 1px 0 #000}
.slot .alt{position:absolute;left:1px;top:-2px;font-size:10px;color:var(--muted)}
.arrow{font-size:22px;color:var(--muted)}
.src{display:flex;gap:10px;align-items:flex-start;padding:7px 0;border-bottom:1px solid var(--line)}.src:last-child{border-bottom:none}
.src .kind{font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--muted);width:92px;flex:none;padding-top:2px}
.src .body{flex:1;min-width:0}.blocked{color:var(--bad);font-size:13px}
.banner{padding:10px 12px;border-radius:8px;margin:10px 0}.banner.unavailable{background:var(--bad-bg);color:var(--bad)}
.banner.conditional{background:var(--warn-bg);color:var(--warn)}.banner.available{background:var(--ok-bg);color:var(--ok)}
.head{display:flex;gap:14px;align-items:center}.head .id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted)}
details summary{cursor:pointer;color:var(--muted)}code{font-family:ui-monospace,monospace;font-size:12px}
.foot{color:var(--muted);font-size:12px;margin:24px 0 8px}
"""
JS = r"""
const FG = window.FG;
const $ = (s, el = document) => el.querySelector(s);
const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[c]));
const TYPE = %TYPES%;
const BUCKET_ORDER = {'common': 0, 'uncommon': 1, 'rare': 2, 'ultra-rare': 3, 'boss': 4};
const POS = {grounded: 'land', submerged: 'underwater', surface: 'water surface', seafloor: 'sea floor', fishing: 'fishing', lava_surface: 'lava'};
const STATUS_TEXT = {available: 'on the island', conditional: 'conditional', unavailable: 'no source here'};
function store(k, v) { try { if (v === undefined) return localStorage.getItem(k); localStorage.setItem(k, v); } catch (e) { return null; } }
function initTheme() {
const t = store('fg-theme'); if (t) document.documentElement.dataset.theme = t;
const b = $('button.theme'); if (!b) return;
b.onclick = () => { const cur = document.documentElement.dataset.theme ||
(matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
const n = cur === 'dark' ? 'light' : 'dark'; document.documentElement.dataset.theme = n; store('fg-theme', n); };
}
function hashArgs() { const o = {}; location.hash.slice(1).split('&').forEach(p => { const [k, v] = p.split('='); if (k) o[k] = decodeURIComponent(v || ''); }); return o; }
function pill(status, text) { return `<span class="pill st-${status}">${esc(text || STATUS_TEXT[status] || status)}</span>`; }
function itemInfo(id) { return FG.items[id] || FG.vanilla[id] || {name: id.split(':')[1].replace(/_/g, ' '), status: 'available'}; }
function icon(id, cls = 'ico') { const it = itemInfo(id); return it.icon ? `<img class="${cls}" src="${esc(it.icon)}" alt="">` : `<span class="noico">?</span>`; }
function itemLink(id) { const it = itemInfo(id); return `<a href="items.html#item=${encodeURIComponent(id)}" title="${esc(id)}">${esc(it.name)}</a>`; }
function monDot(sid) {
const sp = FG.species[sid] || {types: []}; const c = sp.types.map(t => TYPE[t] || '#888');
const bg = c.length > 1 ? `linear-gradient(135deg,${c[0]} 50%,${c[1]} 50%)` : (c[0] || '#888');
return `<span class="dot" style="background:${bg}">${sp.dex || ''}</span>`;
}
function monLink(sid, extra) { const sp = FG.species[sid]; return `<a href="pokemon.html#species=${encodeURIComponent(sid)}">${esc(sp ? sp.name : sid)}</a>${extra ? ' <span class="muted small">' + esc(extra) + '</span>' : ''}`; }
function types(sid) { const sp = FG.species[sid]; return sp ? `<span class="types">${sp.types.map(t => `<span style="background:${TYPE[t]}">${t}</span>`).join('')}</span>` : ''; }
function chips(list) { return `<span class="chips">${list.map(t => `<span>${esc(t)}</span>`).join('')}</span>`; }
function bucket(b) { const pct = FG.meta.buckets[b]; return `<span class="b-${b}">${esc(b === 'boss' ? 'alpha' : b)}</span>${pct !== undefined ? `<span class="muted small"> (${pct}%)</span>` : ''}`; }
// ---------- sources and uses ----------
function slot(ing, count) {
if (!ing) return '<div class="slot"></div>';
const id = ing.items[0]; const alts = ing.items.length;
const title = (ing.tag ? ing.tag + ': ' : '') + ing.items.slice(0, 12).map(i => itemInfo(i).name).join(', ') + (alts > 12 ? ' …' : '');
return `<a class="slot" href="items.html#item=${encodeURIComponent(id || '')}" title="${esc(title)}">${id ? icon(id, '') : ''}${alts > 1 ? '<span class="alt">+</span>' : ''}${count > 1 ? `<span class="cnt">${count}</span>` : ''}</a>`;
}
function recipeHtml(rid) {
const r = FG.recipes[rid]; if (!r) return `<code>${esc(rid)}</code>`;
let left;
if (r.grid) {
const w = Math.max(...r.grid.map(row => row.length));
left = `<div class="cgrid" style="grid-template-columns:repeat(${w},38px)">${r.grid.map(row => { const cells = []; for (let i = 0; i < w; i++) cells.push(slot(row[i])); return cells.join(''); }).join('')}</div>`;
} else {
const n = r.slots.length; const w = n > 3 ? 3 : n;
left = `<div class="cgrid" style="grid-template-columns:repeat(${w},38px)">${r.slots.map(s => slot(s)).join('')}</div>`;
}
return `<div class="recipe"><div class="lbl">${pill(r.status, '')}<span>${esc(r.label)}</span></div><div class="craft">${left}<span class="arrow">→</span>${slot({items: [r.result]}, r.count)}</div><div class="muted small" title="${esc(rid)}">${esc(itemInfo(r.result).name)}</div></div>`;
}
function sourceHtml(s) {
let body = esc(s.text);
if (s.chance) body += ` <span class="muted small">${esc(s.chance)}</span>`;
if (s.species) body = body.replace(esc(FG.species[s.species]?.name || '\u0000'), monLink(s.species));
if (s.blocked && s.blocked.length && s.status !== 'available') body += `<div class="blocked">Needs ${s.blocked.map(esc).join('; ')}</div>`;
return `<div class="src"><div class="kind">${esc(s.kind)}</div><div class="body">${body}</div>${pill(s.status, '')}</div>`;
}
// ---------- pokemon page ----------
function pokemonPage() {
const side = $('#side'); const main = $('#main');
const kinds = [['surface', 'Surface'], ['cave', 'Caves'], ['water', 'Water & coast'], ['end', 'The End (conditional)']];
let s = `<div class="toolbar"><input id="q" placeholder="Find a Pokémon…" list="monlist"></div>`;
s += `<datalist id="monlist">${Object.entries(FG.species).map(([id, sp]) => `<option value="${esc(sp.name)}">`).join('')}</datalist>`;
for (const [k, label] of kinds) {
const locs = FG.locations.filter(l => l.kind === k); if (!locs.length) continue;
s += `<h4>${label}</h4>` + locs.map(l => `<a href="#loc=${encodeURIComponent(l.id)}" data-loc="${esc(l.id)}">${esc(l.name)}<small>${new Set(l.spawns.flatMap(i => FG.spawns[i].m.map(m => m[0]))).size}</small></a>`).join('');
}
const missing = Object.values(FG.species).filter(sp => sp.wild === 'unavailable').length;
s += `<h4>Elsewhere</h4><a href="#missing">Not on the island<small>${missing}</small></a>`;
side.innerHTML = s;
$('#q').addEventListener('change', e => { const v = e.target.value.toLowerCase(); const hit = Object.entries(FG.species).find(([id, sp]) => sp.name.toLowerCase() === v || id === v); if (hit) location.hash = 'species=' + encodeURIComponent(hit[0]); });
const route = () => {
const a = hashArgs();
side.querySelectorAll('a').forEach(x => x.classList.toggle('on', x.getAttribute('href') === location.hash));
if (a.species) main.innerHTML = speciesView(a.species);
else if ('missing' in a) main.innerHTML = missingView();
else main.innerHTML = locationView(a.loc || FG.locations[0].id, a);
window.scrollTo(0, 0);
main.querySelectorAll('[data-filter]').forEach(el => el.addEventListener('input', () => filterRows(main)));
filterRows(main);
};
addEventListener('hashchange', route); route();
}
function filterRows(main) {
const b = $('#fb', main), p = $('#fp', main), q = $('#fq', main), x = $('#fx', main); if (!b) return;
main.querySelectorAll('tbody tr').forEach(tr => {
const ok = (!b.value || tr.dataset.b === b.value) && (!p.value || tr.dataset.p === p.value) && (!q.value || tr.dataset.n.includes(q.value.toLowerCase())) && !(x.checked && tr.dataset.x === 'true');
tr.style.display = ok ? '' : 'none';
});
}
function locationView(id) {
const l = FG.locations.find(x => x.id === id); if (!l) return '<p>Unknown location.</p>';
const rows = l.spawns.map(i => FG.spawns[i]).sort((a, b) => (a.x - b.x) || (BUCKET_ORDER[a.b] - BUCKET_ORDER[b.b]) || (b.w - a.w));
const species = new Set(rows.flatMap(r => r.m.map(m => m[0])));
let h = `<div class="card"><h1>${esc(l.name)}</h1><div class="muted">${esc(l.kind === 'cave' ? 'Cave biome' : l.kind === 'water' ? 'Water / coast biome' : l.kind === 'end' ? 'The End' : 'Surface biome')} · ${species.size} Pokémon · ${rows.length} spawn entries · <code>${esc(l.id)}</code></div>`;
if (l.status !== 'available') h += `<div class="banner ${l.status}">${esc(l.reason || STATUS_TEXT[l.status])}</div>`;
h += `<p class="small muted">Rarity: Cobblemon first picks a bucket (chance in brackets), then a spawn inside it by weight. Herds and alphas spawn as groups.</p></div>`;
h += `<div class="card"><div class="toolbar"><input id="fq" data-filter placeholder="Filter by name"><label>Rarity <select id="fb" data-filter><option value="">all</option>${Object.keys(BUCKET_ORDER).map(b => `<option value="${b}">${b === 'boss' ? 'alpha' : b}</option>`).join('')}</select></label><label><input type="checkbox" id="fx" data-filter> hide spawns that need special blocks or structures</label><label>Where <select id="fp" data-filter><option value="">all</option>${Object.entries(POS).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select></label></div>`;
h += `<div class="tblwrap"><table class="tbl"><thead><tr><th>Pokémon</th><th>Level</th><th>Rarity · weight</th><th>Where</th><th>Conditions</th></tr></thead><tbody>`;
for (const r of rows) {
const mons = r.m.map(m => `<div class="mon">${monDot(m[0])}<div>${monLink(m[0], m[1])}${r.m.length > 1 ? ` <span class="muted small">${m[3]} · Lv ${m[2][0]}–${m[2][1]}</span>` : ''}</div></div>`).join('');
const conds = r.c.map(c => c.length ? chips(c) : '<span class="muted small">anywhere</span>').join('<span class="muted small"> or </span>');
const anti = r.a.length ? `<div class="small muted">not: ${r.a.map(a => esc(a.join(', '))).join('; ')}</div>` : '';
const mult = r.mul.length ? `<div class="small muted">${r.mul.map(x => `×${x.multiplier} when ${esc(x.when.join(', '))}`).join('; ')}</div>` : '';
h += `<tr data-x="${r.x}" data-b="${r.b}" data-p="${r.p}" data-n="${esc(r.m.map(m => (FG.species[m[0]]?.name || m[0]).toLowerCase()).join(' '))}"><td>${mons}${r.t === 'pokemon-herd' ? `<span class="pill">herd up to ${r.h}</span>` : ''}</td><td class="nw">${r.lv[0]}–${r.lv[1]}</td><td>${bucket(r.b)}<div class="muted small">weight ${r.w}</div></td><td>${esc(POS[r.p] || r.p)}</td><td>${conds}${anti}${mult}${r.st !== 'available' ? pill(r.st) : ''}</td></tr>`;
}
return h + '</tbody></table></div></div>';
}
function speciesView(sid) {
const sp = FG.species[sid]; if (!sp) return '<p>Unknown species.</p>';
let h = `<div class="card"><div class="head">${monDot(sid)}<div><h1>${esc(sp.name)} <span class="muted">#${sp.dex}</span></h1>${types(sid)} <span class="id">${esc(sid)}</span></div></div>`;
h += `<div class="banner ${sp.status}">${sp.wild !== 'unavailable' ? 'Found in the wild on the island.' : sp.status !== 'unavailable' ? 'Not found in the wild here, but obtainable another way (below).' : sp.implemented ? 'No way to get it on this island.' : 'Not implemented in Cobblemon (no model), so it cannot be obtained.'}</div></div>`;
if (sp.locations.length) {
h += `<div class="card"><h2>Where to find it</h2><table class="tbl"><thead><tr><th>Location</th><th>Level</th><th>Rarity</th></tr></thead><tbody>`;
h += sp.locations.map(l => `<tr><td><a href="#loc=${encodeURIComponent(l.biome)}">${esc(l.name)}</a>${l.zones && l.zones.length ? ` <span class="muted small">${esc(l.zones.join(', '))}</span>` : ''}${l.special ? ' <span class="muted small">only near particular blocks</span>' : ''}</td><td>${l.level[0]}–${l.level[1]}</td><td>${l.buckets.map(bucket).join(', ')}</td></tr>`).join('');
h += `</tbody></table><details><summary>All ${sp.spawns.length} spawn entries with conditions</summary>${sp.spawns.map(i => FG.spawns[i]).map(r => `<div class="src"><div class="kind">${esc(r.id)}</div><div class="body">${bucket(r.b)} · weight ${r.w} · Lv ${r.lv[0]}–${r.lv[1]} · ${esc(POS[r.p] || r.p)}<br>${r.c.map(c => chips(c)).join(' or ')}<div class="muted small">${r.loc.map(i => esc(FG.locations[i]?.name || '')).join(', ') || 'no island biome'}</div></div>${pill(r.st)}</div>`).join('')}</details></div>`;
} else if (sp.why_not_wild.length) {
h += `<div class="card"><h2>Why it is not wild here</h2><ul>${sp.why_not_wild.map(w => `<li>${esc(w)}</li>`).join('')}</ul></div>`;
} else if (!sp.spawns.length) {
h += `<div class="card"><h2>Wild spawns</h2><p class="muted">Cobblemon has no wild spawn for it.</p></div>`;
}
if (sp.sources.length) h += `<div class="card"><h2>Other ways to get it</h2>${sp.sources.map(sourceHtml).join('')}</div>`;
if (sp.evolutions.length) h += `<div class="card"><h2>Evolves into</h2>${sp.evolutions.map(e => `<div class="src"><div class="kind">evolution</div><div class="body">${monLink(e.to)}${e.form ? ` <span class="muted small">(${esc(e.form)} form)</span>` : ''}: ${esc(e.how.join(', ') || 'level up')}</div></div>`).join('')}</div>`;
if (sp.drops.length) h += `<div class="card"><h2>Drops (when defeated in the wild)</h2><div class="items">${sp.drops.map(d => `<a class="item" href="items.html#item=${encodeURIComponent(d.item)}">${icon(d.item)}<span class="n">${esc(itemInfo(d.item).name)}</span><span class="muted small">${esc(d.chance)}</span></a>`).join('')}</div></div>`;
return h;
}
function missingView() {
const list = Object.entries(FG.species).filter(([id, sp]) => sp.wild === 'unavailable').sort((a, b) => a[1].dex - b[1].dex);
let h = `<div class="card"><h1>Not found in the wild on the island</h1><p class="muted">${list.length} species. Green: obtainable another way (evolution, fossil, starter, trade). Red: no way on this island.</p></div><div class="card"><div class="tblwrap"><table class="tbl"><thead><tr><th>Pokémon</th><th>How to get it here</th><th>Wild spawns need</th></tr></thead><tbody>`;
for (const [id, sp] of list) {
const how = sp.sources.filter(s => s.status !== 'unavailable').slice(0, 3).map(s => esc(s.text)).join('<br>') || (sp.implemented ? '' : '<span class="muted">not implemented in Cobblemon</span>');
h += `<tr><td><div class="mon">${monDot(id)}${monLink(id)}</div></td><td>${how} ${pill(sp.status, '')}</td><td class="small muted">${esc(sp.why_not_wild.slice(0, 3).join('; ')) || (sp.spawns.length ? '' : 'no wild spawn in Cobblemon')}</td></tr>`;
}
return h + '</tbody></table></div></div>';
}
// ---------- items page ----------
function itemsPage() {
const main = $('#main');
const cats = [...new Set(Object.values(FG.items).map(i => i.category))].sort();
const mods = [...new Set(Object.values(FG.items).map(i => i.mod))].sort();
const route = () => {
const a = hashArgs();
if (a.item) { main.innerHTML = itemView(a.item); window.scrollTo(0, 0); return; }
main.innerHTML = `<div class="card"><h1>Items</h1><p class="muted">Every item the pack's mods add, with how to get it on the island. Red items have no source here.</p><div class="toolbar"><input id="iq" placeholder="Search items…" value="${esc(a.q || '')}"><select id="ic"><option value="">All categories</option>${cats.map(c => `<option${a.cat === c ? ' selected' : ''}>${esc(c)}</option>`).join('')}</select><select id="im"><option value="">All mods</option>${mods.map(c => `<option${a.mod === c ? ' selected' : ''}>${esc(c)}</option>`).join('')}</select><select id="is"><option value="">Any status</option><option value="available">On the island</option><option value="conditional">Conditional</option><option value="unavailable"${a.status === 'unavailable' ? ' selected' : ''}>No source here</option></select></div><div id="count" class="muted small"></div></div><div class="items" id="list"></div>`;
const draw = () => {
const q = $('#iq').value.toLowerCase(), c = $('#ic').value, m = $('#im').value, s = $('#is').value;
const list = Object.entries(FG.items).filter(([id, it]) => (!q || it.name.toLowerCase().includes(q) || id.includes(q)) && (!c || it.category === c) && (!m || it.mod === m) && (!s || (it.status === s && !(s === 'unavailable' && it.technical))))
.sort((x, y) => x[1].category.localeCompare(y[1].category) || x[1].name.localeCompare(y[1].name));
$('#count').textContent = `${list.length} items`;
$('#list').innerHTML = list.map(([id, it]) => `<a class="item" href="#item=${encodeURIComponent(id)}" title="${esc(id)}">${icon(id)}<span class="n">${esc(it.name)}</span><span class="sdot ${it.technical ? '' : it.status}" title="${esc(it.technical ? 'creative/admin item' : STATUS_TEXT[it.status])}"></span></a>`).join('');
};
['#iq', '#ic', '#im', '#is'].forEach(sel => $(sel).addEventListener('input', draw)); draw();
};
addEventListener('hashchange', route); route();
}
function itemView(id) {
const it = itemInfo(id); const isMod = !!FG.items[id];
let h = `<p><a href="items.html">← All items</a></p><div class="card"><div class="head">${icon(id, 'ico big')}<div><h1>${esc(it.name)}</h1><span class="id">${esc(id)}</span> ${it.category ? `<span class="pill">${esc(it.category)}</span>` : ''}</div></div>`;
if (it.tooltip && it.tooltip.length) h += `<p>${it.tooltip.map(esc).join('<br>')}</p>`;
const msg = it.technical ? 'Creative or admin item; not meant to be obtained in survival.' : it.status === 'available' ? 'Obtainable on the island.' : it.status === 'conditional' ? 'Only obtainable under a condition (e.g. reaching the End).' : 'No source on this island' + (it.reason ? ': ' + it.reason + '.' : '.');
h += `<div class="banner ${it.technical ? 'conditional' : it.status}">${msg}</div></div>`;
const srcs = it.sources || [];
const recs = srcs.filter(s => s.recipe), other = srcs.filter(s => !s.recipe);
h += `<div class="card"><h2>How to get it</h2>`;
if (!srcs.length) h += `<p class="muted">No recipe, loot table, drop or worldgen feature produces it.</p>`;
if (recs.length) h += `<div class="recipes">${recs.map(s => recipeHtml(s.recipe)).join('')}</div>`;
if (other.length) h += `<div style="margin-top:8px">${other.map(sourceHtml).join('')}</div>`;
h += `</div>`;
const uses = (FG.items[id] && FG.items[id].uses) || [];
if (isMod || uses.length) {
const ur = uses.filter(u => u.recipe), uo = uses.filter(u => !u.recipe);
h += `<div class="card"><h2>What it is used for</h2>`;
if (!uses.length) h += `<p class="muted">No recipe or Cobblemon mechanic uses it.</p>`;
if (uo.length) h += uo.map(u => `<div class="src"><div class="kind">${esc(u.kind)}</div><div class="body">${u.species ? esc(u.text).replace(esc(FG.species[u.species]?.name || '\u0000'), monLink(u.species)) : esc(u.text)}</div></div>`).join('');
if (ur.length) h += `<h3>Recipes that use it (${ur.length})</h3><div class="recipes">${ur.slice(0, 60).map(u => recipeHtml(u.recipe)).join('')}</div>${ur.length > 60 ? `<p class="muted">… and ${ur.length - 60} more.</p>` : ''}`;
h += `</div>`;
}
if (it.tags && it.tags.length) h += `<div class="card"><h3>Tags</h3>${chips(it.tags)}</div>`;
return h;
}
document.addEventListener('DOMContentLoaded', () => {
initTheme();
const page = document.body.dataset.page;
if (page === 'pokemon') pokemonPage();
if (page === 'items') itemsPage();
});
"""
def slim(data):
"""The JSON as the pages need it (spawns as short records, location indices)."""
loc_index = {loc["id"]: i for i, loc in enumerate(data["locations"])}
spawns = []
for s in data["spawns"]:
spawns.append({
"id": s["id"], "t": s["type"], "b": s["bucket"], "w": s["weight"], "lv": s["level"], "p": s["position"],
"h": s["max_herd"], "st": s["status"],
"m": [[m["species"], " ".join(p for p in m["props"] if not p.startswith(("level", "levelRange"))),
m["level"], m["role"]] for m in s["members"]],
"c": [c["text"] for c in s["conditions"] if c["biomes"]] or [c["text"] for c in s["conditions"]],
"a": s["anticonditions"], "mul": s["multipliers"],
"loc": [loc_index[b] for b in s["biomes"] if b in loc_index],
# needs particular blocks or a structure nearby: listed after the plain spawns
"x": s["needs_blocks"],
})
species = {k: {kk: v[kk] for kk in ("name", "dex", "types", "implemented", "status", "wild", "locations", "spawns",
"sources", "evolutions", "drops", "why_not_wild")}
for k, v in data["species"].items()}
return {"meta": data["meta"], "locations": data["locations"], "spawns": spawns, "species": species,
"items": data["items"], "vanilla": data["vanilla"], "recipes": data["recipes"]}
def page(title, active, body, page_id=""):
nav = "".join(f'<a href="{href}"{" class=on" if active == href else ""}>{label}</a>'
for href, label in (("index.html", "Overview"), ("pokemon.html", "Pokémon"), ("items.html", "Items")))
return f"""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{html.escape(title)}</title><link rel="stylesheet" href="assets/guide.css"></head>
<body data-page="{page_id}"><header class="top"><a class="brand" href="index.html">Cobblemon Survival · Field Guide</a><nav>{nav}</nav>
<span class="sp"></span><button class="theme" type="button" title="Switch light/dark">◐</button></header>
{body}
<script src="data.js"></script><script src="assets/guide.js"></script></body></html>
"""
def index_body(data):
e = html.escape
species = data["species"]
wild = sum(1 for s in species.values() if s["wild"] == "available")
other = sum(1 for s in species.values() if s["wild"] != "available" and s["status"] == "available")
cond = sum(1 for s in species.values() if s["status"] == "conditional")
none = sum(1 for s in species.values() if s["status"] == "unavailable")
items = data["items"]
nosrc = sorted((i for i, v in items.items() if v["status"] == "unavailable" and not v["technical"]),
key=lambda i: (items[i]["category"], items[i]["name"]))
used_nosrc = [i for i in nosrc if items[i]["uses"]]
cards = []
for loc in data["locations"]:
n = len({m["species"] for i in loc["spawns"] for m in data["spawns"][i]["members"]})
kind = {"surface": "Surface", "cave": "Cave", "water": "Water / coast", "end": "The End"}.get(loc["kind"], loc["kind"])
cards.append(f'<a class="loc" href="pokemon.html#loc={e(loc["id"])}"><b>{e(loc["name"])}</b><div class="k">{kind} · {n} Pokémon'
f'{" · conditional" if loc["status"] != "available" else ""}</div></a>')
def item_row(i):
it = items[i]
img = f'<img class="ico" src="{e(it["icon"])}" alt="">' if it.get("icon") else '<span class="noico">?</span>'
why = it.get("reason") or "no source"
return (f'<a class="item" href="items.html#item={e(i)}" title="{e(i)}">{img}<span class="n">{e(it["name"])}'
f'<br><span class="muted small">{e(why)}</span></span></a>')
problems = "".join(f"<details><summary>{e(k)} ({len(v)})</summary><p class='small'><code>{e(', '.join(map(str, v[:400])))}</code></p></details>"
for k, v in data["problems"].items() if v)
struct = "".join(f"<tr><td>{e(s['name'])}</td><td><span class='pill st-{s['status']}'>{s['status']}</span></td><td class='small muted'>{e(s['reason'])}</td></tr>"
for s in data["island"]["structures"] if s["status"] != "unavailable")
return f"""<main>
<div class="card"><h1>Field guide</h1><p>Where to find every Pokémon on the island, how to get every item the mods add, and what each item is for,
generated from the pack's own data (Cobblemon spawn pools, species, recipes and loot tables, with <code>datapacks/core</code> on top).
Generated {e(data['meta']['generated'])}.</p>
<div class="stats"><div class="stat"><b>{wild}</b>Pokémon in the wild here</div><div class="stat"><b>{other}</b>more by evolution, fossils, starters</div>
<div class="stat"><b>{cond}</b>only in the End</div>
<div class="stat"><b>{none}</b>Pokémon with no way to get them here</div><div class="stat"><b>{len(items)}</b>mod items</div>
<div class="stat"><b>{len(nosrc)}</b>items with no source here</div></div>
<p class="small muted">Statuses: <span class="pill st-available">on the island</span> <span class="pill st-conditional">conditional</span> needs something that may not exist (the End, a stronghold inside the border) <span class="pill st-unavailable">no source here</span> ore, Nether or missing biome only.</p></div>
<div class="card"><h2>Locations</h2><div class="grid-cards">{''.join(cards)}</div>
<p><a href="pokemon.html#missing">Pokémon not found in the wild on the island →</a></p></div>
<div class="card"><h2>Items with no source on the island ({len(nosrc)})</h2>
<p class="muted small">{len(used_nosrc)} of them are used by a recipe or a Cobblemon mechanic. Creative/admin items are left out.</p>
<div class="items">{''.join(item_row(i) for i in nosrc)}</div></div>
<div class="card"><h2>Structures that can generate</h2><div class="tblwrap"><table class="tbl"><tbody>{struct}</tbody></table></div></div>
<div class="card"><h2>What the generator could not resolve</h2>{problems}</div>
<p class="foot">Unofficial fan project, not affiliated with Nintendo, Game Freak, Creatures Inc., The Pokémon Company, the Cobblemon team, Mojang Studios or Microsoft.
Icons are taken from the installed mods at build time and are their authors' work.</p>
</main>"""
def write(data, out):
(out / "assets").mkdir(parents=True, exist_ok=True)
(out / "assets/guide.css").write_text(CSS)
(out / "assets/guide.js").write_text(JS.replace("%TYPES%", json.dumps(TYPE_COLORS)))
(out / "data.js").write_text("window.FG=" + json.dumps(slim(data), ensure_ascii=False, separators=(",", ":")) + ";\n")
(out / "index.html").write_text(page("Field Guide", "index.html", index_body(data)))
(out / "pokemon.html").write_text(page("Pokémon by location · Field Guide", "pokemon.html",
'<main><div class="layout"><aside class="side" id="side"></aside><section id="main"></section></div></main>',
"pokemon"))
(out / "items.html").write_text(page("Items · Field Guide", "items.html", '<main id="main"></main>', "items"))
+260
View File
@@ -0,0 +1,260 @@
"""Cobblemon world spawns (data/*/spawn_pool_world), resolved against the island.
Mirrors Cobblemon 1.8.4: a spawn's conditions are OR'd and its anticonditions
veto; presets (spawn_detail_presets) are merged into every condition with
MergeMode.INSERT (lists appended, single values kept if already set) and their
anticondition is appended (SpawnDetailPreset.apply). A biome named in any
anticondition is never considered (BiomePrecalculation).
"""
import json
from .datasource import ROOT, norm_id, split_id
MERGED_FIELDS_SKIP = {"isRaining", "isThundering", "isSlimeChunk", "markers", "isPokeSnack"} # not in copyFrom()
TIME_RANGES = {"day", "night", "morning", "noon", "afternoon", "evening", "midnight", "predawn", "dawn", "dusk",
"twilight", "any"}
MOON = ["full", "waning gibbous", "last quarter", "waning crescent", "new", "waxing crescent", "first quarter",
"waxing gibbous"]
def parse_range(text, default=(1, 100)):
if text is None:
return list(default)
if isinstance(text, (int, float)):
return [int(text), int(text)]
parts = str(text).split("-")
try:
return [int(parts[0]), int(parts[-1])] if parts[0] else list(default)
except ValueError:
return list(default)
def parse_props(text):
"""'venusaur held_item=cobblemon:grass_gem alpha=true' -> species id and extra properties."""
tokens = str(text).split()
species = norm_id(tokens[0] if ":" in tokens[0] else "cobblemon:" + tokens[0]) if tokens else ""
return species, tokens[1:]
def as_list(v):
return [] if v is None else v if isinstance(v, list) else [v]
class Spawns:
def __init__(self, stack, tags, island, pools=None):
"""pools: {pool id: (file, json)} to read instead of the stack's visible spawn_pool_world files."""
self.stack, self.tags, self.island = stack, tags, island
self.btags, self.blocktags = tags["worldgen/biome"], tags["block"]
self.zones = load_zones()
self.unknown_zones = set()
self.presets = {}
for pid, rel in sorted(stack.ids("spawn_detail_presets").items()):
self.presets[split_id(pid)[1]] = stack.json(rel) or {}
self.entries = []
self.skipped = []
modids = stack.modids
if pools is None:
pools = {fid: (rel, stack.json(rel)) for fid, rel in sorted(stack.ids("spawn_pool_world").items())}
for fid, (rel, d) in pools.items():
d = d or {}
if d.get("enabled") is False:
self.skipped.append((fid, "disabled"))
continue
if not self._mods_ok(d, modids):
self.skipped.append((fid, "needs other mods"))
continue
for s in d.get("spawns", []):
if s.get("type") not in ("pokemon", "pokemon-herd") or not self._mods_ok(s, modids):
continue
self.entries.append(self._entry(fid, rel, s))
@staticmethod
def _mods_ok(d, modids):
return (all(m in modids for m in d.get("neededInstalledMods", []))
and not any(m in modids for m in d.get("neededUninstalledMods", [])))
def _entry(self, fid, rel, s):
conds = [dict(c) for c in as_list(s.get("condition")) + as_list(s.get("conditions"))]
antis = [dict(c) for c in as_list(s.get("anticondition")) + as_list(s.get("anticonditions"))]
bucket, position, weight = s.get("bucket"), s.get("spawnablePositionType", s.get("context")), s.get("weight")
for name in s.get("presets", []):
p = self.presets.get(name.split(":")[-1])
if p is None:
continue
bucket = p.get("bucket", bucket)
position = p.get("spawnablePositionType", p.get("context", position))
if p.get("condition"):
if conds:
for c in conds:
for k, v in p["condition"].items():
if k in MERGED_FIELDS_SKIP:
continue
if isinstance(v, list):
c[k] = list(c.get(k, [])) + v
else:
c.setdefault(k, v)
else:
conds.append(dict(p["condition"]))
if p.get("anticondition"):
antis.append(dict(p["anticondition"]))
if s.get("type") == "pokemon":
species, extra = parse_props(s.get("pokemon", ""))
members = [{"species": species, "props": extra, "level": parse_range(s.get("level", s.get("levelRange"))),
"role": "single"}]
else:
members = []
herd = parse_range(s.get("levelRange", s.get("level")))
for m in s.get("herdablePokemon", []):
species, extra = parse_props(m.get("pokemon", ""))
lv = parse_range(m.get("levelRange"), herd)
if m.get("isLeader"): # leaders list 1-100; the herd's own range is what they spawn at
lv = [max(lv[0], herd[0]), min(lv[1], herd[1])]
members.append({"species": species, "props": extra, "level": lv,
"role": "leader" if m.get("isLeader") else "follower", "weight": m.get("weight"),
"max": m.get("maxTimes")})
excluded = set()
for a in antis:
excluded |= self.btags.resolve_all(a.get("biomes", []))
out_conds, biomes, zones = [], set(), set()
for c in conds or [{}]:
oc = self._condition(c)
oc["biomes"] = sorted(set(oc["biomes"]) - excluded)
out_conds.append(oc)
biomes |= set(oc["biomes"])
zones |= set(oc["zones"])
mults = as_list(s.get("weightMultiplier")) + as_list(s.get("weightMultipliers"))
return {
"id": s.get("id") or fid, "file": rel, "pool": fid, "type": s["type"], "bucket": bucket,
"weight": weight, "percentage": s.get("percentage"), "position": position,
"level": parse_range(s.get("level", s.get("levelRange"))) if s.get("type") == "pokemon-herd" else members[0]["level"],
"max_herd": s.get("maxHerdSize"), "members": members, "presets": s.get("presets", []),
"conditions": out_conds, "anticonditions": [self._condition(a, anti=True) for a in antis],
"biomes": sorted(biomes), "zones": sorted(zones), "multipliers": [{"multiplier": m.get("multiplier"),
"when": self._condition(m.get("condition", {}))["text"]}
for m in mults],
"drops": s.get("drops"),
}
def _condition(self, c, anti=False):
"""Condition JSON -> island biomes, structure/block requirements and readable tokens."""
isl = self.island
if c.get("biomes"):
allb = self.btags.resolve_all(c["biomes"])
here = isl.island_biomes(allb)
else:
allb, here = None, sorted(b for b in isl.biomes if isl.biomes[b]["kind"] != "nether")
text = []
# the companion mod's zone condition (mod/.../zone/Zones.kt): "zones" wins over "zone"
zones = as_list(c["zones"]) if c.get("zones") is not None else as_list(c.get("zone"))
if zones:
text.append(("zones " if len(zones) > 1 else "zone ") + ", ".join(self._zone_label(z) for z in zones))
if c.get("dimensions"):
text.append("dimension " + ", ".join(c["dimensions"]))
if c.get("structures"):
st, which = isl.structure_status(c["structures"])
text.append("in/near " + ", ".join(_short(x) for x in c["structures"]))
# the structure decides where this can happen: only biomes the structures generate in
if not anti:
where = {b for w in which for b in isl.structures[w]["biomes"]}
here = [b for b in here if b in where]
else:
st, which = None, []
tr = c.get("timeRange")
if tr:
text.append(tr if tr in TIME_RANGES else f"time {tr}")
if c.get("moonPhase") is not None:
phases = []
for part in str(c["moonPhase"]).split(","):
lo, _, hi = part.partition("-")
if lo.strip().isdigit():
phases += [MOON[i % 8] for i in range(int(lo), int(hi or lo) + 1)]
else:
phases.append(part.strip())
text.append("moon: " + ", ".join(phases))
if c.get("canSeeSky") is True:
text.append("open sky")
elif c.get("canSeeSky") is False:
text.append("no sky (under cover)")
if c.get("isRaining") is True:
text.append("raining")
elif c.get("isRaining") is False:
text.append("not raining")
if c.get("isThundering"):
text.append("thunderstorm")
lo, hi = c.get("minSkyLight"), c.get("maxSkyLight")
if (lo, hi) not in ((None, None), (0, 15)) and not (lo in (None, 0) and hi in (None, 15)):
text.append(f"sky light {lo if lo is not None else 0}–{hi if hi is not None else 15}")
lo, hi = c.get("minLight"), c.get("maxLight")
if lo is not None or hi is not None:
text.append(f"light {lo if lo is not None else 0}–{hi if hi is not None else 15}")
lo, hi = c.get("minY"), c.get("maxY")
if lo is not None and hi is not None:
text.append(f"y {int(lo)}…{int(hi)}")
elif lo is not None:
text.append(f"y ≥ {int(lo)}")
elif hi is not None:
text.append(f"y ≤ {int(hi)}")
for axis in ("X", "Z"):
lo, hi = c.get("min" + axis), c.get("max" + axis)
if lo is not None or hi is not None:
text.append(f"{axis.lower()} {'' if lo is None else int(lo)}…{'' if hi is None else int(hi)}")
if c.get("minHeight") is not None or c.get("maxHeight") is not None:
text.append(f"space {c.get('minHeight', '')}–{c.get('maxHeight', '')} high")
near = c.get("neededNearbyBlocks") or []
base = c.get("neededBaseBlocks") or []
if base and base != ["#cobblemon:natural"]:
text.append("standing on " + ", ".join(_short(b) for b in base))
if near:
text.append("near " + ", ".join(_short(b) for b in near))
if c.get("fluid"):
text.append("in " + _short(c["fluid"]))
if c.get("isSlimeChunk"):
text.append("slime chunk")
if c.get("isPokeSnack") is True:
text.append("lured by a Poké Snack")
elif c.get("isPokeSnack") is False and not anti:
pass
if c.get("minLureLevel"):
text.append(f"Lure {c['minLureLevel']}+" + (f" (max {c['maxLureLevel']})" if c.get("maxLureLevel") else ""))
if c.get("bait"):
text.append("bait " + _short(c["bait"]))
if c.get("rodType"):
text.append("rod " + _short(c["rodType"]))
if c.get("markers"):
text.append("markers " + ", ".join(c["markers"]))
if anti and c.get("biomes"):
text.insert(0, "biomes " + ", ".join(_short(b) for b in c["biomes"]))
return {
"biomes": here, "all_ids": sorted(allb) if allb else [],
"structures": {"refs": c.get("structures", []), "status": st, "present": which},
"near": [sorted(self.blocktags.resolve(b)) for b in near],
"near_refs": near, "base": [sorted(self.blocktags.resolve(b)) for b in base], "base_refs": base,
"poke_snack": c.get("isPokeSnack") is True, "zones": zones, "text": text,
}
def _zone_label(self, zid):
z = self.zones.get(zid)
if not z:
self.unknown_zones.add(zid)
return zid
return f"{z['name']} (Lv {z['levels'][0]}–{z['levels'][1]})"
def load_zones():
"""{zone id: {name, levels}} from world/zones.json (surface zones and caves)."""
path = ROOT / "world/zones.json"
if not path.exists():
return {}
plan = json.loads(path.read_text())
return {z["id"]: {"name": z.get("name", z["id"]), "levels": z.get("levels", [None, None])}
for z in plan.get("zones", []) + plan.get("caves", [])}
def _short(ref):
"""'#cobblemon:natural' -> '#natural', 'minecraft:oak_leaves' -> 'oak leaves'."""
tag = ref.startswith("#")
ns, path = split_id(ref)
name = path.replace("_", " ").replace("/", ": ")
if tag:
return "#" + (name if ns in ("minecraft", "cobblemon", "c") else f"{ns}:{name}")
return name
+58
View File
@@ -0,0 +1,58 @@
"""Tag resolution over the data stack (biome, item, block, structure, ... tags).
Tags merge over all layers unless a layer sets "replace": true; entries can be
ids, #tags or {"id": ..., "required": false} objects. Unknown tags resolve to
an empty set and are recorded, so a report can list them.
"""
from .datasource import norm_id, split_id
class Tags:
def __init__(self, stack, registry):
self.stack, self.registry = stack, registry
self.cache = {}
self.missing = set()
def raw(self, tag):
"""The merged entries of one tag, without expanding nested tags."""
ns, path = split_id(tag)
entries = []
for data in self.stack.all_json(f"data/{ns}/tags/{self.registry}/{path}.json"):
if data.get("replace"):
entries = []
for v in data.get("values", []):
entries.append(v["id"] if isinstance(v, dict) else v)
return entries
def exists(self, tag):
ns, path = split_id(tag)
return self.stack.exists(f"data/{ns}/tags/{self.registry}/{path}.json")
def resolve(self, ref, _seen=None):
"""'#ns:tag' -> set of ids; a plain id -> {id}."""
if not ref.startswith("#"):
return {norm_id(ref)}
tag = norm_id(ref)
if tag in self.cache:
return self.cache[tag]
seen = _seen or set()
if tag in seen:
return set()
seen.add(tag)
if not self.exists(tag):
self.missing.add(tag)
out = set()
for entry in self.raw(tag):
out |= self.resolve(entry, seen) if entry.startswith("#") else {norm_id(entry)}
self.cache[tag] = out
return out
def resolve_all(self, refs):
out = set()
for r in ([refs] if isinstance(refs, str) else refs or []):
out |= self.resolve(r)
return out
def all_tags(self):
prefix = f"tags/{self.registry}"
return sorted("#" + k for k in self.stack.ids(prefix))
+580
View File
@@ -0,0 +1,580 @@
"""What can exist on the island: dimensions, biomes, structures, natural blocks, mobs.
Everything is derived from data: world/biomes.json and the world preset for
biomes and dimensions, structure and structure-set JSON for structures, biome
feature lists and NeoForge biome modifiers for natural blocks, biome spawners
and structure spawn overrides for mobs. Minecraft and Cobblemon decide a few
things in code; those are the small tables below, each saying where it is from.
Statuses: "available" (on the island), "conditional" (needs something that may
not exist, e.g. the End or a stronghold inside the border), "unavailable".
"""
import json
import re
from . import nbt
from .datasource import ROOT, norm_id
RANK = {"unavailable": 0, "conditional": 1, "available": 2}
END_BIOMES = ["minecraft:the_end", "minecraft:end_highlands", "minecraft:end_midlands",
"minecraft:small_end_islands", "minecraft:end_barrens"]
NETHER_BIOMES = ["minecraft:nether_wastes", "minecraft:soul_sand_valley", "minecraft:crimson_forest",
"minecraft:warped_forest", "minecraft:basalt_deltas"]
# Cobblemon 1.8.4 adds these placed features from code (CobblemonPlacedFeatures.register()),
# each to the biomes of one tag. None = all biomes; the feature filters itself.
COBBLEMON_CODED_FEATURES = {
"apricorn_trees": ["#cobblemon:has_feature/apricorns_dense", "#cobblemon:has_feature/apricorns_normal",
"#cobblemon:has_feature/apricorns_sparse"], # ApricornTreeFeature checks these
"saccharine_tree": ["#cobblemon:has_feature/saccharine_tree"],
"mints": None, "medicinal_leek": None,
"big_root": ["#minecraft:is_overworld"], "revival_herb": ["#cobblemon:has_feature/revival_herbs"],
"berry_groves": ["#minecraft:is_overworld"], "type_gems": ["#minecraft:is_overworld"],
"swamp_grains": ["#cobblemon:is_swamp"], "plains_grains": ["#cobblemon:is_plains"],
"galarica_nuts": ["#cobblemon:is_beach"], "habitats/treasure_hoard": ["#minecraft:is_overworld"],
"habitats/abandoned_fortress": ["#minecraft:is_nether"], "habitats/ancient_wellsprings": ["#cobblemon:is_lush"],
"habitats/chorus_briars": ["#cobblemon:is_end"], "habitats/deep_roots": ["#cobblemon:is_lush"],
"habitats/earthen_hives": ["#cobblemon:is_dripstone"], "habitats/exposed_geodes": ["#minecraft:is_overworld"],
"habitats/lost_ruins": ["#minecraft:is_overworld"], "habitats/quartz_spikes": ["#cobblemon:nether/is_wasteland"],
"habitats/rocky_tidepools": ["#cobblemon:is_stony_beach"], "habitats/root_nurseries": ["#cobblemon:is_swamp"],
"habitats/sandy_tidepools": ["#cobblemon:is_beach"], "habitats/skeletal_fall": ["#cobblemon:nether/is_soul_sand"],
"habitats/strange_crimson_fungal_dwellings": ["#cobblemon:nether/is_wasteland"],
"habitats/strange_warped_fungal_dwellings": ["#cobblemon:nether/is_wasteland"],
"habitats/volcanic_plumes": ["#minecraft:is_nether"], "habitats/rocky_outcrops": ["#cobblemon:is_mountain"],
"fossils/prehistoric_birch_tree": ["#cobblemon:has_block/birch_log"],
"fossils/prehistoric_dripstone_oasis": ["#cobblemon:is_dripstone"],
"fossils/prehistoric_enhydro_agate": ["#cobblemon:is_lush"],
"fossils/prehistoric_eroded_pillar": ["#cobblemon:has_block/red_sand"],
"fossils/prehistoric_frozen_pond": ["#cobblemon:is_glacial"],
"fossils/prehistoric_frozen_spike": ["#cobblemon:is_glacial"],
"fossils/prehistoric_hydrothermal_vents": ["#cobblemon:is_temperate_ocean"],
"fossils/prehistoric_lush_den": ["#cobblemon:is_jungle"], "fossils/prehistoric_mossy_pond": ["#cobblemon:is_lush"],
"fossils/prehistoric_mud_pit": ["#cobblemon:is_jungle"], "fossils/prehistoric_oak_tree": ["#cobblemon:has_block/oak_log"],
"fossils/prehistoric_powdered_deposit": ["#cobblemon:is_snowy"],
"fossils/prehistoric_rooted_pits": ["#cobblemon:is_swamp"], "fossils/prehistoric_sandy_den": ["#cobblemon:has_block/sand"],
"fossils/prehistoric_spruce_tree": ["#cobblemon:has_block/spruce_log"],
"fossils/prehistoric_submerged_impact": ["#cobblemon:is_temperate_ocean"],
"fossils/prehistoric_submerged_spike": ["#cobblemon:is_frozen_ocean"],
"fossils/prehistoric_preserved_skeleton": ["#cobblemon:is_frozen_ocean"],
"fossils/prehistoric_sunscorched_den": ["#cobblemon:has_block/red_sand"],
"fossils/prehistoric_sunscorched_remains": ["#cobblemon:has_block/red_sand"],
"fossils/prehistoric_suspicious_mounds": ["#cobblemon:is_plains"],
"fossils/prehistoric_underwater_fissure": ["#cobblemon:is_temperate_ocean"],
"fossils/prehistoric_vibrant_hydrothermal_vents": ["#cobblemon:is_warm_ocean"],
"ruins/crumbling_arch": ["#minecraft:is_overworld"], "ruins/deep_crypt": ["#cobblemon:is_deep_dark"],
"ruins/rooted_arch": ["#cobblemon:is_lush"],
}
# Blocks placed by feature (or tree decorator) code that do not appear in the feature's JSON,
# keyed by feature type. Lists may hold #block tags; a string is a regex over block ids.
CODE_FEATURE_BLOCKS = {
"cobblemon:apricorn_tree": ["cobblemon:apricorn_log", "cobblemon:apricorn_leaves"],
"cobblemon:saccharine_tree": ["cobblemon:saccharine_log", "cobblemon:saccharine_leaves"],
"cobblemon:type_gem": r"cobblemon:[a-z]+_gem_cluster", # TypeGemFeature picks a random gem cluster
"minecraft:glowstone_blob": ["minecraft:glowstone"],
"minecraft:basalt_columns": ["minecraft:basalt"], "minecraft:basalt_pillar": ["minecraft:basalt"],
"minecraft:ice_spike": ["minecraft:packed_ice"], "minecraft:iceberg": ["minecraft:packed_ice", "minecraft:blue_ice"],
"minecraft:blue_ice": ["minecraft:blue_ice"],
"minecraft:end_spike": ["minecraft:obsidian", "minecraft:iron_bars"], "minecraft:end_island": ["minecraft:end_stone"],
"minecraft:chorus_plant": ["minecraft:chorus_plant", "minecraft:chorus_flower"],
"minecraft:kelp": ["minecraft:kelp", "minecraft:kelp_plant"],
"minecraft:seagrass": ["minecraft:seagrass", "minecraft:tall_seagrass"], "minecraft:sea_pickle": ["minecraft:sea_pickle"],
"minecraft:coral_tree": ["#minecraft:coral_blocks", "#minecraft:corals", "#minecraft:wall_corals", "minecraft:sea_pickle"],
"minecraft:coral_claw": ["#minecraft:coral_blocks", "#minecraft:corals", "#minecraft:wall_corals", "minecraft:sea_pickle"],
"minecraft:coral_mushroom": ["#minecraft:coral_blocks", "#minecraft:corals", "#minecraft:wall_corals", "minecraft:sea_pickle"],
"minecraft:vines": ["minecraft:vine"], "minecraft:bamboo": ["minecraft:bamboo", "minecraft:podzol"],
"minecraft:monster_room": ["minecraft:cobblestone", "minecraft:mossy_cobblestone", "minecraft:spawner"],
"minecraft:desert_well": ["minecraft:sandstone", "minecraft:sandstone_slab", "minecraft:suspicious_sand"],
"minecraft:dripstone_cluster": ["minecraft:dripstone_block", "minecraft:pointed_dripstone"],
"minecraft:pointed_dripstone": ["minecraft:pointed_dripstone"], "minecraft:large_dripstone": ["minecraft:dripstone_block"],
"minecraft:sculk_patch": ["minecraft:sculk", "minecraft:sculk_vein", "minecraft:sculk_sensor",
"minecraft:sculk_shrieker", "minecraft:sculk_catalyst"],
"minecraft:underwater_magma": ["minecraft:magma_block"], "minecraft:freeze_top_layer": ["minecraft:ice", "minecraft:snow"],
"minecraft:beehive": ["minecraft:bee_nest"], "minecraft:cocoa": ["minecraft:cocoa"],
"minecraft:leave_vine": ["minecraft:vine"], "minecraft:trunk_vine": ["minecraft:vine"],
}
# Loot tables that Minecraft's structure and feature code references directly (not via templates).
CODE_LOOT = {
"minecraft:desert_pyramid": ["chests/desert_pyramid", "archaeology/desert_pyramid"],
"minecraft:jungle_temple": ["chests/jungle_temple", "chests/jungle_temple_dispenser"],
"minecraft:igloo": ["chests/igloo_chest"],
"minecraft:shipwreck": ["chests/shipwreck_map", "chests/shipwreck_supply", "chests/shipwreck_treasure"],
"minecraft:ocean_ruin": ["chests/underwater_ruin_big", "chests/underwater_ruin_small"],
"minecraft:buried_treasure": ["chests/buried_treasure"],
"minecraft:stronghold": ["chests/stronghold_corridor", "chests/stronghold_crossing", "chests/stronghold_library"],
"minecraft:mineshaft": ["chests/abandoned_mineshaft"],
"minecraft:fortress": ["chests/nether_bridge"],
"minecraft:woodland_mansion": ["chests/woodland_mansion"],
"minecraft:end_city": ["chests/end_city_treasure"],
"minecraft:ruined_portal": ["chests/ruined_portal"],
# placed features
"minecraft:monster_room": ["chests/simple_dungeon"], "minecraft:monster_room_deep": ["chests/simple_dungeon"],
"minecraft:desert_well": ["archaeology/desert_well"],
}
# Notable blocks that code-built structures place (only those that matter as a resource).
CODE_STRUCTURE_BLOCKS = {
"minecraft:ocean_monument": ["minecraft:gold_block", "minecraft:wet_sponge", "minecraft:sea_lantern",
"minecraft:prismarine", "minecraft:prismarine_bricks", "minecraft:dark_prismarine"],
"minecraft:mineshaft": ["minecraft:rail", "minecraft:cobweb", "minecraft:oak_planks", "minecraft:oak_fence"],
"minecraft:desert_pyramid": ["minecraft:tnt", "minecraft:suspicious_sand"],
"minecraft:stronghold": ["minecraft:end_portal_frame", "minecraft:bookshelf", "minecraft:iron_bars"],
"minecraft:jungle_temple": ["minecraft:dispenser", "minecraft:tripwire_hook", "minecraft:redstone_wire"],
"minecraft:ocean_ruin": ["minecraft:suspicious_sand", "minecraft:suspicious_gravel"],
"minecraft:ruined_portal": ["minecraft:obsidian", "minecraft:crying_obsidian", "minecraft:netherrack",
"minecraft:magma_block", "minecraft:gold_block"],
}
# Mobs that Minecraft spawns from code rather than biome spawners (kind, requirement, note).
SPECIAL_MOBS = {
"minecraft:villager": ("structure", "#minecraft:village", "lives in villages"),
"minecraft:iron_golem": ("structure", "#minecraft:village", "spawns in villages"),
"minecraft:cat": ("structure", "#minecraft:village", "strays in villages"),
"minecraft:wandering_trader": ("overworld", None, "visits players"),
"minecraft:trader_llama": ("overworld", None, "comes with the wandering trader"),
"minecraft:phantom": ("overworld", None, "attacks players who have not slept for three nights"),
"minecraft:pillager": ("overworld", None, "patrols"),
"minecraft:vindicator": ("structure", "#minecraft:village", "raids"),
"minecraft:evoker": ("structure", "#minecraft:village", "raids"),
"minecraft:ravager": ("structure", "#minecraft:village", "raids"),
"minecraft:witch": ("structure", "#minecraft:village", "raids"),
"minecraft:skeleton_horse": ("overworld", None, "skeleton traps in thunderstorms"),
"minecraft:zombie_villager": ("overworld", None, "zombie variant"),
"minecraft:warden": ("biome", "minecraft:deep_dark", "summoned by sculk shriekers"),
"minecraft:elder_guardian": ("structure", "minecraft:monument", "ocean monuments"),
"minecraft:ender_dragon": ("biome", "minecraft:the_end", "the End"),
"minecraft:shulker": ("structure", "minecraft:end_city", "End cities"),
"minecraft:breeze": ("structure", "minecraft:trial_chambers", "trial spawners"),
"minecraft:sniffer": ("item", "minecraft:sniffer_egg", "hatched from a sniffer egg"),
}
class Island:
def __init__(self, stack, tags, log=print):
self.stack, self.tags, self.log = stack, tags, log
self.btags = tags["worldgen/biome"]
self.notes = []
self._load_biomes()
self._load_structures()
self._load_features()
self._load_mobs()
# --- dimensions and biomes -----------------------------------------------------------
def _load_biomes(self):
cfg = json.loads((ROOT / "world/biomes.json").read_text())
world = json.loads((ROOT / "world/world.json").read_text())
self.world_type = world.get("level_type", "minecraft:normal")
preset = self.stack.get("worldgen/world_preset", self.world_type) or {}
dims = preset.get("dimensions", {})
self.dimensions = sorted(dims) or ["minecraft:overworld", "minecraft:the_nether", "minecraft:the_end"]
water = [norm_id(b) for b in cfg.get("water", [])]
allowed = [norm_id(b) for b in cfg.get("allowed", [])]
preset_biomes = set(re.findall(r'"biome":\s*"([^"]+)"', json.dumps(dims.get("minecraft:overworld", {}))))
extra = preset_biomes - set(allowed) - set(water)
if extra:
self.notes.append(f"world preset lists biomes not in world/biomes.json: {sorted(extra)}")
added_caves = {norm_id(a["biome"]) for a in cfg.get("add", [])
if a.get("parameters", {}).get("depth", [0, 0])[0] > 0}
caves = self.btags.resolve_all(["#c:is_underground", "#c:is_cave"]) | added_caves
self.biomes = {}
for b in allowed + water:
kind = "water" if b in water else "cave" if b in caves else "surface"
self.biomes[b] = {"dimension": "minecraft:overworld", "kind": kind, "status": "available"}
if "minecraft:the_end" in dims or not dims:
for b in END_BIOMES:
self.biomes[b] = {"dimension": "minecraft:the_end", "kind": "end", "status": "conditional",
"reason": "the End: needs a stronghold portal and Eyes of Ender"}
if "minecraft:the_nether" in dims or not dims:
for b in NETHER_BIOMES:
self.biomes.setdefault(b, {"dimension": "minecraft:the_nether", "kind": "nether", "status": "available"})
self.overworld = {b for b, v in self.biomes.items() if v["dimension"] == "minecraft:overworld"}
self.has_nether = "minecraft:the_nether" in dims
def biome_status(self, biomes):
best = "unavailable"
for b in biomes:
s = self.biomes.get(b, {}).get("status", "unavailable")
if RANK[s] > RANK[best]:
best = s
return best
def island_biomes(self, biomes):
return sorted(b for b in biomes if b in self.biomes)
# --- structures ------------------------------------------------------------------------
def _load_structures(self):
in_set, rings = set(), set()
for sid, rel in self.stack.ids("worldgen/structure_set").items():
d = self.stack.json(rel) or {}
members = [s["structure"] for s in d.get("structures", [])] if isinstance(d.get("structures"), list) else []
in_set.update(norm_id(m) for m in members)
if d.get("placement", {}).get("type") == "minecraft:concentric_rings":
rings.update(norm_id(m) for m in members)
stags = self.tags["worldgen/structure"]
self.structures = {}
for sid, rel in self.stack.ids("worldgen/structure").items():
d = self.stack.json(rel) or {}
biomes = self.btags.resolve_all(d.get("biomes", []))
here = self.island_biomes(biomes)
status, reason = self.biome_status(here), ""
if sid not in in_set:
status, reason = "unavailable", "not in any structure set (never generates)"
elif not here:
reason = "its biomes are not on the island"
elif sid in rings and status == "available":
status, reason = "conditional", "generates in rings 1,280+ blocks from the origin; may be outside the world border"
elif status == "conditional":
reason = "only in the End"
self.structures[sid] = {"type": d.get("type"), "biomes": here, "status": status, "reason": reason,
"tags": [], "loot": set(), "blocks": set(), "mobs": set(), "data": d}
for tag in stags.all_tags():
for sid in stags.resolve(tag):
if sid in self.structures:
self.structures[sid]["tags"].append(tag)
for sid, s in self.structures.items():
if s["status"] == "unavailable":
continue
contents = self.walk(s["data"], start=sid)
s["loot"] |= contents["loot"]
s["blocks"] |= contents["blocks"]
s["mobs"] |= contents["entities"]
s["loot"] |= {f"minecraft:{p}" for p in CODE_LOOT.get(s["type"], [])}
if s["type"] == "minecraft:ocean_ruin":
s["loot"].add(f"minecraft:archaeology/ocean_ruin_{s['data'].get('biome_temp', 'cold')}")
s["blocks"] |= set(CODE_STRUCTURE_BLOCKS.get(sid, []) + CODE_STRUCTURE_BLOCKS.get(s["type"], []))
for cat in s["data"].get("spawn_overrides", {}).values():
s["mobs"] |= {norm_id(sp["type"]) for sp in cat.get("spawns", [])}
def structure_status(self, refs):
"""Best status over structure ids or #tags (for spawn conditions and mob rules)."""
stags = self.tags["worldgen/structure"]
best, which = "unavailable", []
for ref in refs:
for sid in stags.resolve(ref):
s = self.structures.get(sid)
if s and RANK[s["status"]] > 0:
which.append(sid)
if RANK[s["status"]] > RANK[best]:
best = s["status"]
return best, sorted(set(which))
def walk(self, obj, start=""):
"""Follow template pools, processor lists, NBT templates and features from a JSON object;
collect loot tables, palette blocks and entity ids. Unknown strings are ignored."""
out = {"loot": set(), "blocks": set(), "entities": set()}
replaced = set() # template marker blocks that rule processors always swap out
rules = [] # (input block or None, output blocks) of rule processors
loot_ids = self.stack.ids("loot_table")
seen = set()
todo = list(_strings(obj))
while todo:
s = todo.pop()
if not isinstance(s, str) or ":" not in s and "/" not in s or s in seen:
continue
seen.add(s)
rid = norm_id(s)
if rid in loot_ids:
out["loot"].add(rid)
continue
for kind in ("worldgen/template_pool", "worldgen/processor_list", "worldgen/configured_feature",
"worldgen/placed_feature"):
d = self.stack.get(kind, rid)
if d is not None:
todo.extend(_strings(d))
if kind.endswith("feature"):
out["blocks"] |= _block_names(d)
elif kind.endswith("processor_list"):
found_rules, removed = _processor_blocks(d)
rules.extend(found_rules)
replaced |= removed
nbt_rel = self.stack.resource("structure", rid, ext=".nbt")
if self.stack.exists(nbt_rel):
try:
tpl = nbt.load(self.stack.read(nbt_rel))
except Exception as e: # a broken template must not stop the guide
self.notes.append(f"unreadable structure template {nbt_rel}: {e}")
continue
palettes = tpl.get("palettes") or [tpl.get("palette", [])]
for pal in palettes:
out["blocks"] |= {norm_id(p["Name"]) for p in pal if "Name" in p}
for b in tpl.get("blocks", []):
if "nbt" in b:
todo.extend(nbt.strings(b["nbt"]))
for e in tpl.get("entities", []):
eid = e.get("nbt", {}).get("id")
if eid:
out["entities"].add(norm_id(eid))
grew = True
while grew: # a rule only places its output where its input block exists
before = len(out["blocks"])
for inp, outs in rules:
if inp is None or inp in out["blocks"]:
out["blocks"] |= outs
grew = len(out["blocks"]) > before
out["blocks"] = {b for b in out["blocks"] - replaced if b not in (
"minecraft:air", "minecraft:structure_void", "minecraft:jigsaw", "minecraft:cave_air")}
return out
# --- natural blocks (features + terrain) -------------------------------------------------
def _load_features(self):
biome_features = {b: set() for b in self.biomes}
for b in self.biomes:
d = self.stack.get("worldgen/biome", b) or {}
for step in d.get("features", []):
biome_features[b].update(norm_id(f) for f in step if isinstance(f, str))
for name, refs in COBBLEMON_CODED_FEATURES.items():
# None means every biome of every dimension; the feature's own checks (dirt, water) keep it
# out of the End, so only overworld biomes are counted.
for b in (self.btags.resolve_all(refs) if refs else self.overworld) & set(self.biomes):
biome_features[b].add(f"cobblemon:{name}")
self.biome_mods = []
removed = set()
for mid, rel in self.stack.ids("neoforge/biome_modifier").items():
d = self.stack.json(rel) or {}
t = d.get("type")
if t not in ("neoforge:add_features", "neoforge:remove_features", "neoforge:add_spawns", "neoforge:remove_spawns"):
continue
biomes = self.holder_set(d.get("biomes"), self.btags)
self.biome_mods.append((mid, t, biomes, d))
if t.endswith("_features"):
feats = self.holder_set(d.get("features"), self.tags["worldgen/placed_feature"])
for b in biomes & set(self.biomes):
if t == "neoforge:add_features":
biome_features[b] |= feats
else:
removed |= biome_features[b] & feats
biome_features[b] -= feats
self.biome_features = biome_features
# Blocks that would generate if the pack had not switched their features off (ores):
# features removed by a biome modifier, and placed features overridden to zero placements.
for fid in self.stack.ids("worldgen/placed_feature"):
d = self.stack.get("worldgen/placed_feature", fid) or {}
if any(p.get("type") == "minecraft:count" and p.get("count") == 0 for p in d.get("placement", [])):
removed.add(fid)
self.removed_blocks = {}
for fid in removed:
for blk in self._feature_blocks(fid, ignore_zero=True)[0]:
self.removed_blocks.setdefault(blk, set()).add(fid)
self.natural = {} # block -> {"biomes": set, "via": set}
self.feature_loot = {} # loot table -> {"biomes", "via"}
feature_cache = {}
for b, feats in biome_features.items():
for f in feats:
if f not in feature_cache:
feature_cache[f] = self._feature_blocks(f)
blocks, filt, loot = feature_cache[f]
if filt and not filt(b):
continue
for blk in blocks:
n = self.natural.setdefault(blk, {"biomes": set(), "via": set()})
n["biomes"].add(b)
n["via"].add(f)
for lt in loot:
n = self.feature_loot.setdefault(lt, {"biomes": set(), "via": set()})
n["biomes"].add(b)
n["via"].add(f)
# terrain: whatever the noise settings place (stone, deepslate, sand, terracotta, ...)
for dim, biomes in (("minecraft:overworld", self.overworld),
("minecraft:the_end", {b for b in self.biomes if self.biomes[b]["kind"] == "end"})):
preset = self.stack.get("worldgen/world_preset", self.world_type) or {}
settings = preset.get("dimensions", {}).get(dim, {}).get("generator", {}).get("settings")
d = settings if isinstance(settings, dict) else self.stack.get("worldgen/noise_settings", settings or "")
for blk in _block_names(d or {}):
n = self.natural.setdefault(blk, {"biomes": set(), "via": set()})
n["biomes"] |= biomes
n["via"].add("terrain")
for blk in ("minecraft:bedrock", "minecraft:water", "minecraft:lava", "minecraft:grass_block", "minecraft:dirt"):
self.natural.setdefault(blk, {"biomes": set(self.overworld), "via": {"terrain"}})
for sid, s in self.structures.items():
if RANK[s["status"]] == 0:
continue
for blk in s["blocks"]:
n = self.natural.setdefault(blk, {"biomes": set(), "via": set()})
n["via"].add(f"structure:{sid}")
def _feature_blocks(self, fid, ignore_zero=False):
"""(blocks, biome filter, loot tables) for a placed feature, or nothing if it was zeroed."""
d = self.stack.get("worldgen/placed_feature", fid)
if d is None:
return set(), None, set()
placement = d.get("placement", [])
if not ignore_zero and any(p.get("type") == "minecraft:count" and p.get("count") == 0 for p in placement):
return set(), None, set()
included, excluded = self._biome_predicates(d)
blocks, loot, seen = set(), set(), set()
todo = [d.get("feature")]
while todo:
f = todo.pop()
if isinstance(f, str):
if f in seen:
continue
seen.add(f)
f = self.stack.get("worldgen/configured_feature", f) or self.stack.get("worldgen/placed_feature", f)
if not isinstance(f, dict):
continue
blocks |= _block_names(f)
inc, exc = self._biome_predicates(f)
included = included if inc is None else (inc if included is None else included & inc)
excluded |= exc
for node in _dicts(f):
extra = CODE_FEATURE_BLOCKS.get(node.get("type"))
if isinstance(extra, list):
blocks |= self.tags["block"].resolve_all(extra)
elif isinstance(extra, str):
pat = re.compile(extra)
blocks |= {k.replace(":blocks/", ":") for k in self.stack.ids("loot_table")
if ":blocks/" in k and pat.fullmatch(k.replace(":blocks/", ":"))}
if node.get("type") == "minecraft:multiface_growth" and isinstance(node.get("config", {}).get("block"), str):
blocks.add(norm_id(node["config"]["block"]))
if f.get("type") in ("cobblemon:structure", "minecraft:fossil"):
w = self.walk(f.get("config", {}))
blocks |= w["blocks"]
loot |= w["loot"]
for s in _strings(f):
if s != fid and (self.stack.get("worldgen/configured_feature", s) is not None
or self.stack.get("worldgen/placed_feature", s) is not None):
todo.append(s)
loot |= {f"minecraft:{p}" for p in CODE_LOOT.get(fid, [])}
def filt(b):
return (included is None or b in included) and b not in excluded
return blocks, (filt if included is not None or excluded else None), loot
def _biome_predicates(self, d):
"""Cobblemon's 'cobblemon:biome' predicates (includedBiomes/excludedBiomes) anywhere in a feature."""
inc, exc = None, set()
for node in _dicts(d):
if node.get("type") == "cobblemon:biome":
if node.get("includedBiomes"):
s = self.btags.resolve_all(node["includedBiomes"])
inc = s if inc is None else inc | s
exc |= self.btags.resolve_all(node.get("excludedBiomes", []))
return inc, exc
def holder_set(self, value, tags):
"""NeoForge holder sets: id, #tag, list, or {"type": "neoforge:any"|"and"|"or"|"not", ...}."""
if value is None:
return set()
if isinstance(value, str):
return tags.resolve(value) if value.startswith("#") else {norm_id(value)}
if isinstance(value, list):
out = set()
for v in value:
out |= self.holder_set(v, tags)
return out
t = value.get("type")
if t == "neoforge:any":
return set(self.biomes) if tags is self.btags else {"*"}
if t == "neoforge:or":
return set().union(*(self.holder_set(v, tags) for v in value.get("values", [])))
if t == "neoforge:and":
sets = [self.holder_set(v, tags) for v in value.get("values", [])]
return set.intersection(*sets) if sets else set()
if t == "neoforge:not":
base = set(self.biomes) if tags is self.btags else set()
return base - self.holder_set(value.get("value"), tags)
return set()
def natural_status(self, block):
n = self.natural.get(block)
if not n:
return "unavailable", []
biomes = self.island_biomes(n["biomes"])
st = self.biome_status(biomes)
for via in n["via"]:
if via.startswith("structure:"):
s = self.structures[via[10:]]["status"]
if RANK[s] > RANK[st]:
st = s
return st, biomes
# --- mobs --------------------------------------------------------------------------------
def _load_mobs(self):
self.mobs = {} # entity -> list of {"via": text, "status": s, "biomes": [...]} (item-gated ones resolved later)
for b in self.biomes:
d = self.stack.get("worldgen/biome", b) or {}
for cat, spawns in d.get("spawners", {}).items():
for sp in spawns:
self._add_mob(norm_id(sp["type"]), "biome", b, self.biomes[b]["status"])
for mid, t, biomes, d in self.biome_mods:
if t == "neoforge:add_spawns":
sps = d.get("spawners", [])
for sp in sps if isinstance(sps, list) else [sps]:
for b in biomes & set(self.biomes):
self._add_mob(norm_id(sp["type"]), "biome", b, self.biomes[b]["status"])
for sid, s in self.structures.items():
if RANK[s["status"]]:
for m in s["mobs"]:
self._add_mob(m, "structure", sid, s["status"])
self.item_mobs = {}
for mob, (kind, req, note) in SPECIAL_MOBS.items():
if kind == "overworld":
self._add_mob(mob, "special", note, "available")
elif kind == "biome" and req in self.biomes:
self._add_mob(mob, "special", note, self.biomes[req]["status"])
elif kind == "structure":
st, which = self.structure_status([req])
if RANK[st]:
self._add_mob(mob, "special", note, st)
elif kind == "item":
self.item_mobs[mob] = (req, note)
def _add_mob(self, mob, kind, where, status):
lst = self.mobs.setdefault(mob, [])
for e in lst:
if e["kind"] == kind and kind == "biome":
e["where"].add(where)
if RANK[status] > RANK[e["status"]]:
e["status"] = status
return
lst.append({"kind": kind, "where": {where}, "status": status})
def mob_status(self, mob):
best = "unavailable"
for e in self.mobs.get(mob, []):
if RANK[e["status"]] > RANK[best]:
best = e["status"]
return best
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)
def _dicts(obj):
if isinstance(obj, dict):
yield obj
for v in obj.values():
yield from _dicts(v)
elif isinstance(obj, list):
for v in obj:
yield from _dicts(v)
def _processor_blocks(plist):
"""([(input block or None, output blocks)], marker blocks the list always replaces)."""
rules, replaced = [], set()
procs = plist.get("processors", []) if isinstance(plist, dict) else plist
for proc in procs if isinstance(procs, list) else []:
if proc.get("processor_type") == "minecraft:block_ignore":
replaced |= _block_names(proc)
for rule in proc.get("rules", []):
pred = rule.get("input_predicate", {})
inp = norm_id(pred["block"]) if isinstance(pred.get("block"), str) else None
if pred.get("predicate_type") == "minecraft:blockstate_match":
inp = next(iter(_block_names(pred)), None)
rules.append((inp, _block_names(rule.get("output_state", {}))))
certain = pred.get("predicate_type") in ("minecraft:block_match", "minecraft:blockstate_match") or (
pred.get("predicate_type") == "minecraft:random_block_match" and pred.get("probability", 0) >= 1)
if certain and inp:
replaced.add(inp)
return rules, replaced
def _block_names(obj):
"""Block ids a feature or surface rule places: every {"Name": ...} block state in it."""
return {norm_id(d["Name"]) for d in _dicts(obj) if isinstance(d.get("Name"), str)}