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:
commit
398301f0be
232 files changed
+72665
No files matched your search
@@ -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.
|
||||
"""
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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 []
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 => ({'&': '&', '<': '<', '>': '>', '"': '"'}[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"))
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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)}
|
||||
Reference in new issue
Block a user