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

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

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

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

No files matched your search

+138
View File
@@ -0,0 +1,138 @@
"""Read Minecraft 1.21 worlds: NBT, region (.mca) files and chunk data.
Only reading, and only what the world tools need: block and biome palettes,
heightmaps and level.dat. Long arrays come back as numpy int64 arrays.
"""
import gzip
import struct
import zlib
from pathlib import Path
import numpy as np
MIN_Y = -64
class _Reader:
__slots__ = ("buf", "pos")
def __init__(self, buf):
self.buf = buf
self.pos = 0
def take(self, n):
start = self.pos
self.pos += n
return self.buf[start:self.pos]
def unpack(self, fmt, size):
value = struct.unpack_from(fmt, self.buf, self.pos)[0]
self.pos += size
return value
def string(self):
length = self.unpack(">H", 2)
return bytes(self.take(length)).decode("utf-8", "replace")
def payload(self, tag):
if tag == 1:
return self.unpack(">b", 1)
if tag == 2:
return self.unpack(">h", 2)
if tag == 3:
return self.unpack(">i", 4)
if tag == 4:
return self.unpack(">q", 8)
if tag == 5:
return self.unpack(">f", 4)
if tag == 6:
return self.unpack(">d", 8)
if tag == 7:
return np.frombuffer(self.take(self.unpack(">i", 4)), dtype=np.int8)
if tag == 8:
return self.string()
if tag == 9:
inner, length = self.unpack(">b", 1), self.unpack(">i", 4)
return [self.payload(inner) for _ in range(length)]
if tag == 10:
out = {}
while (inner := self.unpack(">b", 1)) != 0:
name = self.string()
out[name] = self.payload(inner)
return out
if tag == 11:
return np.frombuffer(self.take(4 * self.unpack(">i", 4)), dtype=">i4")
if tag == 12:
return np.frombuffer(self.take(8 * self.unpack(">i", 4)), dtype=">i8").astype(np.int64)
raise ValueError(f"unknown NBT tag {tag}")
def parse_nbt(data):
reader = _Reader(memoryview(data))
tag = reader.unpack(">b", 1)
reader.string()
return reader.payload(tag)
def read_level_dat(world):
return parse_nbt(gzip.decompress(Path(world, "level.dat").read_bytes()))["Data"]
def region_chunks(path):
"""Yield (chunk_x, chunk_z, root_compound) for every chunk stored in a region file."""
data = Path(path).read_bytes()
if len(data) < 8192:
return
_, rx, rz, _ = Path(path).name.split(".")
rx, rz = int(rx), int(rz)
for i in range(1024):
loc = struct.unpack_from(">I", data, i * 4)[0]
offset, sectors = loc >> 8, loc & 0xFF
if offset == 0 or sectors == 0:
continue
start = offset * 4096
length, compression = struct.unpack_from(">IB", data, start)
payload = data[start + 5:start + 4 + length]
if compression == 2:
payload = zlib.decompress(payload)
elif compression == 1:
payload = gzip.decompress(payload)
elif compression != 3:
continue # external (.mcc) or LZ4 chunks: not produced by vanilla defaults
yield rx * 32 + (i % 32), rz * 32 + (i // 32), parse_nbt(payload)
def unpack(longs, bits, count):
"""Unpack a 1.16+ packed long array (entries never span two longs)."""
if bits == 0 or longs is None or len(longs) == 0:
return np.zeros(count, dtype=np.int64)
per_long = 64 // bits
shifts = np.arange(per_long, dtype=np.uint64) * np.uint64(bits)
values = (longs.astype(np.uint64)[:, None] >> shifts[None, :]) & np.uint64((1 << bits) - 1)
return values.reshape(-1)[:count].astype(np.int64)
def heightmap(chunk, name):
"""16x16 array (z, x) of the first free y above the column, in world coordinates."""
longs = chunk.get("Heightmaps", {}).get(name)
if longs is None:
return None
return unpack(longs, 9, 256).reshape(16, 16) + MIN_Y
def section_blocks(section):
"""(palette names, 4096 indices in y,z,x order) for one chunk section."""
states = section.get("block_states", {})
palette = [entry["Name"] for entry in states.get("palette", [])]
bits = max(4, (len(palette) - 1).bit_length())
indices = unpack(states.get("data"), bits, 4096) if len(palette) > 1 else np.zeros(4096, dtype=np.int64)
return palette, indices
def section_biomes(section):
"""(palette, 64 indices in y,z,x order) for one chunk section."""
biomes = section.get("biomes", {})
palette = list(biomes.get("palette", []))
bits = (len(palette) - 1).bit_length()
indices = unpack(biomes.get("data"), bits, 64) if len(palette) > 1 else np.zeros(64, dtype=np.int64)
return palette, indices
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Write the island world type from world/biomes.json.
Vanilla's overworld biome parameters come from Minecraft's own data generator
(run on the vanilla server jar that the NeoForge installer put in
build/server; no server is started). Every entry is sent to its target biome
from world/biomes.json, the extra entries are appended, and the result becomes
the inline biome list of WorldGen Editor's delegate in
datapacks/core/data/cobblesurvival/worldgen/world_preset/island.json.
The world type has no Nether dimension.
Usage: tools/biome_layout.py (run tools/smoke_test.py once first so build/server exists)
"""
import json
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from mcserver import CACHE, PACK, ROOT, SERVER # noqa: E402
from packwiz_sync import java # noqa: E402
PRESET = ROOT / "datapacks/core/data/cobblesurvival/worldgen/world_preset/island.json"
def vanilla_parameters():
version = PACK["versions"]["minecraft"]
report = CACHE / f"datagen-{version}" / "reports/biome_parameters/minecraft/overworld.json"
if not report.exists():
jar = SERVER / "libraries/net/minecraft/server" / version / f"server-{version}.jar"
if not jar.exists():
sys.exit(f"{jar} is missing; run tools/smoke_test.py once so the NeoForge installer fetches it")
work = CACHE / f"datagen-{version}"
work.mkdir(parents=True, exist_ok=True)
subprocess.run([java(), "-DbundlerMainClass=net.minecraft.data.Main", "-jar", str(jar),
"--reports", "--output", str(work)], cwd=work, check=True, stdout=subprocess.DEVNULL)
return json.loads(report.read_text())["biomes"]
def main():
spec = json.loads((ROOT / "world/biomes.json").read_text())
targets = set(spec["allowed"]) | set(spec["water"])
entries, unmapped = [], set()
for entry in vanilla_parameters():
target = spec["remap"].get(entry["biome"])
if target is None:
unmapped.add(entry["biome"])
continue
entries.append({"biome": target, "parameters": entry["parameters"]})
for extra in spec["add"]:
entries.append({"biome": extra["biome"], "parameters": extra["parameters"]})
bad_targets = {e["biome"] for e in entries} - targets
if unmapped or bad_targets:
sys.exit(f"vanilla biomes without a target: {sorted(unmapped)}; targets outside the list: {sorted(bad_targets)}")
preset = {
"dimensions": {
"minecraft:overworld": {
"type": "minecraft:overworld",
"generator": {
"type": "minecraft:noise",
"biome_source": {
"type": "worldgen_editor:island_biome_source",
"preset": "cobblesurvival_island",
"delegate": {"type": "minecraft:multi_noise", "biomes": "@BIOMES@"},
},
"settings": "worldgen_editor:island_overworld",
},
},
"minecraft:the_end": {
"type": "minecraft:the_end",
"generator": {"type": "minecraft:noise", "biome_source": {"type": "minecraft:the_end"},
"settings": "minecraft:end"},
},
}
}
# One biome entry per line keeps the 7,600-entry file diffable.
body = ",\n".join(" " + json.dumps(e, separators=(",", ":")) for e in entries)
text = json.dumps(preset, indent=2).replace('"@BIOMES@"', "[\n" + body + "\n ]")
json.loads(text)
PRESET.write_text(text + "\n")
counts = {}
for e in entries:
counts[e["biome"]] = counts.get(e["biome"], 0) + 1
print(f"wrote {PRESET.relative_to(ROOT)}: {len(entries)} entries")
for biome, n in sorted(counts.items(), key=lambda kv: -kv[1]):
print(f" {n:5} {biome}")
if __name__ == "__main__":
main()
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""Build the companion mod and export the pack.
Outputs, all under build/:
stage/full/ every mod plus the companion jar; the Prism
instance and the server smoke test install this
<slug>-<ver>.mrpack Modrinth pack
<slug>-<ver>-curseforge.zip CurseForge pack
Each platform export only lists mods that platform hosts. A mod hosted on just
one platform is left out of the other export and reported; it is never copied
into overrides/, because the pack must not bundle third-party jars. The
companion mod is our own work, so it is the one jar allowed in overrides/.
Usage: tools/build.py [--skip-mod]
"""
import argparse
import os
import shutil
import subprocess
import sys
import tomllib
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PACK = ROOT / "pack"
BUILD = ROOT / "build"
MODRINTH_HOSTS = ("https://cdn.modrinth.com/", "https://github.com/",
"https://raw.githubusercontent.com/", "https://gitlab.com/")
def run(cmd, cwd):
print(f"$ {' '.join(map(str, cmd))} (in {cwd.relative_to(ROOT) if cwd.is_relative_to(ROOT) else cwd})")
subprocess.run(cmd, cwd=cwd, check=True)
def packwiz(*args, cwd):
run([shutil.which("packwiz") or "packwiz", *args, "-y"], cwd)
def java_home():
"""Gradle 8.14 cannot run on Java 25, so prefer an explicit Java 21."""
for candidate in (os.environ.get("JAVA21_HOME"), os.environ.get("JAVA_HOME"),
Path.home() / ".var/app/org.prismlauncher.PrismLauncher/data/PrismLauncher/java/java-runtime-delta"):
if candidate and (Path(candidate) / "bin" / "java").exists():
return str(candidate)
return None
def build_mod():
env = dict(os.environ)
if home := java_home():
env["JAVA_HOME"] = home
subprocess.run(["./gradlew", "build", "--console=plain"], cwd=ROOT / "mod", env=env, check=True)
def companion_jar():
props = dict(line.split("=", 1) for line in (ROOT / "mod/gradle.properties").read_text().splitlines()
if "=" in line and not line.startswith("#"))
jar = ROOT / "mod/build/libs" / f"{props['mod_id']}-{props['mod_version']}.jar"
if not jar.exists():
sys.exit(f"companion jar not found: {jar} (run without --skip-mod)")
return jar
def platforms_of(meta):
hosts = set()
if "curseforge" in meta.get("update", {}):
hosts.add("curseforge")
if meta.get("download", {}).get("url", "").startswith(MODRINTH_HOSTS):
hosts.add("modrinth")
return hosts
def stage(name, jar, platform=None):
dest = BUILD / "stage" / name
shutil.rmtree(dest, ignore_errors=True)
shutil.copytree(PACK, dest)
left_out = []
if platform:
for path in sorted((dest / "mods").glob("*.pw.toml")):
meta = tomllib.loads(path.read_text())
if platform not in platforms_of(meta):
left_out.append(meta["name"])
path.unlink()
shutil.copy2(jar, dest / "mods" / jar.name)
packwiz("refresh", cwd=dest)
return dest, left_out
def check_overrides(archive, jar):
bundled = [n for n in zipfile.ZipFile(archive).namelist()
if n.endswith(".jar") and Path(n).name != jar.name]
if bundled:
sys.exit(f"{archive.name} would bundle third-party jars: {bundled}")
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--skip-mod", action="store_true", help="reuse the last companion mod build")
args = parser.parse_args()
if not args.skip_mod:
build_mod()
jar = companion_jar()
packwiz("refresh", cwd=PACK)
pack = tomllib.loads((PACK / "pack.toml").read_text())
base = f"{pack['name'].lower().replace(' ', '-')}-{pack['version']}"
stage("full", jar)
report = {}
for platform, suffix, command in (("modrinth", ".mrpack", "modrinth"),
("curseforge", "-curseforge.zip", "curseforge")):
dest, left_out = stage(platform, jar, platform)
out = BUILD / f"{base}{suffix}"
out.unlink(missing_ok=True)
packwiz(command, "export", "-o", str(out), cwd=dest)
check_overrides(out, jar)
report[out.name] = left_out
print("\nExports (no third-party jars in overrides):")
for name, left_out in report.items():
print(f" build/{name}")
for mod in left_out:
print(f" left out, not hosted on this platform: {mod}")
print(" build/stage/full/pack.toml (all mods; used by Prism sync and the smoke test)")
if __name__ == "__main__":
main()
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Static dependency check for a folder of NeoForge mod jars.
Reads META-INF/neoforge.mods.toml from every jar (and from jar-in-jar files
under META-INF/jarjar/), then checks that each required dependency is present
and inside its declared version range. Needs no server and no EULA, so it runs
anywhere; the server smoke test is still the real proof.
Version ranges use Maven syntax ("[1.8.4,1.9)", "[21.1,)", "1.0" = at least).
Build metadata after "+" is ignored, so Cobblemon "1.8.4+1.21.1" compares as
1.8.4; qualifiers follow Maven (21-beta < 21 < 21.1.265). A mismatch on the `minecraft` dependency is only a warning: every JEI
build for 1.21.1 declares "[1.21, 1.21.1)" and NeoForge 21.1 still loads it
(checked on the test server, 2026-10-28), so this check is stricter than the
loader there.
Usage: tools/check_deps.py <mods_dir> [--neoforge VER] [--minecraft VER]
"""
import argparse
import io
import re
import sys
import tomllib
import zipfile
from pathlib import Path
PLATFORM = {"neoforge", "minecraft", "javafml", "fml", "kotlinforforge_loader"}
QUALIFIERS = {"alpha": 1, "a": 1, "beta": 2, "b": 2, "milestone": 3, "m": 3, "rc": 4, "cr": 4, "snapshot": 5}
RELEASE = (0, 10, "") # pads the shorter version: a release sorts above its own alpha/beta/rc
def key(version):
"""Maven-like ordering: numbers sort above qualifiers, so 21.1.265 > 21-beta > 21-alpha."""
parts = re.split(r"[.\-]", version.split("+", 1)[0])
return [(1, int(p), "") if p.isdigit() else (0, QUALIFIERS.get(p.lower(), 6), p.lower()) for p in parts if p]
def compare(a, b):
ka, kb = key(a), key(b)
n = max(len(ka), len(kb))
ka, kb = ka + [RELEASE] * (n - len(ka)), kb + [RELEASE] * (n - len(kb))
return (ka > kb) - (ka < kb)
def in_range(version, spec):
spec = spec.strip()
if spec in ("", "*"):
return True
if spec[0] not in "[(":
return compare(version, spec) >= 0
for part in re.findall(r"[\[(][^\])]*[\])]", spec):
lo_inc, hi_inc = part[0] == "[", part[-1] == "]"
bounds = part[1:-1].split(",")
if len(bounds) == 1:
if compare(version, bounds[0]) == 0:
return True
continue
lo, hi = (b.strip() for b in bounds)
if lo and (compare(version, lo) < 0 or (compare(version, lo) == 0 and not lo_inc)):
continue
if hi and (compare(version, hi) > 0 or (compare(version, hi) == 0 and not hi_inc)):
continue
return True
return False
def read_mods(data, name, found, deps):
z = zipfile.ZipFile(io.BytesIO(data))
names = set(z.namelist())
if "META-INF/neoforge.mods.toml" in names:
toml = tomllib.loads(z.read("META-INF/neoforge.mods.toml").decode("utf-8", "replace"))
manifest = z.read("META-INF/MANIFEST.MF").decode("utf-8", "replace") if "META-INF/MANIFEST.MF" in names else ""
jar_version = (re.search(r"Implementation-Version: *(\S+)", manifest) or [None, "0"])[1]
for mod in toml.get("mods", []):
version = str(mod.get("version", "0")).replace("${file.jarVersion}", jar_version)
found[mod["modId"]] = (version, name)
for mod_id, entries in toml.get("dependencies", {}).items():
for dep in entries:
required = dep.get("type", "required" if dep.get("mandatory", True) else "optional") == "required"
if required:
deps.append((mod_id, dep["modId"], dep.get("versionRange", "*"), name))
for inner in (n for n in names if n.startswith("META-INF/jarjar/") and n.endswith(".jar")):
read_mods(z.read(inner), f"{name}!{inner.rsplit('/', 1)[-1]}", found, deps)
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("mods_dir", type=Path)
parser.add_argument("--neoforge")
parser.add_argument("--minecraft", default="1.21.1")
args = parser.parse_args()
found, deps = {}, []
for jar in sorted(args.mods_dir.glob("*.jar")):
read_mods(jar.read_bytes(), jar.name, found, deps)
if args.neoforge:
found["neoforge"] = (args.neoforge, "loader")
found["minecraft"] = (args.minecraft, "game")
problems, warnings = [], []
for owner, dep, spec, jar in deps:
if dep not in found:
if dep not in PLATFORM:
problems.append(f"{owner} ({jar}) needs {dep} {spec}: not installed")
elif not in_range(found[dep][0], spec):
(warnings if dep == "minecraft" else problems).append(
f"{owner} ({jar}) needs {dep} {spec}: found {found[dep][0]}")
print(f"{len(found)} mods, {len(deps)} required dependencies checked")
for w in warnings:
print(f" warning: {w}")
if problems:
print("\n".join(f" - {p}" for p in problems))
sys.exit(1)
print("all required dependencies satisfied")
if __name__ == "__main__":
main()
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Report keyboard keys that more than one keybind uses, with the pack's defaults applied.
NeoForge's Controls screen marks two keybinds as conflicting whenever they share
a base key, even when one only works in GUIs and the other only in-game (its
KeyMapping.same() falls back to comparing keys). The only exception is two
binds in the same context with different modifiers (Ctrl+F next to F). So the
pack's defaults give every keyboard bind its own base key.
The list of keybinds comes from a client's options.txt (written on the first
launch), so run this after adding a mod and launching the game once. Mouse
buttons are left out: JEI's click actions in its item list share them with
attack/use/pick block by design.
Usage: tools/check_keybinds.py [options.txt] (default: the Prism dev instance)
"""
import sys
from collections import defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DEFAULTS = ROOT / "pack/config/defaultoptions/keybindings.txt"
INSTANCE = (Path.home() / ".var/app/org.prismlauncher.PrismLauncher/data/PrismLauncher/instances"
/ "Cobblemon Survival Pack (dev)/minecraft/options.txt")
def parse(lines):
binds = {}
for line in lines:
if not line.startswith("key_"):
continue
name, _, rest = line[4:].partition(":")
key, _, modifiers = rest.partition(":")
binds[name] = (key, modifiers.replace("NONE", ""))
return binds
def main():
options = Path(sys.argv[1]) if len(sys.argv) > 1 else INSTANCE
if not options.exists():
sys.exit(f"{options} not found; launch the game once so it is written")
binds = parse(options.read_text().splitlines())
defaults = parse(DEFAULTS.read_text().splitlines())
unknown = sorted(set(defaults) - set(binds))
binds.update(defaults)
by_key = defaultdict(list)
for name, (key, modifiers) in binds.items():
if key.startswith("key.keyboard.") and key != "key.keyboard.unknown":
by_key[key].append((name, modifiers))
def clashes(entries):
# One mod's binds share a context, so distinct modifiers keep them apart (e.g. JEI's
# Page Down and Shift+Page Down). Anything else on the same key shows as a conflict.
mods = {name.rsplit(".", 1)[0] for name, _ in entries}
return len(entries) > 1 and not (len(mods) == 1 and len({m for _, m in entries}) == len(entries))
shared = {k: [f"{n}{' +' + m if m else ''}" for n, m in v] for k, v in sorted(by_key.items()) if clashes(v)}
print(f"{len(binds)} keybinds, {len(defaults)} set by the pack, {len(by_key)} keyboard keys in use")
for name in unknown:
print(f" warning: the pack sets {name}, which this client doesn't have")
if shared:
print("keys used by more than one keybind:")
for key, names in shared.items():
print(f" {key}: {', '.join(names)}")
sys.exit(1)
print("no keyboard key is used twice")
if __name__ == "__main__":
main()
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""Write data-pack overrides so that structures place no ore.
Ore: removing ore features is not enough. Some structure templates have ore or
raw ore blocks built in (Cobblemon habitats and ruins), and some processor lists
turn blocks into ore (vanilla diamond fossils). For every template pool element
or configured feature (vanilla fossils, Cobblemon's `cobblemon:structure`) that
places such a template, and every processor list that outputs ore, this appends
one rule processor at the end of the processors that run for it, turning each
ore block into plain stone (see replacement()).
Vanilla mobs that structures place are kept out by the companion mod instead
(world/OnlyPokemon.kt), so structures keep all their pieces.
Data comes from the vanilla server jar and every mod jar in build/server/mods.
Re-run after a mod update and commit the result.
Usage: tools/clean_structures.py
"""
import copy
import gzip
import json
import re
import shutil
import sys
import zipfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import anvil # noqa: E402
from mcserver import PACK, ROOT, SERVER # noqa: E402
ORE = re.compile(r"(_ore|:raw_(iron|copper|gold)_block|:ancient_debris)$")
OUT = ROOT / "datapacks/core/data"
STRIP_ID = "cobblesurvival:strip_ores"
# Placeholder look-alikes for the raw ore blocks (decided in Phase 1, see docs/balance.md).
RAW = {"minecraft:raw_iron_block": "minecraft:tuff", "minecraft:raw_copper_block": "minecraft:terracotta",
"minecraft:raw_gold_block": "minecraft:yellow_terracotta"}
def replacement(block):
if block in RAW:
return RAW[block]
name = block.split(":", 1)[1]
if name.startswith("deepslate_"):
return "minecraft:deepslate"
if name.startswith("nether_") or block == "minecraft:ancient_debris":
return "minecraft:netherrack"
return "minecraft:stone"
def sources():
version = PACK["versions"]["minecraft"]
jars = sorted(SERVER.glob(f"libraries/net/minecraft/server/{version}-*/server-{version}-*-extra.jar"))
jars += sorted(p for p in (SERVER / "mods").glob("*.jar") if not p.name.startswith("cobblesurvival-"))
if not jars:
sys.exit("build/server is not set up; run tools/smoke_test.py once first")
return [zipfile.ZipFile(j) for j in jars]
def resource_id(name, kind):
"""data/<ns>/<kind>/<path>.<ext> -> ns:path"""
_, ns, rest = name.split("/", 2)
return f"{ns}:{rest[len(kind) + 1:].rsplit('.', 1)[0]}"
def strip_processor(blocks):
"""A rule processor turning each ore block into stone, wrapped in `minecraft:capped`.
Some lists use `minecraft:capped`, which changes blocks in a second, batch
pass after every per-block processor, so a plain rule at the end can be
undone. A capped processor with an unreachable cap runs its rule on every
block in that batch pass, after the other processors in the list.
"""
rules = [{"input_predicate": {"predicate_type": "minecraft:block_match", "block": b},
"location_predicate": {"predicate_type": "minecraft:always_true"},
"output_state": {"Name": replacement(b)}} for b in sorted(blocks)]
return {"processor_type": "minecraft:capped", "limit": 1_000_000,
"delegate": {"processor_type": "minecraft:rule", "rules": rules}}
def ore_outputs(node):
"""Ore blocks a processor (or anything nested in it, e.g. a capped delegate) can output."""
if isinstance(node, dict):
name = node.get("output_state", {}).get("Name", "") if isinstance(node.get("output_state"), dict) else ""
found = {name} if ORE.search(name) else set()
return found.union(*(ore_outputs(v) for v in node.values()))
if isinstance(node, list):
return set().union(*(ore_outputs(v) for v in node)) if node else set()
return set()
def main():
templates, processor_lists, pools, features = {}, {}, {}, {}
for z in sources():
for name in z.namelist():
if not name.startswith("data/"):
continue
if name.endswith(".nbt") and "/structure/" in name:
root = anvil.parse_nbt(gzip.decompress(z.read(name)))
palette = root.get("palette") or [p for ps in root.get("palettes", []) for p in ps]
ores = {p["Name"] for p in palette if ORE.search(p["Name"])}
if ores:
templates[resource_id(name, "structure")] = ores
elif name.endswith(".json") and "/worldgen/processor_list/" in name:
processor_lists[resource_id(name, "worldgen/processor_list")] = json.loads(z.read(name))
elif name.endswith(".json") and "/worldgen/template_pool/" in name:
pools[resource_id(name, "worldgen/template_pool")] = json.loads(z.read(name))
elif name.endswith(".json") and "/worldgen/configured_feature/" in name:
features[resource_id(name, "worldgen/configured_feature")] = json.loads(z.read(name))
ore_blocks = set().union(*templates.values()) if templates else set()
lists_to_strip = set()
for pid, plist in processor_lists.items():
outputs = ore_outputs(plist)
if outputs:
ore_blocks |= outputs
lists_to_strip.add(pid)
for feature in features.values():
config = feature.get("config")
if not isinstance(config, dict):
continue
named = [v for value in config.values() for v in (value if isinstance(value, list) else [value])
if isinstance(v, str)]
if any(t in templates for t in named):
lists_to_strip.update(v for k, v in config.items() if "processors" in k and isinstance(v, str))
pools_to_write = {}
def visit(element, pool_id):
if element.get("element_type") == "minecraft:list_pool_element":
for inner in element["elements"]:
visit(inner, pool_id)
return
if element.get("location") not in templates:
return
procs = element.get("processors", "minecraft:empty")
if isinstance(procs, str) and procs != "minecraft:empty":
lists_to_strip.add(procs)
else:
pools_to_write[pool_id] = True
element["processors"] = STRIP_ID if procs == "minecraft:empty" else \
{"processors": procs.get("processors", []) + [strip_processor(ore_blocks)]}
edited_pools = {}
for pid, pool in pools.items():
copy_ = copy.deepcopy(pool)
for entry in copy_.get("elements", []):
visit(entry["element"], pid)
if pid in pools_to_write:
edited_pools[pid] = copy_
strip = strip_processor(ore_blocks)
written = []
def write(kind, rid, data):
ns, path = rid.split(":", 1)
target = OUT / ns / kind / f"{path}.json"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json.dumps(data, indent=2) + "\n")
written.append(target)
# Overrides always start from the original jar data, so clear the last run's output first.
for kind in ("worldgen/processor_list", "worldgen/template_pool"):
for ns_dir in OUT.iterdir():
old = ns_dir / kind
if old.is_dir() and ns_dir.name != "cobblesurvival":
shutil.rmtree(old)
write("worldgen/processor_list", STRIP_ID, {"processors": [strip]})
for pid in sorted(lists_to_strip):
if pid not in processor_lists:
print(f"warning: processor list {pid} not found")
continue
procs = processor_lists[pid].get("processors", [])
write("worldgen/processor_list", pid, {"processors": procs + [strip]})
for pid, pool in sorted(edited_pools.items()):
write("worldgen/template_pool", pid, pool)
print(f"{len(templates)} templates contain ore; {len(ore_blocks)} ore blocks get replaced")
print(f"wrote {len(written)} files ({len(lists_to_strip)} processor lists, {len(edited_pools)} template pools)")
for block in sorted(ore_blocks):
print(f" {block} -> {replacement(block)}")
if __name__ == "__main__":
main()
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Write data-pack overrides that stop Cobblemon's evolution-stone ores from generating.
Cobblemon adds its ore features from code as direct holders, so the
`neoforge:remove_features` modifier (which matches registry holders) cannot
remove them. Cobblemon looks each placed feature up by id, though, so an
override with zero placements under the same id turns it off. Re-run this
after a Cobblemon update and commit the result.
Usage: tools/cobblemon_ore_overrides.py <Cobblemon jar>
"""
import json
import shutil
import sys
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PREFIX = "data/cobblemon/worldgen/placed_feature/ore/"
OUT = ROOT / "datapacks/core" / PREFIX
def main():
if len(sys.argv) != 2:
sys.exit(__doc__)
jar = zipfile.ZipFile(sys.argv[1])
shutil.rmtree(OUT, ignore_errors=True)
OUT.mkdir(parents=True)
names = sorted(n for n in jar.namelist() if n.startswith(PREFIX) and n.endswith(".json"))
for name in names:
original = json.loads(jar.read(name))
override = {"feature": original["feature"], "placement": [{"type": "minecraft:count", "count": 0}]}
(OUT / name[len(PREFIX):]).write_text(json.dumps(override, indent=2) + "\n")
print(f"wrote {len(names)} overrides to {OUT.relative_to(ROOT)}")
if __name__ == "__main__":
main()
+7
View File
@@ -0,0 +1,7 @@
{
"kotlin-for-forge": {
"curseforge_slug": "kotlin-for-forge",
"trust_same_filename": "author's own 5.12.0 upload on both platforms; the two builds differ by 12 bytes (checked 2026-10-28)"
},
"architectury-api": {"curseforge_slug": "architectury-api"}
}
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Give every packwiz mod both a Modrinth and a CurseForge source.
packwiz only records the platform a mod was added from. Exporting to the other
platform then copies the jar into overrides/, which would bundle a third-party
jar. This script finds the same file on the other platform and verifies it by
SHA-1 before linking it:
* Modrinth -> CurseForge: CurseForge project found via cfwidget (slug from
crosslink.json or the Modrinth slug), file matched by filename, then both
files are downloaded and compared: same SHA-1, or (for jars re-built with
other timestamps) the same entry names and CRC32s.
* CurseForge -> Modrinth: exact lookup of the SHA-1 on Modrinth.
Mods with no counterpart are reported; build.py keeps them out of the export
for the platform that lacks them instead of bundling them.
Usage: tools/crosslink.py [pack_dir] (then run `packwiz refresh`)
"""
import io
import json
import sys
import tomllib
import urllib.error
import urllib.parse
import urllib.request
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
OVERRIDES = json.loads((ROOT / "tools" / "crosslink.json").read_text())
UA = {"User-Agent": "cobblemon-survival-pack-tools/0.1 (packwiz crosslink)"}
def get_json(url):
req = urllib.request.Request(url, headers=UA)
try:
with urllib.request.urlopen(req, timeout=60) as r:
return json.load(r)
except urllib.error.HTTPError as e:
if e.code == 404:
return None
raise
def download(url):
req = urllib.request.Request(url, headers=UA)
with urllib.request.urlopen(req, timeout=300) as r:
return r.read()
def same_file(a, b):
"""'sha1' if byte-identical, 'content' if the jars hold identical entries.
The manifest is ignored: authors who upload to each platform from a
separate build get a different Implementation-Timestamp and nothing else.
"""
if a == b:
return "sha1"
try:
entries = [sorted((i.filename, i.CRC) for i in zipfile.ZipFile(io.BytesIO(x)).infolist()
if i.filename != "META-INF/MANIFEST.MF")
for x in (a, b)]
except zipfile.BadZipFile:
return None
return "content" if entries[0] == entries[1] else None
def forgecdn_url(file_id, filename):
return "https://edge.forgecdn.net/files/{}/{}/{}".format(
file_id // 1000, file_id % 1000, urllib.parse.quote(filename))
def link_curseforge(path, meta, override):
mr = meta["update"]["modrinth"]
version = get_json(f"https://api.modrinth.com/v2/version/{mr['version']}")
project = get_json(f"https://api.modrinth.com/v2/project/{mr['mod-id']}")
file = next(f for f in version["files"] if f["filename"] == meta["filename"])
slug = override.get("curseforge_slug", project["slug"])
cf = get_json(f"https://api.cfwidget.com/minecraft/mc-mods/{slug}")
if not cf or "files" not in cf:
return f"no CurseForge project for slug '{slug}'"
candidates = [f for f in cf["files"] if f["name"] == meta["filename"]]
if not candidates:
return f"CurseForge project {cf['id']} has no file named {meta['filename']}"
mr_bytes = download(file["url"])
for cand in candidates:
match = same_file(mr_bytes, download(forgecdn_url(cand["id"], cand["name"])))
if not match and override.get("trust_same_filename") and len(candidates) == 1:
match = f"filename, trusted: {override['trust_same_filename']}"
if match:
text = path.read_text().rstrip() + (
f"\n\n[update.curseforge]\nfile-id = {cand['id']}\nproject-id = {cf['id']}\n")
path.write_text(text)
return f"linked CurseForge {cf['id']}/{cand['id']} (same {match})"
return "CurseForge file found by name but its contents differ"
def link_modrinth(path, meta):
sha1 = meta["download"]["hash"] if meta["download"]["hash-format"] == "sha1" else None
if not sha1:
return "no SHA-1 in metadata"
version = get_json(f"https://api.modrinth.com/v2/version_file/{sha1}?algorithm=sha1")
if not version:
return "not on Modrinth"
file = next(f for f in version["files"] if f["hashes"]["sha1"] == sha1)
cf = meta["update"]["curseforge"]
path.write_text(
f'name = "{meta["name"]}"\n'
f'filename = "{meta["filename"]}"\n'
f'side = "{meta.get("side", "both")}"\n\n'
f'[download]\nurl = "{file["url"]}"\nhash-format = "sha1"\nhash = "{sha1}"\n\n'
f'[update]\n[update.curseforge]\nfile-id = {cf["file-id"]}\nproject-id = {cf["project-id"]}\n\n'
f'[update.modrinth]\nmod-id = "{version["project_id"]}"\nversion = "{version["id"]}"\n')
return f"linked Modrinth {version['project_id']}/{version['id']}"
def main():
pack = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "pack"
missing = []
for path in sorted((pack / "mods").glob("*.pw.toml")):
meta = tomllib.loads(path.read_text())
update = meta.get("update", {})
override = OVERRIDES.get(path.name.removesuffix(".pw.toml"), {})
if "modrinth" in update and "curseforge" in update:
status = "already linked"
elif override.get("skip"):
status = f"skipped: {override['skip']}"
elif "modrinth" in update:
status = link_curseforge(path, meta, override)
else:
status = link_modrinth(path, meta)
if not status.startswith(("linked", "already")):
missing.append(path.name)
print(f"{path.name:45} {status}")
if missing:
print("\nSingle-platform mods (kept out of the other platform's export):")
print("\n".join(f" {m}" for m in missing))
if __name__ == "__main__":
main()
+8
View File
@@ -0,0 +1,8 @@
"""Field guide generator for the Cobblemon Survival Pack.
Reads the pack's effective game data (vanilla, NeoForge, every mod jar in
pack/mods, then datapacks/core on top) and writes build/field-guide/:
fieldguide.json (the single source), an HTML guide, and in-game exports.
Entry point: tools/fieldguide/build.py. Overview: docs/field-guide.md.
"""
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Generate the field guide: where every Pokémon lives on the island, how to get every mod item,
and what each item is for, from the pack's own data.
Reads vanilla data (build/server/libraries), NeoForge, every jar listed in pack/mods (copied to
build/fieldguide-cache/jars from build/stage/full, build/server or the Prism instance), then
datapacks/core on top, plus world/biomes.json and world/world.json for the island. The recipes the
pack's KubeJS scripts add, change or remove come from `/kubejs export` on the test server
(kubejs_export.py); when that export is older than the pack, this boots the test server once to
refresh it (about a minute) unless --no-server is given.
Writes build/field-guide/:
fieldguide.json the single source every renderer reads
index.html, pokemon.html, items.html, data.js, assets/, icons/ the HTML guide
ingame/ftbquests/ FTB Quests "Field Guide" chapters (not installed)
ingame/kubejs/ Pokédex "Found in" lang override (not installed)
Usage: tools/fieldguide/build.py [--out DIR] [--no-icons] [--no-server] [--from-json FILE] [--extra FILE ...]
--no-server never start Minecraft: use the last KubeJS recipe export even if the pack changed since
--from-json skip extraction and render an existing fieldguide.json (e.g. one a runtime exporter wrote)
--extra add sources from a JSON file (format in docs/field-guide.md), e.g. ore veins or trades
"""
import argparse
import json
import sys
import time
from pathlib import Path
sys.dont_write_bytecode = True # keep tools/fieldguide free of __pycache__
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from fieldguide import datasource, export_ftbquests, export_lang, guide, kubejs_export, render_html # noqa: E402
from fieldguide.icons import Icons # noqa: E402
from fieldguide.model import Model # noqa: E402
ROOT = Path(__file__).resolve().parent.parent.parent
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("--out", type=Path, default=ROOT / "build/field-guide")
ap.add_argument("--no-icons", action="store_true", help="skip rendering item icons")
ap.add_argument("--no-server", action="store_true", help="use the last KubeJS recipe export as it is")
ap.add_argument("--from-json", type=Path, help="render this fieldguide.json instead of extracting")
ap.add_argument("--extra", type=Path, action="append", default=[], help="extra sources JSON (repeatable)")
args = ap.parse_args()
out = args.out
started = time.time()
if args.from_json:
data = json.loads(args.from_json.read_text())
lang = {}
else:
print("reading the pack's data")
stack = datasource.build_stack(log=_log_once())
model = Model(stack, log=lambda msg: print(" " + msg), extras=args.extra, kubejs=_kubejs(args.no_server))
icons = {}
if not args.no_icons:
wanted = set(model.mod_items) | _vanilla_mentions(model)
ic = Icons(stack, out)
icons = ic.write_all(wanted)
print(f" icons: {len(icons)} rendered, {len(ic.missing)} without a usable model")
data = guide.build(model, icons)
guide.write(data, out / "fieldguide.json")
lang = model.lang
render_html.write(data, out)
chapters = export_ftbquests.write(data, out / "ingame")
if lang:
n, missing = export_lang.write(data, lang, out / "ingame")
print(f" Pokédex override: {n} keys" + (f", {len(missing)} keys not in the jar's lang" if missing else ""))
(out / "ingame/README.md").write_text(INGAME_README)
_summary(data, chapters, out, time.time() - started)
def _kubejs(no_server):
"""The pack's KubeJS recipe changes; refreshed from the test server when the pack changed since."""
data = kubejs_export.load()
if data and not data["stale"]:
print(f" KubeJS recipes: export from {data['exported']}")
return data
why = "no KubeJS recipe export yet" if not data else "pack/kubejs, pack/mods or datapacks/core changed since the last KubeJS recipe export"
if no_server:
print(f" warning: {why}; --no-server, so the guide " +
("uses the old export" if data else "shows no pack recipes"))
return data
print(f" {why}: booting the test server for /kubejs export")
try:
return kubejs_export.export(log=lambda msg: print(" " + msg))
except (SystemExit, Exception) as e: # e.g. no EULA, a server already running, mods failing to load
print(f" warning: KubeJS recipe export failed ({type(e).__name__}: {e}); the guide " +
("uses the old export" if data else "shows no pack recipes"))
return data
def _vanilla_mentions(model):
"""Vanilla items that show up in recipes or sources of mod items (they get icons too)."""
out = set()
mods = set(model.mod_items)
for r in model.recipes.values():
if r["result"] in mods or any(i in mods for s in r["slots"] for i in s["items"]):
out.add(r["result"])
for s in r["slots"]:
out.update(s["items"][:8])
for d in model.species.values():
for e in (d.get("drops") or {}).get("entries", []):
out.add(e["item"] if ":" in e["item"] else "minecraft:" + e["item"])
return {i for i in out if i.startswith("minecraft:")}
def _log_once():
seen = []
def log(msg):
if msg.startswith("warning:") and "not found in" in msg:
seen.append(msg.split()[1])
if len(seen) == 1:
print(" some pack/mods jars are not downloaded yet (run tools/build.py); continuing without them")
return
print(" " + msg)
return log
def _summary(data, chapters, out, secs):
sp = data["species"]
items = data["items"]
wild = sum(1 for s in sp.values() if s["wild"] == "available")
other = sum(1 for s in sp.values() if s["wild"] != "available" and s["status"] == "available")
cond = sum(1 for s in sp.values() if s["status"] == "conditional")
none = [s for s in sp.values() if s["status"] == "unavailable"]
nosrc = [i for i, v in items.items() if v["status"] == "unavailable" and not v["technical"]]
print(f"\nPokémon: {wild} wild on the island, {other} more by evolution/fossil/starter, {cond} only in the End, "
f"{len(none)} not obtainable ({sum(1 for s in none if not s['implemented'])} of them not implemented)")
for loc in data["locations"]:
n = len({m["species"] for i in loc["spawns"] for m in data["spawns"][i]["members"]})
print(f" {loc['name']:<24} {loc['kind']:<8} {n:>4} species")
print(f"Items: {len(items)} mod items, {len(nosrc)} with no source on the island")
print(f"FTB Quests chapters: " + ", ".join(f"{k} ({v} quests)" for k, v in chapters.items()))
print(f"Wrote {out} in {secs:.1f}s; open {out / 'index.html'}")
INGAME_README = """# In-game exports (generated, not installed)
- `ftbquests/`: FTB Quests "Field Guide" chapters. See `ftbquests/README.md`.
- `kubejs/`: Pokédex description override that appends "Found in: …". See `kubejs/README.md`.
Both are generated by `python3 tools/fieldguide/build.py` from `../fieldguide.json`.
Installing them is a separate decision; nothing here is copied into pack/.
"""
if __name__ == "__main__":
main()
+266
View File
@@ -0,0 +1,266 @@
"""Layered, read-only view of the game's resources, resolved like Minecraft's pack stack.
Layers from bottom to top: vanilla server data (plus the vanilla client jar for
textures, if Prism has it), NeoForge, every mod jar listed in pack/mods, then
datapacks/core. A higher layer replaces a file with the same path, tags are
merged over all layers, and a pack.mcmeta "filter" block hides matching files
of every layer below it (how Phase 3 can blank Cobblemon's own spawn pools).
Jars are copied into build/fieldguide-cache/jars first, so a server or Prism
instance that replaces its jars mid-run cannot change what we read.
"""
import json
import re
import shutil
import tomllib
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
BUILD = ROOT / "build"
CACHE = BUILD / "fieldguide-cache"
PRISM = Path.home() / ".var/app/org.prismlauncher.PrismLauncher/data/PrismLauncher"
MOD_DIRS = [BUILD / "stage/full/mods", BUILD / "server/mods",
PRISM / "instances/Cobblemon Survival Pack (dev)/minecraft/mods"]
# Only these asset subtrees are indexed; Cobblemon alone ships ~20k model/sound files we never read.
ASSET_DIRS = ("lang/", "models/item/", "models/block/", "textures/item/", "textures/block/", "blockstates/")
class Layer:
"""One resource pack: a jar or a folder, with its pack.mcmeta filter."""
def __init__(self, name, path, roots=("data", "assets"), modids=()):
self.name, self.path, self.modids = name, Path(path), list(modids)
self.files = {}
self.filters = []
self.zip = zipfile.ZipFile(self.path) if self.path.is_file() else None
names = self.zip.namelist() if self.zip else [
p.relative_to(self.path).as_posix() for p in self.path.rglob("*") if p.is_file()]
for n in names:
parts = n.split("/", 3)
if len(parts) < 4 or parts[0] not in roots or n.endswith("/"):
continue
if parts[0] == "assets" and not parts[2].startswith(("lang", "models", "textures", "blockstates")):
continue
if parts[0] == "assets" and not (parts[2] + "/" + parts[3]).startswith(ASSET_DIRS):
continue
self.files[n] = n
meta = self.read("pack.mcmeta", missing_ok=True)
if meta:
block = json.loads(meta).get("filter", {}).get("block", [])
self.filters = [(re.compile(f.get("namespace", ".*")), re.compile(f.get("path", ".*"))) for f in block]
def read(self, rel, missing_ok=False):
try:
return self.zip.read(rel) if self.zip else (self.path / rel).read_bytes()
except (KeyError, FileNotFoundError):
if missing_ok:
return None
raise
def __repr__(self):
return f"Layer({self.name})"
def loads(raw):
"""JSON as Minecraft's lenient reader accepts it (a few mod files carry // comments)."""
text = raw.decode("utf-8-sig")
try:
return json.loads(text)
except json.JSONDecodeError:
text = re.sub(r"^\s*//.*$", "", text, flags=re.M)
return json.loads(re.sub(r",(\s*[}\]])", r"\1", text))
class DataStack:
def __init__(self, layers):
self.layers = layers
self.index = {}
self.errors = []
self._ids = {}
self._json = {}
for i, layer in enumerate(layers):
for rel in layer.files:
self.index.setdefault(rel, []).append(i)
self.unfiltered = {rel: list(idxs) for rel, idxs in self.index.items()}
for i, layer in enumerate(layers):
if not layer.filters:
continue
for rel, idxs in self.index.items():
ns, path = rel.split("/", 2)[1:]
if any(n.fullmatch(ns) and p.fullmatch(path) for n, p in layer.filters):
self.index[rel] = [j for j in idxs if j >= i]
@property
def modids(self):
return {"minecraft", "neoforge", "c"} | {m for layer in self.layers for m in layer.modids}
def exists(self, rel):
return bool(self.index.get(rel))
def read(self, rel):
idxs = self.index.get(rel)
return self.layers[idxs[-1]].read(rel) if idxs else None
def layer_of(self, rel):
idxs = self.index.get(rel)
return self.layers[idxs[-1]] if idxs else None
def json(self, rel):
if rel not in self._json:
raw = self.read(rel)
try:
self._json[rel] = loads(raw) if raw is not None else None
except (ValueError, UnicodeDecodeError) as e:
self.errors.append(f"{rel}: {e}")
self._json[rel] = None
return self._json[rel]
def all_json(self, rel):
"""Every layer's copy of a file, bottom to top (tags merge these)."""
out = []
for i in self.index.get(rel, []):
try:
out.append(loads(self.layers[i].read(rel)))
except (ValueError, UnicodeDecodeError) as e:
self.errors.append(f"{self.layers[i].name}:{rel}: {e}")
return out
def ids(self, kind, root="data", ext=".json"):
"""{namespace:path -> rel} for every visible resource of a kind, e.g. ids('recipe')."""
key = (root, kind, ext)
if key not in self._ids:
found = {}
for rel, idxs in self.index.items():
if not idxs or not rel.startswith(root + "/") or not rel.endswith(ext):
continue
_, ns, rest = rel.split("/", 2)
if rest.startswith(kind + "/"):
found[f"{ns}:{rest[len(kind) + 1:-len(ext)]}"] = rel
self._ids[key] = found
return self._ids[key]
def hidden(self, kind, root="data", ext=".json"):
"""{namespace:path -> json} for resources of a kind that a pack.mcmeta filter hides from every layer,
read from the topmost layer that has them (e.g. Cobblemon's own spawn pools under datapacks/core's filter)."""
out = {}
for rel, idxs in self.unfiltered.items():
if self.index.get(rel) or not rel.startswith(root + "/") or not rel.endswith(ext):
continue
_, ns, rest = rel.split("/", 2)
if rest.startswith(kind + "/"):
try:
out[f"{ns}:{rest[len(kind) + 1:-len(ext)]}"] = (rel, loads(self.layers[idxs[-1]].read(rel)))
except (ValueError, UnicodeDecodeError) as e:
self.errors.append(f"{self.layers[idxs[-1]].name}:{rel}: {e}")
return dict(sorted(out.items()))
def resource(self, kind, rid, root="data", ext=".json"):
ns, path = split_id(rid)
return f"{root}/{ns}/{kind}/{path}{ext}"
def get(self, kind, rid):
return self.json(self.resource(kind, rid))
def split_id(rid):
rid = rid.lstrip("#")
return rid.split(":", 1) if ":" in rid else ("minecraft", rid)
def norm_id(rid):
ns, path = split_id(rid)
return f"{ns}:{path}"
# --- locating and caching jars -----------------------------------------------------------
def pack_mod_files():
"""Jar filenames the pack defines (pack/mods/*.pw.toml)."""
out = []
for toml in sorted((ROOT / "pack/mods").glob("*.pw.toml")):
data = tomllib.loads(toml.read_text())
if data.get("filename", "").endswith(".jar"):
out.append(data["filename"])
return out
def find_jar(name, dirs=MOD_DIRS):
for d in dirs:
if (d / name).is_file():
return d / name
return None
def companion_jar():
for d in MOD_DIRS:
jars = sorted(d.glob("cobblesurvival-*.jar"), key=lambda p: p.stat().st_mtime)
if jars:
return jars[-1]
return None
def cached(src):
"""Copy a jar into build/fieldguide-cache/jars unless an identical copy is there."""
dst = CACHE / "jars" / src.name
st = src.stat()
if not dst.exists() or dst.stat().st_size != st.st_size or int(dst.stat().st_mtime) != int(st.st_mtime):
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
return dst
def jar_modids(path):
with zipfile.ZipFile(path) as z:
try:
toml = tomllib.loads(z.read("META-INF/neoforge.mods.toml").decode())
except (KeyError, tomllib.TOMLDecodeError, UnicodeDecodeError):
return []
return [m["modId"] for m in toml.get("mods", []) if "modId" in m]
def newest(pattern_dir, pattern):
hits = sorted(Path(pattern_dir).glob(pattern)) if Path(pattern_dir).exists() else []
return hits[-1] if hits else None
def build_stack(datapack=ROOT / "datapacks/core", extra_layers=(), log=print):
"""Create the layer stack. Missing optional inputs are reported, missing required ones raise."""
pack_toml = tomllib.loads((ROOT / "pack/pack.toml").read_text())
mc = pack_toml.get("versions", {}).get("minecraft", "1.21.1")
neo = pack_toml.get("versions", {}).get("neoforge")
libs = BUILD / "server/libraries"
vanilla = newest(libs / "net/minecraft/server", f"{mc}-*/server-{mc}-*-extra.jar")
if not vanilla:
raise SystemExit(f"vanilla data jar not found under {libs} (run tools/smoke_test.py --load-only once)")
layers = []
client = PRISM / f"libraries/com/mojang/minecraft/{mc}/minecraft-{mc}-client.jar"
if client.exists():
layers.append(Layer("minecraft-client", cached(client), roots=("assets",)))
else:
log(f"note: no vanilla client jar at {client}; vanilla items get no icons")
layers.append(Layer("minecraft", cached(vanilla), modids=["minecraft"]))
neo_jar = newest(libs / "net/neoforged/neoforge", f"{neo or '*'}/neoforge-*-universal.jar")
if neo_jar:
layers.append(Layer("neoforge", cached(neo_jar), modids=["neoforge"]))
else:
log("note: NeoForge universal jar not found; c: tags from NeoForge are missing")
mods = []
for name in pack_mod_files():
src = find_jar(name)
if not src:
log(f"warning: {name} (pack/mods) not found in {', '.join(str(d) for d in MOD_DIRS)}")
continue
mods.append(cached(src))
comp = companion_jar()
if comp:
mods.append(cached(comp))
for jar in sorted(mods, key=lambda p: p.name.lower()):
layers.append(Layer(jar.name, jar, modids=jar_modids(jar)))
for extra in extra_layers:
layers.append(Layer(Path(extra).name, extra))
layers.append(Layer("datapacks/core", datapack, roots=("data",)))
# KubeJS serves pack/kubejs/data as a data pack and pack/kubejs/assets as a resource pack.
if (ROOT / "pack/kubejs").is_dir():
layers.append(Layer("pack/kubejs", ROOT / "pack/kubejs"))
return DataStack(layers)
+387
View File
@@ -0,0 +1,387 @@
"""FTB Quests "Field Guide" chapters from fieldguide.json (reference only, nothing locks).
Format of FTB Quests 2101.1.37 (read from its source, BaseQuestFile/Chapter/
Quest/TranslationManager): quests live in config/ftbquests/quests/; each
chapter is chapters/<filename>.snbt with 16-hex-digit string ids; titles,
subtitles and descriptions are not in the chapter file but in
lang/<locale>.snbt under keys "<type>.<ID>.title", "quest.<ID>.quest_subtitle",
"quest.<ID>.quest_desc" (a list of lines) and "chapter.<ID>.chapter_subtitle".
Icons are single-item stacks {id, components}; a Pokémon icon is
cobblemon:pokemon_model with the cobblemon:pokemon_item component
{species, aspects} (Cobblemon 1.8.4 PokemonItemComponent).
Ids are derived from names (sha256), so they stay the same between runs and
Phase 8's generator can place its own chapters next to these.
"""
import hashlib
from .world import RANK
CHAPTERS = ("field_guide_wild", "field_guide_other", "field_guide_items")
BIOME_ICONS = {
"minecraft:plains": "minecraft:short_grass", "minecraft:forest": "minecraft:oak_sapling",
"minecraft:cherry_grove": "minecraft:cherry_sapling", "minecraft:mangrove_swamp": "minecraft:mangrove_propagule",
"minecraft:jungle": "minecraft:jungle_sapling", "minecraft:desert": "minecraft:cactus",
"minecraft:badlands": "minecraft:red_sand", "minecraft:windswept_hills": "minecraft:gravel",
"minecraft:jagged_peaks": "minecraft:snow_block", "minecraft:lush_caves": "minecraft:moss_block",
"minecraft:dripstone_caves": "minecraft:pointed_dripstone", "minecraft:deep_dark": "minecraft:sculk",
"minecraft:basalt_deltas": "minecraft:basalt", "minecraft:ocean": "minecraft:kelp",
"minecraft:deep_ocean": "minecraft:prismarine", "minecraft:lukewarm_ocean": "minecraft:seagrass",
"minecraft:deep_lukewarm_ocean": "minecraft:sea_pickle", "minecraft:warm_ocean": "minecraft:brain_coral",
"minecraft:river": "minecraft:water_bucket", "minecraft:beach": "minecraft:sand",
}
COLUMNS = 14
def stable_id(name):
"""64-bit id from a name. FTB parses ids with Long.parseLong(hex, 16), so the top bit must be clear."""
v = int(hashlib.sha256(("cobblesurvival-fieldguide/" + name).encode()).hexdigest()[:16], 16) & 0x7FFF_FFFF_FFFF_FFFF
return f"{max(v, 2):016X}"
# --- SNBT in FTB Library's style (tabs, newline-separated, sorted keys) -------------------------
def _key(k):
return k if all(c.isalnum() or c in "._-+" for c in k) else _str(k)
def _str(s):
out = s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\t", "\\t").replace("\r", "")
return f'"{out}"'
def snbt(value, indent=0):
tab = "\t" * indent
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, int):
return str(value)
if isinstance(value, float):
return f"{value:g}d" if "e" not in f"{value:g}" else f"{value:.6f}d"
if isinstance(value, str):
return _str(value)
if isinstance(value, list):
if not value:
return "[ ]"
return "[\n" + "\n".join(tab + "\t" + snbt(v, indent + 1) for v in value) + "\n" + tab + "]"
if isinstance(value, dict):
if not value:
return "{ }"
return "{\n" + "\n".join(f"{tab}\t{_key(k)}: {snbt(v, indent + 1)}" for k, v in sorted(value.items())) + "\n" + tab + "}"
raise TypeError(type(value))
def parse_snbt(text):
"""Read SNBT the way FTB Library 2101's SNBTParser does (used to check our own output).
Lines whose trimmed text starts with // or # are comments; entries are separated by commas
or newlines; numbers carry b/s/l/f/d suffixes; true/false are bytes.
"""
lines = [ln for ln in text.split("\n") if not ln.strip().startswith(("//", "#"))]
src, pos = "\n".join(lines), 0
simple = lambda c: c.isalnum() or c in "._-+\u221e" # noqa: E731
def ws():
nonlocal pos
while pos < len(src) and src[pos] in " \t\r\n,":
pos += 1
def quoted():
nonlocal pos
pos += 1
out = []
esc = {"n": "\n", "t": "\t", "b": "\b", "r": "\r", "f": "\f", '"': '"', "\\": "\\"}
while True:
c = src[pos]
pos += 1
if c == "\n":
raise ValueError(f"newline inside a string at {pos}")
if c == "\\":
out.append(esc.get(src[pos], ""))
pos += 1
elif c == '"':
return "".join(out)
else:
out.append(c)
def word():
nonlocal pos
start = pos
while pos < len(src) and simple(src[pos]):
pos += 1
return src[start:pos]
def value():
nonlocal pos
ws()
c = src[pos]
if c == "{":
pos += 1
out = {}
while True:
ws()
if src[pos] == "}":
pos += 1
return out
key = quoted() if src[pos] == '"' else word()
ws()
if src[pos] != ":":
raise ValueError(f"expected ':' after {key!r} at {pos}")
pos += 1
out[key] = value()
if c == "[":
pos += 1
out = []
while True:
ws()
if src[pos] == "]":
pos += 1
if len({type(v) for v in out}) > 1:
raise ValueError("mixed list")
return out
out.append(value())
if c == '"':
return quoted()
w = word()
if w in ("true", "false"):
return w == "true"
if w[-1:] in "dD" and _num(w[:-1]):
return float(w[:-1])
if _num(w):
return int(w) if w.lstrip("-").isdigit() else float(w)
return w
result = value()
ws()
if pos != len(src):
raise ValueError(f"trailing data at {pos}")
return result
def _num(s):
try:
float(s)
return True
except ValueError:
return False
# --- chapter building --------------------------------------------------------------------------------
class Builder:
def __init__(self, data):
self.data = data
self.lang = {}
self.chapters = []
def chapter(self, filename, title, subtitle, icon, order):
cid = stable_id("chapter/" + filename)
self.lang[f"chapter.{cid}.title"] = title
self.lang[f"chapter.{cid}.chapter_subtitle"] = subtitle
ch = {"id": cid, "filename": filename, "group": "", "order_index": order, "icon": icon,
"default_quest_shape": "", "default_hide_dependency_lines": False,
"quests": [], "quest_links": [], "images": []}
self.chapters.append(ch)
return ch
def quest(self, ch, name, x, y, title, icon, subtitle=None, desc=(), shape=None, size=None):
qid = stable_id(f"{ch['filename']}/{name}")
q = {"id": qid, "x": float(x), "y": float(y), "icon": icon,
"tasks": [{"id": stable_id(f"{ch['filename']}/{name}/task"), "type": "checkmark"}]}
if shape:
q["shape"] = shape
if size:
q["size"] = float(size)
self.lang[f"quest.{qid}.title"] = title
if subtitle:
self.lang[f"quest.{qid}.quest_subtitle"] = subtitle
if desc:
self.lang[f"quest.{qid}.quest_desc"] = [line for line in desc if not line.startswith("{")]
ch["quests"].append(q)
return q
def item_icon(iid):
return {"id": iid}
def pokemon_icon(sid):
return {"id": "cobblemon:pokemon_model", "components": {"cobblemon:pokemon_item": {"species": sid, "aspects": []}}}
def species_lines(data, sid, limit=10):
sp = data["species"][sid]
lines = [f"#{sp['dex']} · " + " / ".join(t.capitalize() for t in sp["types"])]
if sp["locations"]:
lines.append("Found in:")
for loc in sp["locations"][:limit]:
if loc.get("special"):
lines.append(f"- {loc['name']}: only near particular blocks (e.g. buildings)")
continue
zones = f" ({', '.join(loc['zones'])})" if loc.get("zones") else ""
lines.append(f"- {loc['name']}{zones}: Lv {loc['level'][0]}-{loc['level'][1]}, "
+ ", ".join("alpha" if b == "boss" else b for b in loc["buckets"]))
if len(sp["locations"]) > limit:
lines.append(f"- and {len(sp['locations']) - limit} more places")
best = sorted((data["spawns"][i] for i in sp["spawns"] if data["spawns"][i]["status"] != "unavailable"),
key=lambda s: -(s["weight"] or 0))[:2]
conds = {" · ".join(c["text"]) for s in best for c in s["conditions"] if c["text"] and c["biomes"]}
for c in sorted(conds)[:2]:
lines.append(f"Usually: {c}")
other = [s for s in sp["sources"] if s["status"] != "unavailable"]
if other:
lines.append("Also:" if sp["locations"] else "Not found in the wild here. Get it by:")
lines += [f"- {s['text']}" for s in other[:4]]
for ev in sp["evolutions"][:4]:
lines.append(f"Evolves into {ev['name']}" + (f" ({', '.join(ev['how'])})" if ev["how"] else ""))
if sp["drops"]:
names = [f"{_item_name(data, d['item'])} {d['chance']}" for d in sp["drops"][:5]]
lines.append("Drops: " + ", ".join(names))
return [ln for ln in lines if ln is not None]
def _item_name(data, iid):
return (data["items"].get(iid) or data["vanilla"].get(iid) or {}).get("name") or iid.split(":")[1].replace("_", " ")
def primary_location(data, sid):
"""Location where the species is easiest to meet: most common bucket, then highest weight."""
best, key = None, None
order = {"common": 0, "uncommon": 1, "rare": 2, "ultra-rare": 3, "boss": 4}
for i in data["species"][sid]["spawns"]:
s = data["spawns"][i]
if s["status"] != "available":
continue
for b in s["biomes"]:
k = (s.get("needs_blocks", False), order.get(s["bucket"], 9), -(s["weight"] or 0))
if key is None or k < key:
best, key = b, k
return best
def build(data):
b = Builder(data)
species = data["species"]
wild = b.chapter("field_guide_wild", "Field Guide: Wild Pokémon",
["Where each Pokémon lives on this island. Reference only: nothing here locks anything.",
"Generated from the pack's spawn data by tools/fieldguide."], item_icon("cobblemon:poke_ball"), 900)
by_loc = {}
for sid, sp in species.items():
if sp["wild"] == "available":
loc = primary_location(data, sid)
if loc:
by_loc.setdefault(loc, []).append(sid)
y = 0.0
for loc in data["locations"]:
if loc["kind"] == "end":
continue
mons = sorted(by_loc.get(loc["id"], []), key=lambda s: species[s]["dex"])
everyone = sorted({m["species"] for i in loc["spawns"] for m in data["spawns"][i]["members"]},
key=lambda s: species.get(s, {}).get("dex", 0))
desc = [f"{len(everyone)} Pokémon can appear here. Each one's own entry lists all its places.", ""]
desc += [", ".join(species[s]["name"] for s in everyone if s in species)]
b.quest(wild, "loc/" + loc["id"], 0, y, loc["name"], item_icon(BIOME_ICONS.get(loc["id"], "minecraft:grass_block")),
subtitle=f"{len(everyone)} Pokémon", desc=desc, shape="square", size=1.5)
for n, sid in enumerate(mons):
b.quest(wild, "mon/" + sid, 2 + (n % COLUMNS), y + (n // COLUMNS), species[sid]["name"], pokemon_icon(sid),
desc=species_lines(data, sid), shape="circle")
y += max(1, (len(mons) + COLUMNS - 1) // COLUMNS) + 1.5
other = b.chapter("field_guide_other", "Field Guide: Other Pokémon",
["Pokémon that do not spawn in the wild on this island, and how to get them anyway."],
item_icon("cobblemon:old_amber_fossil"), 901)
obtainable = sorted((s for s, sp in species.items() if sp["wild"] != "available" and sp["status"] != "unavailable"),
key=lambda s: species[s]["dex"])
for n, sid in enumerate(obtainable):
b.quest(other, "mon/" + sid, n % COLUMNS, n // COLUMNS, species[sid]["name"], pokemon_icon(sid),
desc=species_lines(data, sid), shape="circle")
none = sorted((s for s, sp in species.items() if sp["status"] == "unavailable" and sp["implemented"]),
key=lambda s: species[s]["dex"])
rows = (len(obtainable) + COLUMNS - 1) // COLUMNS + 1
chunks = [none[i:i + 40] for i in range(0, len(none), 40)]
for n, chunk in enumerate(chunks):
b.quest(other, f"none/{n}", n * 2, rows + 0.5, "Not obtainable here" + (f" ({n + 1})" if len(chunks) > 1 else ""),
item_icon("minecraft:barrier"), subtitle=f"{len(chunk)} species",
desc=["These have no wild spawn, evolution, fossil or starter route on this island:", ""]
+ [", ".join(species[s]["name"] for s in chunk)], shape="square")
items = b.chapter("field_guide_items", "Field Guide: Items",
["How to get each Cobblemon item on this island and what it is for.",
"Red text: no source on the island."], item_icon("cobblemon:water_stone"), 902)
cats = {}
for iid, it in data["items"].items():
if not it["technical"] and (it["sources"] or it["uses"]):
cats.setdefault(it["category"], []).append(iid)
y = 0.0
for cat in sorted(cats):
ids = sorted(cats[cat], key=lambda i: data["items"][i]["name"])
for n, iid in enumerate(ids):
it = data["items"][iid]
lines = list(it["tooltip"][:2])
if it["status"] == "unavailable":
lines.append("&cNo source on this island.")
elif it["status"] == "conditional":
lines.append("&6Only under a condition (e.g. the End).")
srcs = [s for s in it["sources"] if RANK[s["status"]]] or it["sources"]
if srcs:
lines += ["", "How to get it:"] + [f"- {s['text']}" + (f" ({s['chance']})" if s.get("chance") else "")
for s in srcs[:6]]
if len(srcs) > 6:
lines.append(f"- and {len(srcs) - 6} more")
if it["uses"]:
lines += ["", "Used for:"] + [f"- {u['text']}" for u in it["uses"][:6]]
if len(it["uses"]) > 6:
lines.append(f"- and {len(it['uses']) - 6} more")
b.quest(items, "item/" + iid, n % COLUMNS, y + n // COLUMNS, it["name"], item_icon(iid),
subtitle=cat, desc=lines, shape="hexagon" if it["status"] == "available" else "octagon")
y += (len(ids) + COLUMNS - 1) // COLUMNS + 1
return b
README = """# Field Guide chapters for FTB Quests (generated, not installed)
Made by `python3 tools/fieldguide/build.py` from `build/field-guide/fieldguide.json`.
Format: FTB Quests {version} for NeoForge 1.21.1, read from its source.
Contents (paths relative to the game or server folder):
config/ftbquests/quests/chapters/field_guide_wild.snbt wild Pokémon by location
config/ftbquests/quests/chapters/field_guide_other.snbt Pokémon you get another way
config/ftbquests/quests/chapters/field_guide_items.snbt Cobblemon items: how to get, what for
config/ftbquests/quests/lang/en_us.snbt titles and descriptions (only these chapters' keys)
All entries are reference quests with one checkmark task, no dependencies and no rewards, so nothing locks.
Ids are hashes of names and stay the same between runs.
## Installing (your decision)
1. Stop the game or server (FTB Quests rewrites these files when it saves).
2. Copy the three chapter files into `config/ftbquests/quests/chapters/`.
3. Titles and descriptions: if `config/ftbquests/quests/lang/en_us.snbt` does not exist yet, copy ours.
If it exists (for example after Phase 8), merge: put every line of ours inside the existing top-level
`{{ }}`. Keys never collide with other chapters' keys because they use these chapters' ids.
4. For the pack: the same files go under `pack/config/ftbquests/quests/` and are refreshed with packwiz.
5. Start the game and open the quest book. `/ftbquests reload` re-reads the files on a running server.
When regenerating, replace the three chapter files and our keys in the lang file; FTB Quests keeps
manual edits (positions, extra quests) only in its own saved copy, so regenerate before editing by hand.
"""
def write(data, out, ftb_version="2101.1.37"):
b = build(data)
base = out / "ftbquests"
qdir = base / "config/ftbquests/quests"
(qdir / "chapters").mkdir(parents=True, exist_ok=True)
(qdir / "lang").mkdir(parents=True, exist_ok=True)
for ch in b.chapters:
text = snbt(ch) + "\n"
if parse_snbt(text) != ch: # round trip through an FTB-style reader
raise SystemExit(f"SNBT round trip failed for {ch['filename']}")
(qdir / "chapters" / f"{ch['filename']}.snbt").write_text(text)
text = snbt(b.lang) + "\n"
if parse_snbt(text) != b.lang:
raise SystemExit("SNBT round trip failed for lang/en_us.snbt")
(qdir / "lang/en_us.snbt").write_text(text)
(base / "README.md").write_text(README.format(version=ftb_version))
return {ch["filename"]: len(ch["quests"]) for ch in b.chapters}
+72
View File
@@ -0,0 +1,72 @@
"""Pokédex text override: Cobblemon's description keys with "Found in: …" appended.
The keys are the ones each species JSON lists under "pokedex" (for example
cobblemon.species.eevee.desc, plus form keys such as cobblemon.species.eevee-gmax.desc
for non-battle forms). Values are Cobblemon's own English text from its jar
with one sentence added, written as kubejs/assets/cobblemon/lang/en_us.json,
which KubeJS serves as a resource pack. Language files merge key by key, so
only these keys are overridden.
"""
import json
MAX_PLACES = 4
def suffix(sp):
if sp["wild"] == "available" and sp["locations"]:
natural = [l for l in sp["locations"] if not l.get("special")] or sp["locations"]
locs = sorted(natural, key=lambda l: (min(_rarity(b) for b in l["buckets"]), l["name"]))
parts = [f"{l['name']} (Lv {l['level'][0]}-{l['level'][1]})" for l in locs[:MAX_PLACES]]
more = f" and {len(locs) - MAX_PLACES} more" if len(locs) > MAX_PLACES else ""
return f"Found in: {', '.join(parts)}{more}."
if sp["wild"] == "conditional" and sp["locations"]:
return "Found in: " + ", ".join(l["name"] for l in sp["locations"][:MAX_PLACES]) + " (the End)."
ways = [s["text"] for s in sp["sources"] if s["status"] != "unavailable"]
if ways:
return f"Not wild on this island. {ways[0]}."
return "Cannot be obtained on this island." if sp["implemented"] else ""
def _rarity(bucket):
return {"common": 0, "uncommon": 1, "rare": 2, "ultra-rare": 3, "boss": 4}.get(bucket, 5)
def build(data, lang):
out, missing = {}, []
for sid, sp in data["species"].items():
add = suffix(sp)
if not add:
continue
for key in sp["pokedex_keys"]:
if key not in lang:
missing.append(key)
continue
out[key] = f"{lang[key].rstrip()} {add}"
return dict(sorted(out.items())), missing
README = """# Pokédex "Found in" text (generated, not installed)
`kubejs/assets/cobblemon/lang/en_us.json` overrides {count} of Cobblemon's Pokédex description keys
(`cobblemon.species.<name>.desc` and form keys) with the original English text plus one sentence:
"Found in: Plains (Lv 5-32), …", or how to get the Pokémon if it is not wild on this island.
## Installing (your decision)
1. Copy `kubejs/assets/cobblemon/lang/en_us.json` to `pack/kubejs/assets/cobblemon/lang/en_us.json`
(or to `kubejs/assets/...` in an instance). KubeJS loads `kubejs/assets` as a resource pack on the client.
2. If that file already exists, merge the keys (later keys win; nothing else uses these keys).
3. Restart the client or press F3+T, then open the Pokédex on a Pokémon.
Regenerate after a Cobblemon update or a spawn change: the file holds a copy of Cobblemon's own text.
Other languages keep Cobblemon's text; only en_us is generated.
"""
def write(data, lang, out):
entries, missing = build(data, lang)
dst = out / "kubejs/assets/cobblemon/lang/en_us.json"
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_text(json.dumps(entries, ensure_ascii=False, indent=2) + "\n")
(out / "kubejs/README.md").write_text(README.format(count=len(entries)))
return len(entries), missing
+299
View File
@@ -0,0 +1,299 @@
"""Turn the model into fieldguide.json, the single file every renderer reads.
Schema (version 1), all ids are namespaced ("cobblemon:eevee"):
meta generator info, layers read, status legend, rarity bucket chances, kubejs_export
(when the KubeJS recipe export was made, whether it is stale, what it removed/added)
island biomes (kind, status), dimensions, structures (status, reason)
locations [{id, name, kind, status, spawns: [spawn index]}] in display order
spawns [{id, species members, level, bucket, weight, position, conditions, status, biomes, zones}];
zones: the companion mod's zone condition (ids from world/zones.json); species locations
carry the zone names too
hidden_spawns same shape, for spawn pools a pack.mcmeta filter hides (Cobblemon's own pools, replaced
by the zone pools): never in the game, but resolved against the island the same way
(status = what it would be if active); tools/zone_spawns.py plans the zones from them
species {id: {name, dex, types, status, wild, locations, sources, evolutions, drops, pokedex_keys}}
items {id: {name, mod, category, tooltip, status, sources, uses, icon, technical}} (mod items)
vanilla {id: {name, status, icon}} for vanilla items the guide mentions
recipes {id: {type, label, result, count, grid, slots, script?}}; script ("server_scripts:x.js:12")
marks a recipe a pack KubeJS script added or changed
problems data the generator could not resolve
A runtime exporter or KubeJS dump can replace or extend any section; renderers
only read this file.
"""
import datetime
import json
from .datasource import split_id
from .model import CATEGORIES, TECHNICAL
from .world import RANK
KIND_ORDER = {"surface": 0, "cave": 1, "water": 2, "end": 3, "nether": 4}
def build(model, icons=None):
m, isl = model, model.island
st = model.get_status
icons = icons or {}
locations = []
loc_spawns = {b: [] for b in isl.biomes}
real_biomes = set(m.stack.ids("worldgen/biome")) # tags also name biomes of mods that are not installed
spawns = [_spawn(m, i, e, "spawn", real_biomes) for i, e in enumerate(m.spawns.entries)]
for i, e in enumerate(m.spawns.entries):
for b in e["biomes"]:
loc_spawns.setdefault(b, []).append(i)
for b, info in sorted(isl.biomes.items(), key=lambda kv: (KIND_ORDER.get(kv[1]["kind"], 9), kv[0])):
if info["kind"] == "nether":
continue
idx = [i for i in loc_spawns.get(b, []) if spawns[i]["status"] != "unavailable"]
locations.append({"id": b, "name": m.biome_name(b), "kind": info["kind"], "status": info["status"],
"reason": info.get("reason", ""), "spawns": idx})
species = {}
for sid, sp in sorted(m.species.items(), key=lambda kv: (kv[1]["dex"], kv[0])):
srcs = m.sources.get(f"species:{sid}", [])
wild = [s for s in srcs if s["kind"] == "wild"]
wild_status = max((s["status"] for s in wild), key=RANK.get, default="unavailable")
locs = {}
for s in wild:
if RANK[s["status"]]:
e = spawns[s["spawn"]]
for b in e["biomes"]:
lv = next((mm["level"] for mm in e["members"] if mm["species"] == sid), e["level"])
cur = locs.setdefault(b, {"level": None, "alpha": None, "buckets": set(), "special": True,
"zones": set()})
cur["zones"] |= set(e["zones"])
k = "alpha" if e["bucket"] == "boss" else "level"
cur[k] = list(lv) if cur[k] is None else [min(cur[k][0], lv[0]), max(cur[k][1], lv[1])]
cur["buckets"].add(e["bucket"])
cur["special"] = cur["special"] and e["needs_blocks"]
other = [_source(m, s) for s in srcs if s["kind"] != "wild"]
species[sid] = {
"name": sp["name"], "dex": sp["dex"], "types": sp["types"], "implemented": sp["implemented"],
"labels": sp["labels"], "status": st(f"species:{sid}"), "wild": wild_status,
"locations": [{"biome": b, "name": m.biome_name(b), "level": v["level"] or v["alpha"], "alpha_level": v["alpha"],
"buckets": sorted(v["buckets"]),
"zones": [m.spawns.zones.get(z, {}).get("name", z) for z in
sorted(v["zones"], key=lambda z: m.spawns.zones.get(z, {}).get("levels") or [0])],
"special": v["special"]}
for b, v in sorted(locs.items(), key=lambda kv: (kv[1]["special"], m.biome_name(kv[0])))],
"spawns": [s["spawn"] for s in wild],
"sources": sorted(other, key=lambda s: -RANK[s["status"]]),
"evolutions": [{"to": ev["to"], "name": m.species_name(ev["to"]), "how": ev["how"], "form": ev["form"]}
for ev in m.evo_from.get(sid, [])],
"pre": sp["pre"], "drops": _species_drops(sp.get("drops")),
"pokedex_keys": sp["pokedex"] + [k for f in sp["forms"] for k in f["pokedex"] if not f["battle_only"]],
"spawn_biomes_elsewhere": sorted({n for i in m.spawn_by_species.get(sid, [])
for n in spawns[i]["all_biomes"]})[:20],
"why_not_wild": sorted({w for i in m.spawn_by_species.get(sid, []) for w in spawns[i]["why_not"]})[:6]
if wild_status == "unavailable" else [],
}
items, vanilla = {}, {}
mentioned = set()
for iid in m.mod_items:
srcs = [_source(m, s) for s in _useful_sources(m, iid)]
uses = _dedupe(m.uses.get(iid, []))
for s in srcs:
mentioned.update(s.get("mention", []))
items[iid] = {
"name": m.item_name(iid), "mod": split_id(iid)[0], "category": _category(m, iid),
"tooltip": m.tooltip(iid), "status": st(f"item:{iid}"), "technical": iid in TECHNICAL,
"sources": sorted(srcs, key=lambda s: (-RANK[s["status"]], s["kind"])), "uses": uses,
"icon": icons.get(iid), "tags": sorted(t for t in m.itags.all_tags() if iid in m.itags.resolve(t))[:12],
"reason": _reason(m, iid, srcs),
}
recipes = {}
for rid, r in m.recipes.items():
if r["result"] in items or any(i in items for s in r["slots"] for i in s["items"]):
recipes[rid] = {k: r[k] for k in ("type", "label", "result", "count", "grid", "slots", "script") if k in r}
recipes[rid]["status"] = _recipe_status(m, rid)
mentioned.add(r["result"])
for s in r["slots"]:
mentioned.update(s["items"][:8])
for iid in sorted(mentioned):
if iid not in items and iid in m.items:
vsrcs = [_source(m, s) for s in _useful_sources(m, iid)]
vanilla[iid] = {"name": m.item_name(iid), "status": st(f"item:{iid}"), "icon": icons.get(iid),
"sources": sorted(vsrcs, key=lambda s: -RANK[s["status"]])[:12], "reason": _reason(m, iid, vsrcs)}
missing_vanilla = sorted(i for i in m.vanilla_items if st(f"item:{i}") == "unavailable")
spawner = m.stack.get("spawning", "cobblemon:best-spawner-config") or {}
return {
"meta": {
"schema": 1, "generated": datetime.datetime.now().astimezone().isoformat(timespec="seconds"),
"layers": [layer.name for layer in m.stack.layers], "world_type": isl.world_type,
"statuses": {"available": "obtainable on the island", "conditional": "needs something that may not "
"exist (the End, a stronghold inside the border)", "unavailable": "no source on the island"},
"buckets": spawner.get("worldBuckets", {}),
"kubejs_export": m.kubejs and {k: m.kubejs[k] for k in ("exported", "kubejs", "stale", "removed")}
| {"added_or_changed": sorted(m.kubejs["recipes"])},
},
"island": {
"dimensions": isl.dimensions,
"biomes": [{"id": b, "name": m.biome_name(b), **{k: v for k, v in info.items()}} for b, info in isl.biomes.items()],
"structures": [{"id": s, "name": m.structure_name(s), "status": v["status"], "reason": v["reason"],
"biomes": v["biomes"]} for s, v in sorted(isl.structures.items())],
"notes": isl.notes,
},
"locations": locations, "spawns": spawns, "species": species, "items": items, "vanilla": vanilla,
"hidden_spawns": [_spawn(m, i, e, "hidden_spawn", real_biomes) for i, e in enumerate(m.hidden_spawns.entries)],
"recipes": recipes,
"problems": {
**{k: sorted(v) for k, v in m.unresolved.items()},
"vanilla items with no source on the island": missing_vanilla,
"unknown tags": sorted(set().union(*(t.missing for t in m.tags.values()))),
"unreadable files": m.stack.errors,
"spawn pools skipped": [f"{a} ({b})" for a, b in m.spawns.skipped],
"zones in spawn conditions but not in world/zones.json": sorted(m.spawns.unknown_zones | m.hidden_spawns.unknown_zones),
},
}
def _spawn(m, i, e, prefix, real_biomes):
isl = m.island
status = m.get_status(f"{prefix}:{i}")
srcs = m.sources.get(f"{prefix}:{i}", [])
conds = []
for c, src in zip(e["conditions"], srcs):
conds.append({"text": c["text"], "biomes": c["biomes"], "status": src.get("status"),
"structures": c["structures"]["present"], "blocked": _names(m, src.get("blocked_by"))})
return {
"i": i, "id": e["id"], "pool": e["pool"], "type": e["type"], "bucket": e["bucket"], "weight": e["weight"],
"level": e["level"], "position": e["position"], "max_herd": e["max_herd"],
"members": [{"species": mm["species"], "name": m.species_name(mm["species"]), "props": mm["props"],
"level": mm["level"], "role": mm["role"], "weight": mm.get("weight")} for mm in e["members"]],
"conditions": conds, "anticonditions": [a["text"] for a in e["anticonditions"] if a["text"]],
"multipliers": e["multipliers"], "status": status, "biomes": e["biomes"], "zones": e["zones"],
# every alternative needs particular blocks nearby (often player-built: concrete, pyramid blocks)
"needs_blocks": all(any(t.startswith("near ") and "leaves" not in t for t in c["text"])
for c in e["conditions"]),
"all_biomes": sorted({m.biome_name(b) for c in e["conditions"] for b in c.get("all_ids", [])
if b in real_biomes and b not in isl.biomes}),
"why_not": _why_not(e, srcs, isl, m, real_biomes) if status == "unavailable" else [],
"drops": e.get("drops"),
}
def _why_not(e, srcs, isl, m, real_biomes):
"""Short reasons a spawn entry cannot happen on the island."""
out = []
for c, src in zip(e["conditions"], srcs):
if c["structures"]["refs"] and not c["structures"]["present"]:
out.append("needs a structure that does not generate here: "
+ ", ".join(r.lstrip("#").split(":")[-1].replace("_", " ") for r in c["structures"]["refs"]))
elif not c["biomes"]:
names = sorted({m.biome_name(b) for b in c.get("all_ids", []) if b in real_biomes})
out.append("biomes not on the island: " + ", ".join(names[:5]) + (" …" if len(names) > 5 else "")
if names else "no biome on the island")
elif src.get("blocked_by"):
out.append("needs " + "; ".join(_names(m, src["blocked_by"])))
return out
def _names(m, groups):
out = []
for g in groups or []:
if not g:
out.append("an ingredient no item matches (empty tag)")
continue
names = []
for k in g[:4]:
kind, rid = k.split(":", 1)
names.append({"item": m.item_name, "block": m.item_name, "mob": m.entity_name,
"species": m.species_name}.get(kind, lambda x: x)(rid) if kind != "spawn" else "a wild spawn")
out.append(" or ".join(dict.fromkeys(names)) + (" …" if len(g) > 4 else ""))
return out
def _source(m, s):
out = {"kind": s["kind"], "text": s["text"], "status": s.get("status", "unavailable")}
for k in ("chance", "recipe", "places", "biomes", "species", "block", "mob", "table", "fossils", "parents"):
if s.get(k) is not None:
out[k] = s[k]
if s.get("blocked_by"):
out["blocked"] = _names(m, s["blocked_by"])
mention = [k.split(":", 1)[1] for g in s.get("needs", []) for k in g[:3] if k.startswith("item:")]
if mention:
out["mention"] = mention
if out.get("biomes"):
out["biome_names"] = [m.biome_name(b) for b in out["biomes"]]
return out
def _reason(m, iid, srcs):
"""One line on why an item has no source on the island (empty if it has one)."""
if m.get_status(f"item:{iid}") != "unavailable":
return ""
if iid in m.island.removed_blocks:
return "ore: its world generation is switched off on this island"
blocked = [s for s in srcs if s.get("blocked")]
if blocked:
return "needs " + blocked[0]["blocked"][0]
if srcs:
text = srcs[0]["text"]
return text if text.endswith(")") else text + " (not on the island)"
raw = m.sources.get(f"item:{iid}", [])
if raw:
return "only from its own block, which never generates here"
return "no recipe, loot table, drop or worldgen feature in the data"
def _useful_sources(m, iid):
"""Drop circular 'break the placed block' sources and duplicates."""
out, seen = [], set()
for s in m.sources.get(f"item:{iid}", []):
if s["kind"] == "block" and s.get("block") == iid and m.island.natural_status(iid)[0] == "unavailable":
continue
key = (s["kind"], s["text"], s.get("recipe"), s.get("chance"))
if key in seen:
continue
seen.add(key)
out.append(s)
return out
def _dedupe(uses):
out, seen = [], set()
for u in uses:
key = (u["kind"], u["text"])
if key not in seen:
seen.add(key)
out.append(u)
return out
def _category(m, iid):
for name, tags in CATEGORIES:
if any(iid in m.itags.resolve(t) for t in tags):
return name
if iid in m.blocks:
return "Blocks"
return "Other"
def _recipe_status(m, rid):
for s in m.sources.get(f"item:{m.recipes[rid]['result']}", []):
if s.get("recipe") == rid:
return s["status"]
return "unavailable"
def _species_drops(drops):
out = []
for e in (drops or {}).get("entries", []):
chance = f"{e['percentage']:g}%" if "percentage" in e else f"×{e.get('quantityRange', e.get('quantity', 1))}"
out.append({"item": e["item"] if ":" in e["item"] else "minecraft:" + e["item"], "chance": chance})
return out
def write(data, path):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False, indent=1, default=_default) + "\n")
def _default(o):
if isinstance(o, set):
return sorted(o)
raise TypeError(type(o))
+144
View File
@@ -0,0 +1,144 @@
"""Item icons rendered from the jars' item models into build/field-guide/icons/.
Flat items composite their layer textures; block items get a small isometric
cube from their block model's faces; cross-shaped plants use their texture.
The PNGs are third-party assets (Cobblemon's assets are not MPL-licensed), so
they only ever go to build/ and are never committed.
"""
import io
from PIL import Image, ImageEnhance
from .datasource import split_id
S = 16
FACES = { # texture keys tried for each visible face of a cube
"top": ("up", "top", "end", "all", "texture", "side", "particle"),
"left": ("south", "front", "side", "all", "texture", "north", "particle"),
"right": ("east", "side", "all", "texture", "west", "particle"),
}
FLAT_PARENTS = ("item/generated", "item/handheld", "item/handheld_rod", "builtin/generated")
CROSS_PARENTS = ("block/cross", "block/tinted_cross", "block/flower_pot_cross", "block/crop")
FOLIAGE = (72, 181, 24)
class Icons:
def __init__(self, stack, out_dir):
self.stack, self.out = stack, out_dir
self.models = {}
self.missing = []
def model(self, mid):
"""Merged model: (parent chain ids, textures, elements)."""
if mid in self.models:
return self.models[mid]
ns, path = split_id(mid)
d = self.stack.json(f"assets/{ns}/models/{path}.json") or {}
chain, textures, elements = [f"{ns}:{path}"], {}, d.get("elements")
parent = d.get("parent")
if parent and len(chain) < 20:
pchain, ptex, pel = self.model(parent if ":" in parent else "minecraft:" + parent)
chain += pchain
textures.update(ptex)
elements = elements or pel
textures.update(d.get("textures", {}))
self.models[mid] = (chain, textures, elements)
return self.models[mid]
def texture(self, ref, textures, depth=0):
while isinstance(ref, str) and ref.startswith("#") and depth < 10:
ref, depth = textures.get(ref[1:]), depth + 1
if not isinstance(ref, str):
return None
ns, path = split_id(ref)
raw = self.stack.read(f"assets/{ns}/textures/{path}.png")
if raw is None:
return None
img = Image.open(io.BytesIO(raw)).convert("RGBA")
if img.height > img.width: # animated strip: first frame
img = img.crop((0, 0, img.width, img.width))
return img.resize((S, S), Image.NEAREST) if img.size != (S, S) else img
def render(self, iid):
ns, path = split_id(iid)
chain, tex, elements = self.model(f"{ns}:item/{path}")
if len(chain) == 1 and not tex:
chain, tex, elements = self.model(f"{ns}:block/{path}")
if len(chain) == 1 and not tex: # no model at all: KubeJS items get item/generated with their own texture
chain, tex = [f"{ns}:item/{path}", "minecraft:item/generated"], {"layer0": f"{ns}:item/{path}"}
names = [c.split(":", 1)[1] for c in chain]
if any(n in FLAT_PARENTS for n in names) or any(k.startswith("layer") for k in tex):
layers = sorted((k for k in tex if k.startswith("layer")), key=lambda k: int(k[5:] or 0))
img = None
for k in layers:
t = self.texture(tex[k], tex)
if t is not None:
img = t if img is None else Image.alpha_composite(img, t)
return img.resize((32, 32), Image.NEAREST) if img else None
if any(n in CROSS_PARENTS for n in names):
t = self.texture(tex.get("cross") or tex.get("crop") or tex.get("plant") or tex.get("particle"), tex)
return t.resize((32, 32), Image.NEAREST) if t else None
faces = {}
for face, keys in FACES.items():
for k in keys:
if k in tex:
faces[face] = self.texture(tex[k], tex)
if faces[face] is not None:
break
if not any(v is not None for v in faces.values()):
return None
tinted = any(n in ("block/leaves",) for n in names) or path.endswith("leaves")
return iso_cube(faces, tinted)
def write_all(self, ids):
out = {}
for iid in sorted(ids):
try:
img = self.render(iid)
except (OSError, ValueError) as e:
self.missing.append(f"{iid}: {e}")
img = None
if img is None:
self.missing.append(iid)
continue
ns, path = split_id(iid)
rel = f"icons/{ns}/{path}.png"
dst = self.out / rel
dst.parent.mkdir(parents=True, exist_ok=True)
img.save(dst)
out[iid] = rel
return out
def _tint(img, rgb):
r, g, b, a = img.split()
grey = img.convert("L")
return Image.merge("RGBA", [grey.point(lambda v, c=c: v * c // 255) for c in rgb] + [a])
def iso_cube(faces, tinted=False):
"""32x32 isometric block icon from top/left/right 16x16 face textures."""
canvas = Image.new("RGBA", (32, 32), (0, 0, 0, 0))
geometry = { # origin, u axis, v axis (output pixels per texture pixel)
"top": ((16, 0), (1, 0.5), (-1, 0.5)),
"left": ((0, 8), (1, 0.5), (0, 1)),
"right": ((16, 16), (1, -0.5), (0, 1)),
}
shade = {"top": 1.0, "left": 0.8, "right": 0.62}
for face in ("top", "left", "right"):
tex = faces.get(face) or faces.get("top") or next(v for v in faces.values() if v is not None)
if tinted:
tex = _tint(tex, FOLIAGE)
(ox, oy), (ux, uy), (vx, vy) = geometry[face]
det = ux * vy - uy * vx
# inverse affine: texture coords from output coords
a, b = vy / det, -vx / det
d, e = -uy / det, ux / det
c, f = -(a * ox + b * oy), -(d * ox + e * oy)
layer = tex.transform((32, 32), Image.AFFINE, (a, b, c, d, e, f), resample=Image.NEAREST,
fillcolor=(0, 0, 0, 0))
if shade[face] != 1.0:
rgb = ImageEnhance.Brightness(layer.convert("RGB")).enhance(shade[face])
layer = Image.merge("RGBA", (*rgb.split(), layer.split()[3]))
canvas = Image.alpha_composite(canvas, layer)
return canvas
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""The pack's own recipes, as KubeJS really applies them: read from the running test server.
pack/kubejs/server_scripts are code (event.remove with filters, event.shaped, replaceInput, ...),
so the guide does not parse them. Instead this boots the test server (build/server) once on a
throwaway void world and runs `/kubejs export debug` (KubeJS 2101), which reloads the data and
writes local/kubejs/export/:
recipes/<ns>/<path>.json every recipe after the scripts ran; the ones a script added or
changed carry "_kubejs_changed_marker": {source, line}
removed_recipes/<ns>/<path>.json every recipe a script removed (from mod jars and data packs alike)
registries/item.json the item registry, including items KubeJS startup scripts register
The changed recipes, the removed ids and the item ids are copied to build/fieldguide-cache/kubejs-export/
with a hash of the inputs (pack/kubejs scripts and data, pack/mods, datapacks/core).
tools/fieldguide/build.py runs this by itself when that hash no longer matches the pack.
Usage: tools/fieldguide/kubejs_export.py [--accept-eula] (run tools/build.py first)
"""
import argparse
import hashlib
import json
import os
import shutil
import sys
import time
from pathlib import Path
sys.dont_write_bytecode = True
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import mcserver # noqa: E402
ROOT = mcserver.ROOT
SERVER = mcserver.SERVER
EXPORT = SERVER / "local/kubejs/export"
CACHE = ROOT / "build/fieldguide-cache/kubejs-export"
LEVEL = "fieldguide-export"
MARKER = "_kubejs_changed_marker"
# Inputs that decide the recipe set KubeJS ends up with. Assets (textures, lang) do not.
INPUTS = [ROOT / "pack/kubejs/server_scripts", ROOT / "pack/kubejs/startup_scripts", ROOT / "pack/kubejs/data",
ROOT / "pack/mods", ROOT / "datapacks/core"]
def input_hash():
h = hashlib.sha256()
for base in INPUTS:
for p in sorted(base.rglob("*")) if base.exists() else []:
if p.is_file():
h.update(p.relative_to(ROOT).as_posix().encode() + b"\0" + p.read_bytes() + b"\0")
return h.hexdigest()
def load():
"""The cached export, or None: {"recipes": {id: json}, "removed": [id], "items": [id], "stale": bool, ...}."""
path = CACHE / "export.json"
if not path.exists():
return None
data = json.loads(path.read_text())
data["stale"] = data.pop("inputs") != input_hash()
return data
def server_busy():
"""True if some process is already running in build/server (another tool's test server)."""
for proc in Path("/proc").glob("[0-9]*"):
try:
if Path(os.readlink(proc / "cwd")) == SERVER.resolve():
return True
except OSError:
continue
return False
def check_scripts():
"""The server runs build/stage/full's copy of pack/kubejs; refuse to export an older one."""
differ = []
for sub in ("server_scripts", "startup_scripts", "data"):
base = ROOT / "pack/kubejs" / sub
for p in sorted(base.rglob("*")) if base.exists() else []:
if p.is_file():
rel = p.relative_to(ROOT / "pack")
served = SERVER / rel
if not served.is_file() or served.read_bytes() != p.read_bytes():
differ.append(rel.as_posix())
if differ:
raise SystemExit("build/server has an older copy of " + ", ".join(differ) + "; run tools/build.py first")
def export(log=print):
"""Boot the test server, run the KubeJS export, keep the recipe part in the cache. Returns load()."""
if not mcserver.eula_accepted():
raise SystemExit("build/server/eula.txt does not accept the Minecraft EULA yet "
"(tools/selftest.py --accept-eula writes it if you accept it)")
if server_busy():
raise SystemExit("a test server is already running in build/server; try again when it has stopped")
inputs = input_hash()
args_file = mcserver.prepare()
check_scripts()
shutil.rmtree(SERVER / LEVEL, ignore_errors=True)
shutil.rmtree(EXPORT, ignore_errors=True)
flat = '{"layers":[{"block":"minecraft:bedrock","height":1}],"biome":"minecraft:the_void"}'
props = {"level-name": LEVEL, "level-type": "minecraft:flat", "generator-settings": flat,
"generate-structures": "false", "spawn-monsters": "false", "spawn-animals": "false"}
started = time.time()
with mcserver.Server(args_file, props, stdout_name="fieldguide-export-stdout.log") as server:
log(f"test server started in {server.wait_ready():.0f}s")
reply = server.command("kubejs export debug").strip()
if "exporting" not in reply.lower():
raise SystemExit(f"/kubejs export debug answered {reply!r}")
_wait_for_export(server)
server.stop()
shutil.rmtree(SERVER / LEVEL, ignore_errors=True)
recipes = {}
for p in sorted((EXPORT / "recipes").rglob("*.json")):
d = json.loads(p.read_text())
if MARKER in d:
recipes[_id(p, "recipes")] = d
removed = sorted(_id(p, "removed_recipes") for p in (EXPORT / "removed_recipes").rglob("*.json"))
items = sorted(json.loads((EXPORT / "registries/item.json").read_text()))
kubejs = next((p.name for p in sorted((SERVER / "mods").glob("kubejs-*.jar"))), None)
CACHE.mkdir(parents=True, exist_ok=True)
(CACHE / "export.json").write_text(json.dumps({
"inputs": inputs, "kubejs": kubejs, "exported": time.strftime("%Y-%m-%d %H:%M"),
"removed": removed, "recipes": recipes, "items": items}, indent=1) + "\n")
log(f"KubeJS recipes: {len(recipes)} added or changed, {len(removed)} removed ({time.time() - started:.0f}s)")
return load()
def _id(path, sub):
"""recipes/cobblemon/foo/bar.json -> cobblemon:foo/bar"""
return path.relative_to(EXPORT / sub).as_posix()[:-5].replace("/", ":", 1)
def _wait_for_export(server, timeout=300):
"""The export reloads first and writes the files afterwards; wait for index.json and every file it lists
(the index names them by id, cobblemon:foo.json, and the file is cobblemon/foo.json)."""
deadline = time.time() + timeout
while time.time() < deadline:
try:
listed = json.loads((EXPORT / "index.json").read_text())
if all((EXPORT / f.replace(":", "/")).exists() for f in listed):
return
except (OSError, ValueError):
pass
if server.proc.poll() is not None:
raise SystemExit(f"the test server stopped during the export; see {server.log}")
time.sleep(1)
raise SystemExit(f"/kubejs export debug wrote nothing in {timeout}s; see {server.log}")
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("--accept-eula", action="store_true", help="write eula=true for the test server")
args = ap.parse_args()
if args.accept_eula:
(SERVER / "eula.txt").parent.mkdir(parents=True, exist_ok=True)
(SERVER / "eula.txt").write_text("eula=true\n")
data = export()
for rid in data["removed"]:
print(f" - {rid}")
for rid, d in sorted(data["recipes"].items()):
print(f" + {rid} ({d[MARKER].get('source')}:{d[MARKER].get('line')})")
print(f"Wrote {CACHE / 'export.json'}")
if __name__ == "__main__":
main()
+138
View File
@@ -0,0 +1,138 @@
"""Loot tables flattened to (item, approximate chance, count, notes).
Chances are estimates for a reader (expected rolls x weight share x
random_chance conditions); conditions that are not random become notes.
"""
from .datasource import norm_id
def number(v, default=1.0):
"""A loot number provider -> (min, max)."""
if v is None:
return default, default
if isinstance(v, (int, float)):
return float(v), float(v)
t = v.get("type", "minecraft:uniform")
if "min" in v or "max" in v:
lo, hi = number(v.get("min"), 0)[0], number(v.get("max"), 0)[1]
return lo, hi
if t.endswith("binomial"):
n = number(v.get("n"))[1]
return 0.0, n
if t.endswith("constant"):
return float(v.get("value", 1)), float(v.get("value", 1))
return default, default
def condition_effect(conds):
"""(chance factor, notes) of a list of loot conditions."""
factor, notes = 1.0, []
for c in conds or []:
t = c.get("condition", "")
if t.endswith("random_chance"):
ch = c.get("chance", 1)
factor *= ch if isinstance(ch, (int, float)) else number(ch)[1]
elif t.endswith("random_chance_with_enchanted_bonus"):
ch = c.get("unenchanted_chance", 0)
factor *= ch if isinstance(ch, (int, float)) else 0.05
notes.append("Looting raises it")
elif t.endswith("random_chance_with_looting"):
factor *= c.get("chance", 1)
elif t.endswith("table_bonus"):
factor *= (c.get("chances") or [1])[0]
elif t.endswith("killed_by_player"):
notes.append("player kill")
elif t.endswith("match_tool"):
pred = c.get("predicate", {})
enchant = str(pred.get("predicates", {}).get("minecraft:enchantments", "")) + str(pred.get("enchantments", ""))
if "silk_touch" in enchant:
notes.append("Silk Touch")
elif pred.get("items"):
items = pred["items"] if isinstance(pred["items"], list) else [pred["items"]]
notes.append("with " + ", ".join(i.split(":")[-1].replace("_", " ") for i in items))
else:
notes.append("specific tool")
elif t.endswith("block_state_property"):
props = c.get("properties", {})
if "age" in props or "stage" in props:
notes.append("when fully grown" if str(props.get("age", props.get("stage"))) not in ("0",) else "when young")
elif t.endswith("inverted"):
_, inner_n = condition_effect([c.get("term", {})])
if "Silk Touch" in inner_n:
notes.append("without Silk Touch")
elif t.endswith("entity_properties") and "on_fire" in str(c):
notes.append("cooked if on fire")
elif t.endswith("any_of") or t.endswith("alternative"):
pass
return factor, notes
class Loot:
def __init__(self, stack, item_tags):
self.stack, self.item_tags = stack, item_tags
self.ids = stack.ids("loot_table")
self.cache = {}
def table(self, tid):
return self.stack.json(self.ids[tid]) if tid in self.ids else None
def flatten(self, tid, _depth=0):
"""[{item, p, count:[lo,hi], notes}] for a loot table id or inline table."""
if isinstance(tid, str):
tid = norm_id(tid)
if tid in self.cache:
return self.cache[tid]
data = self.table(tid)
else:
data = tid
out = []
if data and _depth < 8:
for pool in data.get("pools", []):
lo, hi = number(pool.get("rolls", 1))
rolls = (lo + hi) / 2 # bonus_rolls only apply with Luck
pf, pnotes = condition_effect(pool.get("conditions"))
entries = list(self._entries(pool.get("entries", [])))
total = sum(w for _, w in entries) or 1
for e, w in entries:
ef, enotes = condition_effect(e.get("conditions"))
share = rolls * pf * ef * w / total
for d in self._entry_items(e, _depth):
out.append({"item": d["item"], "p": min(1.0, share * d["p"]),
"count": d["count"], "notes": sorted(set(pnotes + enotes + d["notes"]))})
if isinstance(tid, str):
self.cache[tid] = out
return out
def _entries(self, entries):
"""(entry, weight) pairs; composite entries are opened up."""
for e in entries:
t = e.get("type", "")
if t.endswith(("alternatives", "group", "sequence")):
for child, w in self._entries(e.get("children", [])):
yield child, w
else:
yield e, e.get("weight", 1)
def _entry_items(self, e, depth):
t = e.get("type", "")
lo, hi = 1.0, 1.0
notes = []
for f in e.get("functions", []):
fn = f.get("function", "")
if fn.endswith("set_count"):
lo, hi = number(f.get("count"))
elif fn.endswith(("furnace_smelt",)):
notes.append("cooked if on fire")
count = [lo, hi]
if t.endswith(":item"):
return [{"item": norm_id(e["name"]), "p": 1.0, "count": count, "notes": notes}]
if t.endswith(":tag"):
items = sorted(self.item_tags.resolve("#" + e["name"]))
if e.get("expand"):
return [{"item": i, "p": 1.0 / max(1, len(items)), "count": count, "notes": notes} for i in items]
return [{"item": i, "p": 1.0, "count": count, "notes": notes + ["all items of a tag"]} for i in items]
if t.endswith("loot_table"):
ref = e.get("value", e.get("name"))
nested = self.flatten(ref, depth + 1)
return [dict(d, notes=d["notes"] + notes) for d in nested]
return []
+21
View File
@@ -0,0 +1,21 @@
{
"_comment": "Item sources that Minecraft decides in code, so no data file shows them. Each entry: item, how (shown in the guide), needs (list of groups; each group is satisfied by any one of its keys: item:<id>, mob:<id>, block:<id>). Keep this list short and add only well-known mechanics; Phase 4 sources (ore veins, KubeJS recipes) go in an --extra file instead (see docs/field-guide.md).",
"sources": [
{"item": "minecraft:emerald", "how": "Trade with villagers", "needs": [["mob:minecraft:villager"]]},
{"item": "minecraft:milk_bucket", "how": "Milk a cow or goat with a bucket", "needs": [["mob:minecraft:cow", "mob:minecraft:goat", "mob:minecraft:mooshroom"], ["item:minecraft:bucket"]]},
{"item": "minecraft:egg", "how": "Laid by chickens", "needs": [["mob:minecraft:chicken"]]},
{"item": "minecraft:white_wool", "how": "Shear a sheep", "needs": [["mob:minecraft:sheep"], ["item:minecraft:shears"]]},
{"item": "minecraft:honeycomb", "how": "Shear a full bee nest", "needs": [["block:minecraft:bee_nest", "block:minecraft:beehive"], ["item:minecraft:shears"]]},
{"item": "minecraft:honey_bottle", "how": "Use a glass bottle on a full bee nest", "needs": [["block:minecraft:bee_nest", "block:minecraft:beehive"], ["item:minecraft:glass_bottle"]]},
{"item": "minecraft:turtle_scute", "how": "Dropped when a baby turtle grows up", "needs": [["mob:minecraft:turtle"]]},
{"item": "minecraft:water_bucket", "how": "Fill a bucket with water", "needs": [["item:minecraft:bucket"]]},
{"item": "minecraft:lava_bucket", "how": "Fill a bucket with lava", "needs": [["item:minecraft:bucket"], ["block:minecraft:lava"]]},
{"item": "minecraft:obsidian", "how": "Pour water on a lava source", "needs": [["item:minecraft:water_bucket"], ["block:minecraft:lava"]]},
{"item": "minecraft:dragon_breath", "how": "Collect the Ender Dragon's breath with a glass bottle", "needs": [["mob:minecraft:ender_dragon"], ["item:minecraft:glass_bottle"]]},
{"item": "minecraft:dragon_egg", "how": "Left by the first Ender Dragon", "needs": [["mob:minecraft:ender_dragon"]]},
{"item": "minecraft:pumpkin_seeds", "how": "Craft from a pumpkin", "needs": [["item:minecraft:pumpkin"]]},
{"item": "minecraft:mud", "how": "Use a water bottle on dirt", "needs": [["item:minecraft:glass_bottle"]]},
{"item": "minecraft:ominous_bottle", "how": "Dropped by raid captains", "needs": [["mob:minecraft:pillager"]]}
],
"concrete_from_powder": true
}
+793
View File
@@ -0,0 +1,793 @@
"""The field guide model: items, recipes, loot, Pokémon and what is obtainable on the island.
Every way to get something is a *source* with a base status (from the island
model) and *needs*: groups of node keys (item:, block:, mob:, species:,
spawn:) of which at least one per group must be obtainable. A fixpoint over
all nodes then decides what the island can provide, so an item whose only
recipe needs Nether quartz is flagged even though the recipe exists.
"""
import json
from collections import defaultdict
from .datasource import BUILD, norm_id, split_id
from .loot import Loot
from .spawns import Spawns, parse_props
from .tags import Tags
from .world import CODE_LOOT, RANK, Island
STATUS = {v: k for k, v in RANK.items()}
REGISTRIES = ("worldgen/biome", "item", "block", "worldgen/structure", "worldgen/placed_feature", "entity_type", "fluid")
# Recipe types -> the station item that must exist (crafting tables and furnaces are always reachable).
STATIONS = {
"minecraft:blasting": "minecraft:blast_furnace", "minecraft:smoking": "minecraft:smoker",
"minecraft:campfire_cooking": "minecraft:campfire", "minecraft:stonecutting": "minecraft:stonecutter",
"minecraft:smithing_transform": "minecraft:smithing_table", "minecraft:smithing_trim": "minecraft:smithing_table",
"cobblemon:cooking_pot": "#cobblemon:campfire_pots", "cobblemon:cooking_pot_shapeless": "#cobblemon:campfire_pots",
"cobblemon:brewing_stand": "minecraft:brewing_stand",
}
RECIPE_LABEL = {
"crafting_shaped": "Crafting", "crafting_shapeless": "Crafting (shapeless)", "smelting": "Smelting",
"blasting": "Blast furnace", "smoking": "Smoker", "campfire_cooking": "Campfire", "stonecutting": "Stonecutter",
"smithing_transform": "Smithing", "cooking_pot": "Cooking Pot", "cooking_pot_shapeless": "Cooking Pot (shapeless)",
"brewing_stand": "Brewing stand",
}
# KubeJS's own crafting types (used when a recipe needs KubeJS features) read like vanilla's.
KUBEJS_TYPES = {"kubejs:shaped": "minecraft:crafting_shaped", "kubejs:shapeless": "minecraft:crafting_shapeless"}
# Creative-only or operator items: listed, but never flagged as "no source".
TECHNICAL = {"cobblemon:pokemon_model", "cobblemon:npc_editor", "cobblemon:habitat_block",
"ftblibrary:fluid_container", "ftblibrary:icon_item", "ftbquests:barrier", "ftbquests:stage_barrier", "ftbquests:detector",
"ftbquests:custom_icon", "ftbquests:missing_item", "ftbquests:lootcrate", "ftbquests:banner",
"ftbquests:loot_crate_opener", "ftbquests:screen_1", "ftbquests:screen_3", "ftbquests:screen_5",
"ftbquests:screen_7", "ftbquests:task_screen_configurator", "ftbquests:progress_detector"}
CATEGORIES = [ # first match wins
("Poké Balls", ["#cobblemon:poke_balls"]), ("Poké Rods", ["#cobblemon:poke_rods"]),
("Evolution items", ["#cobblemon:evolution_items", "#cobblemon:evolution_stones"]),
("Fossils", ["#cobblemon:fossils"]), ("Berries", ["#cobblemon:berries"]),
("Apricorns", ["#cobblemon:apricorns", "#cobblemon:apricorn_sprouts"]), ("Mints", ["#cobblemon:mints", "#cobblemon:mint_leaves", "#cobblemon:mint_seeds"]),
("Medicine", ["#cobblemon:potions", "#cobblemon:remedies", "#cobblemon:revives", "#cobblemon:ethers",
"#cobblemon:full_heal_bottles", "#cobblemon:restores"]),
("Vitamins & candy", ["#cobblemon:vitamins", "#cobblemon:candies", "#cobblemon:experience_candies",
"#cobblemon:iv_candies", "#cobblemon:mochis", "#cobblemon:feathers", "#cobblemon:ability_changers"]),
("Held items", ["#cobblemon:held/is_held_item"]), ("Type gems", ["#cobblemon:type_gems"]),
("Food & cooking", ["#cobblemon:poke_food", "#cobblemon:regional_delicacies", "#cobblemon:sweets",
"#cobblemon:aprijuices", "#cobblemon:teacups", "#cobblemon:herbs", "#cobblemon:seeds"]),
("Machines", ["#cobblemon:machines", "#cobblemon:fossil_machine_parts", "#cobblemon:pokedex"]),
]
class Model:
def __init__(self, stack, log=print, extras=(), kubejs=None):
self.stack, self.log, self.kubejs = stack, log, kubejs
self.tags = {r: Tags(stack, r) for r in REGISTRIES}
self.itags = self.tags["item"]
log("island: biomes, structures, natural blocks, mobs")
self.island = Island(stack, self.tags, log)
log("spawns")
self.spawns = Spawns(stack, self.tags, self.island)
# Spawn pools a pack.mcmeta filter hides (Cobblemon's own, replaced by the zone pools): not in the game,
# but resolved the same way for tools that plan from them (tools/zone_spawns.py).
self.hidden_spawns = Spawns(stack, self.tags, self.island, pools=stack.hidden("spawn_pool_world"))
self.loot = Loot(stack, self.itags)
self.lang = self._lang()
self.sources = defaultdict(list) # node key -> [source]
self.uses = defaultdict(list) # item -> [use]
self.recipes = {}
self.pack_recipes = set() # recipe ids added or changed by the pack's KubeJS scripts
self.referenced = set()
self.unresolved = defaultdict(set)
self._registry_vanilla()
log("species")
self._species()
log("recipes")
self._recipes()
self._kubejs(kubejs)
log("loot tables")
self._loot_sources()
self._drops()
self._berries()
self._fossils()
self._interactions()
self._manual()
for extra in extras:
self._extra(extra)
self._registry_mods()
self._world_nodes()
self._spawn_nodes()
self._evolutions()
self._uses()
log("solving availability")
self._solve()
# --- names --------------------------------------------------------------------------------
def _lang(self):
lang = {}
for rel, idxs in self.stack.index.items():
if rel.endswith("/lang/en_us.json") and rel.startswith("assets/"):
for d in self.stack.all_json(rel):
lang.update(d)
return lang
def item_name(self, iid):
ns, path = split_id(iid)
for key in (f"item.{ns}.{path}", f"block.{ns}.{path}"):
if key in self.lang:
return self.lang[key]
return path.replace("_", " ").title()
def biome_name(self, b):
ns, path = split_id(b)
return self.lang.get(f"biome.{ns}.{path}", path.replace("_", " ").title())
def entity_name(self, e):
ns, path = split_id(e)
return self.lang.get(f"entity.{ns}.{path}", path.replace("_", " ").title())
def structure_name(self, s):
ns, path = split_id(s)
return path.split("/")[-1].replace("_", " ").title() + ("" if ns == "minecraft" else f" ({ns})")
def tooltip(self, iid):
ns, path = split_id(iid)
lines = []
for suffix in (".tooltip", ".tooltip_1", ".tooltip_2", ".desc"):
for kind in ("item", "block"):
if f"{kind}.{ns}.{path}{suffix}" in self.lang:
lines.append(self.lang[f"{kind}.{ns}.{path}{suffix}"])
return lines
# --- registries ---------------------------------------------------------------------------
def _registry_vanilla(self):
rep = BUILD / ".cache/datagen-1.21.1/reports/registries.json"
if rep.exists():
reg = json.loads(rep.read_text())
self.vanilla_items = set(reg["minecraft:item"]["entries"])
self.vanilla_blocks = set(reg["minecraft:block"]["entries"])
self.vanilla_entities = set(reg["minecraft:entity_type"]["entries"])
else:
self.log(f"note: {rep} missing; vanilla registries guessed from the language file")
self.vanilla_items = {"minecraft:" + k.split(".", 2)[2] for k in self.lang
if k.startswith(("item.minecraft.", "block.minecraft.")) and k.count(".") == 2}
self.vanilla_blocks = {"minecraft:" + k.split(".", 2)[2] for k in self.lang
if k.startswith("block.minecraft.") and k.count(".") == 2}
self.vanilla_entities = set()
self.blocks = set(self.vanilla_blocks) | {k for k in self.stack.ids("blockstates", root="assets")
if not k.startswith("minecraft:")}
def _registry_mods(self):
"""Mod items: item models that have a name in the language file or are used as an item in data."""
models = {k for k in self.stack.ids("models/item", root="assets") if not k.startswith("minecraft:")}
named = set()
for k in self.lang:
parts = k.split(".")
if len(parts) == 3 and parts[0] in ("item", "block") and parts[1] != "minecraft":
named.add(f"{parts[1]}:{parts[2]}")
mods = {m for m in models if m in named or m in self.referenced}
# Items a KubeJS startup script registers have no model in any jar (KubeJS makes it at runtime);
# the server's item registry from the KubeJS export has them.
mods |= {i for i in (self.kubejs or {}).get("items", []) if not i.startswith("minecraft:")}
self.mod_items = sorted(mods)
self.items = self.vanilla_items | set(self.mod_items)
missing = {r for r in self.referenced if r not in self.items and not r.startswith("#")
and split_id(r)[0] in self.stack.modids}
for r in sorted(missing):
self.unresolved["item ids used in data but not registered (mod not installed?)"].add(r)
# --- species --------------------------------------------------------------------------------
def _species(self):
self.species = {}
for sid, rel in self.stack.ids("species").items():
d = self.stack.json(rel) or {}
ns, path = split_id(sid)
key = f"{ns}:{path.split('/')[-1]}"
forms = d.get("forms", [])
self.species[key] = {
"id": key, "name": self.lang.get(f"{ns}.species.{key.split(':')[1]}.name", d.get("name", key)),
"dex": d.get("nationalPokedexNumber", 0), "types": [t for t in (d.get("primaryType"), d.get("secondaryType")) if t],
"implemented": d.get("implemented", False) is True, "labels": d.get("labels", []),
"pre": norm_id("cobblemon:" + d["preEvolution"].split()[0]) if d.get("preEvolution") else None,
"evolutions": [dict(e, _form=None) for e in d.get("evolutions", [])] +
[dict(e, _form=f.get("name")) for f in forms for e in (f.get("evolutions") or [])],
"drops": d.get("drops"), "pokedex": d.get("pokedex", []),
"forms": [{"name": f.get("name"), "aspects": f.get("aspects", []), "pokedex": f.get("pokedex", []),
"battle_only": f.get("battleOnly", False)} for f in forms],
}
for e in (d.get("drops") or {}).get("entries", []):
self.referenced.add(norm_id(e["item"]))
def species_name(self, sid):
return self.species.get(sid, {}).get("name") or sid.split(":")[-1].title()
# --- recipes --------------------------------------------------------------------------------
def _conditions_ok(self, d):
conds = d.get("neoforge:conditions")
return all(self._cond(c) for c in conds) if conds else True
def _cond(self, c):
t = c.get("type", "")
if t == "neoforge:mod_loaded":
return c.get("modid") in self.stack.modids
if t == "neoforge:not":
return not self._cond(c.get("value", {}))
if t == "neoforge:and":
return all(self._cond(v) for v in c.get("values", []))
if t == "neoforge:or":
return any(self._cond(v) for v in c.get("values", []))
if t == "neoforge:false":
return False
if t == "neoforge:tag_empty":
return not self.itags.resolve("#" + c.get("tag", ""))
if t == "neoforge:item_exists":
return True
return True
def ingredient(self, ing):
"""Ingredient JSON -> {"items": [...], "tag": label or None}."""
if ing is None:
return None
if isinstance(ing, list):
items, tags = [], []
for i in ing:
r = self.ingredient(i)
if r:
items += r["items"]
tags.append(r["tag"])
return {"items": sorted(set(items)), "tag": " / ".join(t for t in tags if t) or None}
if isinstance(ing, str):
ing = {"tag": ing[1:]} if ing.startswith("#") else {"item": ing}
t = ing.get("type", "")
if "item" in ing:
iid = norm_id(ing["item"])
self.referenced.add(iid)
return {"items": [iid], "tag": None}
if "tag" in ing:
items = sorted(self.itags.resolve("#" + ing["tag"]))
if not items:
self.unresolved["empty item tags in recipes"].add("#" + ing["tag"])
return {"items": items, "tag": "#" + norm_id(ing["tag"])}
if t in ("neoforge:compound",):
return self.ingredient(ing.get("children", []))
if t == "neoforge:difference":
base, sub = self.ingredient(ing.get("base")), self.ingredient(ing.get("subtracted"))
return {"items": sorted(set(base["items"]) - set(sub["items"] if sub else [])), "tag": base["tag"]}
if t == "neoforge:intersection":
parts = [self.ingredient(c) for c in ing.get("children", [])]
items = set(parts[0]["items"]) if parts else set()
for p in parts[1:]:
items &= set(p["items"])
return {"items": sorted(items), "tag": None}
if "items" in ing:
return self.ingredient(ing["items"])
self.unresolved["unknown ingredient types"].add(t or json.dumps(ing)[:60])
return {"items": [], "tag": None}
def _recipes(self):
for rid, rel in sorted(self.stack.ids("recipe").items()):
d = self.stack.json(rel)
if d and self._conditions_ok(d):
self._recipe(rid, d)
def _recipe(self, rid, d, script=None):
"""One recipe JSON -> self.recipes[rid] and a source for its result. script: the KubeJS script line
that added or changed it."""
rtype = norm_id(d.get("type", ""))
rtype = KUBEJS_TYPES.get(rtype, rtype)
res = d.get("result")
if isinstance(res, str):
res = {"id": res}
if not isinstance(res, dict) or not (res.get("id") or res.get("item")):
return # special recipes (dyeing, map cloning, trims) have no fixed result
out = norm_id(res.get("id") or res.get("item"))
count = res.get("count", 1)
grid, slots, extra = None, [], {}
if rtype.endswith(("crafting_shaped", "cooking_pot")):
keys = {k: self.ingredient(v) for k, v in d.get("key", {}).items()}
pattern = d.get("pattern", [])
grid = [[keys.get(ch) if ch != " " else None for ch in row] for row in pattern]
for row in grid:
slots += [s for s in row if s]
elif rtype.endswith(("crafting_shapeless", "cooking_pot_shapeless")):
slots = [self.ingredient(i) for i in d.get("ingredients", [])]
elif rtype.endswith(("smelting", "blasting", "smoking", "campfire_cooking", "stonecutting")):
slots = [self.ingredient(d.get("ingredient"))]
extra = {"time": d.get("cookingtime")}
elif rtype.endswith("smithing_transform"):
slots = [self.ingredient(d.get(k)) for k in ("template", "base", "addition")]
elif rtype == "cobblemon:brewing_stand":
slots = [self.ingredient(d.get("input")), self.ingredient(d.get("bottle"))]
else:
for k in ("ingredient", "ingredients", "input", "base", "addition", "template", "bottle"):
if k in d:
v = self.ingredient(d[k])
slots += [v] if v else []
if "key" in d:
slots += [self.ingredient(v) for v in d["key"].values()]
self.unresolved["recipe types read generically"].add(rtype)
slots = [s for s in slots if s]
self.referenced.add(out)
label = RECIPE_LABEL.get(rtype.split(":")[1], rtype) + (" (pack recipe)" if script else "")
recipe = {"id": rid, "type": rtype, "label": label, "result": out, "count": count, "grid": grid, "slots": slots,
**extra, **({"script": script} if script else {})}
self.recipes[rid] = recipe
needs = [s["items"] and [f"item:{i}" for i in s["items"]] or [] for s in slots]
station = STATIONS.get(rtype)
if station:
needs.append([f"item:{i}" for i in self.itags.resolve(station)])
self.add(f"item:{out}", "recipe", f"{recipe['label']}", needs=needs, recipe=rid)
def _remove_recipe(self, rid):
r = self.recipes.pop(rid, None)
if r:
key = f"item:{r['result']}"
self.sources[key] = [s for s in self.sources[key] if s.get("recipe") != rid]
return r
def _kubejs(self, export):
"""What the pack's KubeJS scripts did to the recipes, read from the test server (kubejs_export.py)."""
if not export:
self.unresolved["KubeJS recipes not read (no export; see docs/field-guide.md)"].add("pack/kubejs/server_scripts")
return
for rid in export["removed"]:
if not self._remove_recipe(rid):
self.unresolved["recipes KubeJS removed that the data does not have"].add(rid)
for rid, d in sorted(export["recipes"].items()):
self._remove_recipe(rid) # changed in place (replaceInput, ...): the export has the final JSON
marker = d.get("_kubejs_changed_marker", {})
self._recipe(rid, d, script=f"{marker.get('source')}:{marker.get('line')}")
self.pack_recipes.add(rid)
if export["stale"]:
self.unresolved["KubeJS recipe export older than the pack (rerun tools/fieldguide/build.py)"].add(
f"exported {export['exported']}")
# --- loot -------------------------------------------------------------------------------
def _loot_places(self):
"""loot table -> [(place text, status, needs)] for chests, archaeology and features."""
places = defaultdict(list)
for sid, s in self.island.structures.items():
if not RANK[s["status"]] and not s["loot"]:
s["loot"] = self.island.walk(s["data"], start=sid)["loot"] | {
f"minecraft:{p}" for p in CODE_LOOT.get(s["type"], [])}
label = self.structure_name(sid) + ("" if RANK[s["status"]] else " (not on the island)")
for t in s["loot"]:
places[t].append((label, s["status"], sid))
for t, f in self.island.feature_loot.items():
st = self.island.biome_status(f["biomes"])
if RANK[st]:
via = sorted(f["via"])[0]
places[t].append((via.split(":")[-1].split("/")[-1].replace("_", " ").title() + " (worldgen feature)",
st, via))
for tid in list(self.loot.ids):
ns, path = split_id(tid)
if ns == "cobblemon" and path.startswith("injection/"):
target = path[len("injection/"):]
targets = ([f"minecraft:chests/village/village_{v}_house" for v in
("desert", "plains", "savanna", "snowy", "taiga")]
if target == "chests/village_house" else [f"minecraft:{target}"])
for tg in targets:
for p in places.get(tg, []):
places[tid].append((p[0] + " (Cobblemon extra loot)", p[1], p[2]))
return places
def _loot_sources(self):
places = self._loot_places()
alpha_spawns = None
for tid in sorted(self.loot.ids):
ns, path = split_id(tid)
if ns not in self.stack.modids:
continue # data for a mod that is not installed (Cobblemon ships some for Adorn, Botany Pots)
data = self.loot.table(tid) or {}
drops = self.loot.flatten(tid)
if not drops:
continue
for d in drops:
self.referenced.add(d["item"])
ltype = data.get("type", "")
common = {"table": tid}
if path.startswith("blocks/"):
block = f"{ns}:{path[7:]}"
for d in drops:
self.add(f"item:{d['item']}", "block", f"Break {self.item_name(block)}",
needs=[[f"block:{block}"]], block=block, **_chance(d), **common)
elif path.startswith("entities/"):
mob = f"{ns}:{path[9:].split('/')[0]}"
for d in drops:
self.add(f"item:{d['item']}", "mob", f"Drop from {self.entity_name(mob)}",
needs=[[f"mob:{mob}"]], mob=mob, **_chance(d), **common)
elif tid in places:
ps = places[tid]
best = max((p[1] for p in ps), key=RANK.get)
needs = [["item:minecraft:brush"]] if ltype == "minecraft:archaeology" else []
kind = "archaeology" if ltype == "minecraft:archaeology" else "chest"
where = sorted({p[0] for p in ps if p[1] == best})
for d in drops:
self.add(f"item:{d['item']}", kind,
("Brush suspicious blocks in " if kind == "archaeology" else "Loot in ") + _join(where),
base=best, needs=needs, places=where, **_chance(d), **common)
elif path.startswith(("gameplay/fishing", "fishing/pokerod", "injection/gameplay/fishing")):
rod = (["item:" + i for i in self.itags.resolve("#cobblemon:poke_rods")] if path.startswith("fishing/")
else ["item:minecraft:fishing_rod"])
if path.startswith("gameplay/fishing/") and path != "gameplay/fishing":
continue # reached through gameplay/fishing
for d in drops:
self.add(f"item:{d['item']}", "fishing", "Fishing" + (" with a Poké Rod" if path.startswith("fishing/") else ""),
needs=[rod], **_chance(d), **common)
elif path.startswith("alpha/"):
if alpha_spawns is None:
alpha_spawns = self._alpha_spawns()
typ = path.split("/")[2].split("_")[0] if path.startswith("alpha/types/") else None
keys = [f"spawn:{i}" for i, t in alpha_spawns if typ is None or typ in t]
for d in drops:
self.add(f"item:{d['item']}", "alpha", "Defeat a wild Alpha Pokémon" + (f" ({typ} type)" if typ else ""),
needs=[keys], **_chance(d), **common)
elif ns == "minecraft" and path.startswith(("gameplay/", "shearing/")):
mob = {"cat_morning_gift": "cat", "hero_of_the_village": "villager", "piglin_bartering": "piglin",
"sniffer_digging": "sniffer", "panda_sneeze": "panda", "armadillo_shed": "armadillo",
"bogged": "bogged"}
key = next((v for k, v in mob.items() if k in path), None)
if key is None:
self.unresolved["loot tables with no known place"].add(tid)
continue
for d in drops:
self.add(f"item:{d['item']}", "gameplay", path.split("/")[-1].replace("_", " ").capitalize(),
needs=[[f"mob:minecraft:{key}"]], **_chance(d), **common)
elif not self._is_nested(tid):
self.unresolved["loot tables with no known place"].add(tid)
def _is_nested(self, tid):
"""Is this table only used from inside other tables (e.g. cobblemon:sets/...)?"""
if not hasattr(self, "_nested"):
self._nested = {norm_id(s) for other in self.loot.ids for s in _strings(self.loot.table(other) or {})
if ":" in s and norm_id(s) in self.loot.ids and norm_id(s) != other}
return tid in self._nested
def _alpha_spawns(self):
out = []
for i, e in enumerate(self.spawns.entries):
for m in e["members"]:
if any(p == "alpha=true" for p in m["props"]):
out.append((i, self.species.get(m["species"], {}).get("types", [])))
return out
def _drops(self):
"""Pokémon drops: species JSON 'drops', overridden per spawn by a spawn's own 'drops'."""
by_species = defaultdict(list)
override = defaultdict(list)
for i, e in enumerate(self.spawns.entries):
for m in e["members"]:
(override if e.get("drops") else by_species)[m["species"]].append(i)
if e.get("drops"):
for d in _drop_entries(e["drops"]):
self.add(f"item:{d['item']}", "pokemon", f"Defeat wild {self.species_name(m['species'])} (spawn {e['id']})",
needs=[[f"spawn:{i}"]], species=m["species"], chance=d["chance"])
self.referenced.add(d["item"])
for sid, sp in self.species.items():
for d in _drop_entries(sp.get("drops")):
keys = [f"spawn:{i}" for i in by_species.get(sid, [])]
if keys:
self.add(f"item:{d['item']}", "pokemon", f"Defeat wild {sp['name']}",
needs=[keys], species=sid, chance=d["chance"])
else:
self.add(f"item:{d['item']}", "pokemon", f"Defeat wild {sp['name']} (it has no wild spawn)",
base="unavailable", species=sid, chance=d["chance"])
def _berries(self):
groves = self.btags.resolve("#minecraft:is_overworld") # berry groves are added to these biomes
for bid, rel in self.stack.ids("berries").items():
d = self.stack.json(rel) or {}
biomes = set()
for sc in d.get("spawnConditions", []):
v = sc.get("variant", "")
if v.endswith("all_biome"):
biomes |= groves
elif v.endswith("preferred_biome"):
biomes |= self.tags["worldgen/biome"].resolve_all(["#" + t for t in d.get("preferredBiomeTags", [])]) & groves
elif v.endswith("specific_biome"):
biomes |= self.tags["worldgen/biome"].resolve_all(["#" + sc["biome"]]) & groves
here = self.island.island_biomes(biomes)
if biomes:
self.add(f"item:{bid}", "berry", "Wild berry trees in " + _join([self.biome_name(b) for b in here] or ["(no island biome)"]),
base=self.island.biome_status(here), biomes=here)
for partner, result in d.get("mutations", {}).items():
if norm_id(bid) < norm_id(partner): # each pair is listed in both berries
self.add(f"item:{norm_id(result)}", "mutation",
f"Berry mutation: plant {self.item_name(bid)} next to {self.item_name(partner)}",
needs=[[f"item:{bid}"], [f"item:{norm_id(partner)}"]], parents=[bid, norm_id(partner)])
def _fossils(self):
parts = [[f"item:{i}"] for i in sorted(self.itags.resolve("#cobblemon:fossil_machine_parts"))]
for fid, rel in self.stack.ids("fossils").items():
d = self.stack.json(rel) or {}
sp, props = parse_props(d.get("result", ""))
items = [norm_id(f) for f in d.get("fossils", [])]
self.add(f"species:{sp}", "fossil", "Revive from " + " + ".join(self.item_name(i) for i in items),
needs=[[f"item:{i}"] for i in items] + parts, fossils=items)
for i in items:
self.uses[i].append({"kind": "fossil", "text": f"Revives {self.species_name(sp)} (Fossil Analyzer + Restoration Tank)",
"species": sp})
def _interactions(self):
for pid, rel in self.stack.ids("pokemon_interactions").items():
d = self.stack.json(rel) or {}
targets = [parse_props(r.get("target", ""))[0] for r in d.get("requirements", []) if r.get("variant") == "properties"]
for inter in d.get("interactions", []):
held = [r.get("itemCondition") for r in inter.get("requirements", []) if r.get("variant") == "owner_held_item"]
tool = sorted(self.itags.resolve_all(held)) if held else []
for eff in inter.get("effects", []):
if eff.get("variant") not in ("drop_item", "give_item") or not eff.get("item"):
continue
item = norm_id(eff["item"])
self.referenced.add(item)
for sp in targets:
how = f"Use {_join([h.lstrip('#') for h in held]) or 'an item'} on your {self.species_name(sp)}"
self.add(f"item:{item}", "interaction", how,
needs=[[f"species:{sp}"]] + ([[f"item:{t}" for t in tool]] if tool else []), species=sp)
def _manual(self):
from pathlib import Path
cfg = json.loads((Path(__file__).parent / "manual_sources.json").read_text())
for s in cfg["sources"]:
self.add(f"item:{s['item']}", "manual", s["how"], needs=s.get("needs", []))
axes = [f"item:{i}" for i in self.itags.resolve("#minecraft:axes")]
for item in sorted(self.vanilla_items | {k for k in self.stack.ids("models/item", root="assets")}):
ns, path = split_id(item)
if path.startswith("stripped_"):
self.add(f"item:{item}", "manual", "Strip with an axe", needs=[[f"item:{ns}:{path[9:]}"], axes])
if cfg.get("concrete_from_powder"):
for b in sorted(self.vanilla_items):
if b.endswith("_concrete_powder"):
self.add(f"item:{b[:-7]}", "manual", "Place concrete powder in water", needs=[[f"item:{b}"]])
def _extra(self, path):
"""Extra sources from a JSON file (ore veins, trades, ...); KubeJS recipes come from _kubejs."""
d = json.loads(open(path).read())
for s in d.get("sources", []):
self.add(f"item:{norm_id(s['item'])}", s.get("kind", "extra"), s["how"], base=s.get("status", "available"),
needs=s.get("needs", []))
for rid in d.get("remove_recipes", []):
self._remove_recipe(rid)
# --- world nodes: blocks, mobs ---------------------------------------------------------------
def _world_nodes(self):
for blk in self.blocks | set(self.island.natural):
st, biomes = self.island.natural_status(blk)
n = self.island.natural.get(blk)
if n:
where = [self.biome_name(b) for b in biomes] or []
structs = [self.structure_name(v[10:]) for v in n["via"] if v.startswith("structure:")]
self.add(f"block:{blk}", "natural", "Generates naturally", base=st, biomes=biomes, structures=sorted(set(structs)),
where=where)
if blk in self.items:
self.add(f"block:{blk}", "placed", "Placed by a player", needs=[[f"item:{blk}"]])
for mob, entries in self.island.mobs.items():
for e in entries:
where = sorted(e["where"])
text = {"biome": "Spawns in " + _join([self.biome_name(b) for b in where]),
"structure": "Spawns in " + _join([self.structure_name(s) for s in where]),
"special": _join(where).capitalize()}[e["kind"]]
self.add(f"mob:{mob}", e["kind"], text, base=e["status"])
for mob, (item, note) in self.island.item_mobs.items():
self.add(f"mob:{mob}", "item", note.capitalize(), needs=[[f"item:{item}"]])
def _spawn_nodes(self):
self.spawn_by_species = defaultdict(list)
self._spawn_sources("spawn", self.spawns.entries)
self._spawn_sources("hidden_spawn", self.hidden_spawns.entries) # no species source: they never spawn
for i, e in enumerate(self.spawns.entries):
for m in e["members"]:
self.spawn_by_species[m["species"]].append(i)
self.add(f"species:{m['species']}", "wild", f"Wild spawn {e['id']}", needs=[[f"spawn:{i}"]], spawn=i)
for sid, sp in self.species.items():
if "starter" in sp["labels"]:
self.add(f"species:{sid}", "starter", "Starter Pokémon (chosen once per player)")
def _spawn_sources(self, prefix, entries):
rods = [f"item:{i}" for i in self.itags.resolve("#cobblemon:poke_rods")]
for i, e in enumerate(entries):
for c in e["conditions"]:
base = self.island.biome_status(c["biomes"])
if c["structures"]["refs"]:
base = STATUS[min(RANK[base], RANK[c["structures"]["status"] or "unavailable"])]
needs = []
if c["near"]:
needs.append([f"block:{b}" for grp in c["near"] for b in grp])
if c["base"]:
needs.append([f"block:{b}" for grp in c["base"] for b in grp])
if c["poke_snack"]:
needs.append(["item:cobblemon:poke_snack"])
if e["position"] == "fishing":
needs.append(rods)
self.add(f"{prefix}:{i}", "spawn", " · ".join(c["text"]) or "anywhere", base=base, needs=needs)
def _evolutions(self):
self.evo_from = defaultdict(list)
for sid, sp in self.species.items():
for e in sp["evolutions"]:
target, props = parse_props(e.get("result", ""))
if not target:
continue
how, needs, base = [], [[f"species:{sid}"]], "available"
variant = e.get("variant")
ctx = e.get("requiredContext")
if variant == "item_interact" and ctx:
items = sorted(self.itags.resolve(ctx)) if str(ctx).startswith("#") else [norm_id(ctx)]
how.append("use " + _join([self.item_name(i) for i in items]))
needs.append([f"item:{i}" for i in items])
for i in items:
self.referenced.add(i)
self.uses[i].append({"kind": "evolution", "text": f"Evolves {sp['name']} into {self.species_name(target)}",
"species": target, "from": sid})
elif variant == "trade":
how.append("trade" + (f" for {self.species_name(norm_id('cobblemon:' + ctx))}" if ctx else ""))
for r in e.get("requirements", []):
text, rneeds, rbase = self._requirement(r, sp, target)
if text:
how.append(text)
needs += rneeds
base = STATUS[min(RANK[base], RANK[rbase])]
desc = f"Evolve {sp['name']}" + (f" ({e['_form']} form)" if e.get("_form") else "") + (": " + ", ".join(how) if how else "")
self.add(f"species:{target}", "evolution", desc, base=base, needs=needs, species=sid,
props=props, how=how)
self.evo_from[sid].append({"to": target, "how": how, "props": props, "form": e.get("_form")})
def _requirement(self, r, sp, target):
"""Evolution requirement -> (text, needs, base status)."""
v = r.get("variant", "")
if v == "level":
return f"level {r.get('minLevel', '?')}" + (f"–{r['maxLevel']}" if r.get("maxLevel") else ""), [], "available"
if v == "held_item":
cond = r.get("itemCondition", "")
items = sorted(self.itags.resolve(cond)) if str(cond).startswith("#") else [norm_id(cond)]
for i in items:
self.referenced.add(i)
self.uses[i].append({"kind": "evolution", "text": f"Held by {sp['name']} to evolve into {self.species_name(target)}",
"species": target, "from": sp["id"]})
return "holding " + _join([self.item_name(i) for i in items]), [[f"item:{i}" for i in items]], "available"
if v == "friendship":
return f"friendship {r.get('amount')}", [], "available"
if v == "time_range":
return r.get("range", ""), [], "available"
if v == "biome":
biomes = self.tags["worldgen/biome"].resolve(r.get("biomeCondition", "")) if r.get("biomeCondition") else set()
here = self.island.island_biomes(biomes)
text = "in " + (_join([self.biome_name(b) for b in here]) if here else r.get("biomeCondition", "") + " (not on the island)")
return text, [], self.island.biome_status(here) if here else "unavailable"
if v == "structure":
st, which = self.island.structure_status([r.get("structureCondition", "")])
return f"near {r.get('structureCondition', '').lstrip('#')}", [], st
if v == "negate":
inner, _, _ = self._requirement(r.get("inner", {}), sp, target)
return f"not ({inner})", [], "available"
if v == "weather":
return "raining" if r.get("isRaining") else "thunderstorm" if r.get("isThundering") else "clear weather", [], "available"
if v == "moon_phase":
return f"moon {str(r.get('moonPhase', '')).lower().replace('_', ' ')}", [], "available"
if v == "has_move":
return f"knows {r.get('move')}", [], "available"
if v == "has_move_type":
return f"knows a {r.get('type')} move", [], "available"
if v == "properties":
return r.get("target", ""), [], "available"
if v == "party_member":
return f"party has {r.get('target')}", [], "available"
if v == "advancement":
return f"advancement {r.get('requiredAdvancement')}", [], "available"
if v in ("blocks_traveled", "damage_taken", "recoil", "defeat", "use_move", "battle_critical_hits"):
return f"{v.replace('_', ' ')} {r.get('amount', '')} {r.get('move', '') or r.get('target', '')}".strip(), [], "available"
if v in ("stat_compare", "stat_equal"):
return " ".join(str(x) for x in r.values() if x != v), [], "available"
if v == "property_range":
return f"{r.get('feature')} {r.get('range')}", [], "available"
self.unresolved["evolution requirement variants shown raw"].add(v)
return v, [], "available"
# --- uses -----------------------------------------------------------------------------------
def _uses(self):
for rid, r in self.recipes.items():
seen = set()
for s in r["slots"]:
for i in s["items"]:
if i not in seen:
seen.add(i)
self.uses[i].append({"kind": "recipe", "recipe": rid, "text": f"{r['label']}: {self.item_name(r['result'])}",
"result": r["result"]})
held = self.itags.resolve("#cobblemon:held/is_held_item")
for i in held:
self.uses[i].append({"kind": "held", "text": "Held item" + (": " + " ".join(self.tooltip(i)) if self.tooltip(i) else "")})
for eid, rel in self.stack.ids("spawn_bait_effects").items():
d = self.stack.json(rel) or {}
item = norm_id(d.get("item", eid))
effects = [e.get("type", "").split(":")[-1].replace("_", " ") for e in d.get("effects", [])]
self.uses[item].append({"kind": "bait", "text": "Bait / Poké Snack effect: " + _join(sorted(set(effects)))})
for i, e in enumerate(self.spawns.entries):
for c in e["conditions"]:
refs = [r for r in c["near_refs"] + c["base_refs"] if not r.startswith("#")]
for ref in refs:
for m in e["members"]:
self.uses[norm_id(ref)].append({"kind": "spawn", "text": f"Nearby block that attracts {self.species_name(m['species'])}",
"species": m["species"]})
# --- solving ----------------------------------------------------------------------------------
def add(self, key, kind, text, base="available", needs=(), **data):
self.sources[key].append({"kind": kind, "text": text, "base": base, "needs": [list(g) for g in needs], **data})
def _solve(self):
status = defaultdict(int)
changed, passes = True, 0
while changed:
changed, passes = False, passes + 1
for key, srcs in self.sources.items():
best = status[key]
if best == 2:
continue
for src in srcs:
v = RANK[src["base"]]
for group in src["needs"]:
v = min(v, max((status[k] for k in group), default=0))
if v <= best:
break
best = max(best, v)
if best != status[key]:
status[key] = best
changed = True
self.status = status
self.passes = passes
for key, srcs in self.sources.items():
for src in srcs:
v, blocked = RANK[src["base"]], []
for group in src["needs"]:
g = max((status[k] for k in group), default=0)
if g < 2:
blocked.append(group)
v = min(v, g)
src["status"] = STATUS[v]
blocked = [g for g in blocked if max((status[k] for k in g), default=0) < RANK[src["base"]]]
blocked.sort(key=lambda g: max((status[k] for k in g), default=0)) # worst first
src["blocked_by"] = [g[:6] for g in blocked][:3]
def get_status(self, key):
return STATUS[self.status.get(key, 0)]
@property
def btags(self):
return self.tags["worldgen/biome"]
def _chance(d):
p = d["p"]
text = "always" if p >= 0.995 else f"≈{p * 100:.3g}%"
lo, hi = d["count"]
if (lo, hi) != (1, 1):
text += f", ×{int(lo)}" + (f"–{int(hi)}" if hi != lo else "")
if d["notes"]:
text += " (" + ", ".join(d["notes"]) + ")"
return {"chance": text}
def _drop_entries(drops):
out = []
for e in (drops or {}).get("entries", []):
if "percentage" in e:
text = f"{e['percentage']:g}%"
elif "quantityRange" in e:
text = f"×{e['quantityRange']}"
else:
text = "×" + str(e.get("quantity", 1))
out.append({"item": norm_id(e["item"]), "chance": text})
return out
def _join(items, limit=6):
items = list(items)
if len(items) > limit:
return ", ".join(items[:limit]) + f" and {len(items) - limit} more"
return ", ".join(items)
def _strings(obj):
if isinstance(obj, str):
yield obj
elif isinstance(obj, dict):
for v in obj.values():
yield from _strings(v)
elif isinstance(obj, list):
for v in obj:
yield from _strings(v)
+65
View File
@@ -0,0 +1,65 @@
"""Minimal reader for gzipped NBT structure templates (data/*/structure/*.nbt).
Only what the field guide needs: the palette, block-entity strings (loot
tables, jigsaw pools) and entity ids. Returns plain Python values.
"""
import gzip
import io
import struct
def _read(f, tag):
if tag == 1:
return struct.unpack(">b", f.read(1))[0]
if tag == 2:
return struct.unpack(">h", f.read(2))[0]
if tag == 3:
return struct.unpack(">i", f.read(4))[0]
if tag == 4:
return struct.unpack(">q", f.read(8))[0]
if tag == 5:
return struct.unpack(">f", f.read(4))[0]
if tag == 6:
return struct.unpack(">d", f.read(8))[0]
if tag == 7:
return f.read(struct.unpack(">i", f.read(4))[0])
if tag == 8:
return f.read(struct.unpack(">H", f.read(2))[0]).decode("utf-8", "replace")
if tag == 9:
inner, n = struct.unpack(">bi", f.read(5))
return [_read(f, inner) for _ in range(n)]
if tag == 10:
out = {}
while True:
t = f.read(1)[0]
if t == 0:
return out
name = f.read(struct.unpack(">H", f.read(2))[0]).decode("utf-8", "replace")
out[name] = _read(f, t)
if tag == 11:
n = struct.unpack(">i", f.read(4))[0]
return list(struct.unpack(f">{n}i", f.read(4 * n)))
if tag == 12:
n = struct.unpack(">i", f.read(4))[0]
return list(struct.unpack(f">{n}q", f.read(8 * n)))
raise ValueError(f"bad NBT tag {tag}")
def load(raw):
data = gzip.decompress(raw) if raw[:2] == b"\x1f\x8b" else raw
f = io.BytesIO(data)
tag = f.read(1)[0]
f.read(struct.unpack(">H", f.read(2))[0])
return _read(f, tag)
def strings(value):
"""Every string inside a decoded NBT value."""
if isinstance(value, str):
yield value
elif isinstance(value, dict):
for v in value.values():
yield from strings(v)
elif isinstance(value, list):
for v in value:
yield from strings(v)
+375
View File
@@ -0,0 +1,375 @@
"""HTML field guide from fieldguide.json: index.html, pokemon.html, items.html.
Pages share assets/guide.css, assets/guide.js and data.js (the JSON, slimmed,
as a script so the guide also works when opened from disk). No network access
and no external libraries; light and dark mode follow the system setting and
can be switched by hand.
"""
import html
import json
TYPE_COLORS = {
"normal": "#9fa19f", "fire": "#e62829", "water": "#2980ef", "electric": "#fac000", "grass": "#3fa129",
"ice": "#3dcef3", "fighting": "#ff8000", "poison": "#9141cb", "ground": "#915121", "flying": "#81b9ef",
"psychic": "#ef4179", "bug": "#91a119", "rock": "#afa981", "ghost": "#704170", "dragon": "#5060e1",
"dark": "#624d4e", "steel": "#60a1b8", "fairy": "#ef70ef",
}
CSS = r"""
:root{--bg:#f6f5f1;--panel:#fff;--panel2:#f0eee8;--text:#1d1d1b;--muted:#6b6a64;--line:#dcd9d0;--accent:#c0392b;
--ok:#2e7d32;--ok-bg:#e3f1e4;--warn:#9a6700;--warn-bg:#fdf1d6;--bad:#b3261e;--bad-bg:#fbe3e1;--link:#1f5fa8;--slot:#c6c6c6;--slot-line:#8b8b8b}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--bg:#17181a;--panel:#212226;--panel2:#2a2b30;--text:#e9e7e1;
--muted:#a3a19a;--line:#3a3b41;--accent:#ff6b5b;--ok:#7bd88f;--ok-bg:#1e3323;--warn:#f2c14e;--warn-bg:#3a3018;--bad:#ff8a80;
--bad-bg:#3d1f1d;--link:#8ab4f8;--slot:#3b3c42;--slot-line:#5a5b62}}
:root[data-theme="dark"]{--bg:#17181a;--panel:#212226;--panel2:#2a2b30;--text:#e9e7e1;--muted:#a3a19a;--line:#3a3b41;--accent:#ff6b5b;
--ok:#7bd88f;--ok-bg:#1e3323;--warn:#f2c14e;--warn-bg:#3a3018;--bad:#ff8a80;--bad-bg:#3d1f1d;--link:#8ab4f8;--slot:#3b3c42;--slot-line:#5a5b62}
*{box-sizing:border-box}html{-webkit-text-size-adjust:100%}
body{margin:0;background:var(--bg);color:var(--text);font:15px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
a{color:var(--link);text-decoration:none}a:hover{text-decoration:underline}
header.top{position:sticky;top:0;z-index:5;display:flex;gap:16px;align-items:center;padding:10px 16px;background:var(--panel);border-bottom:1px solid var(--line)}
header.top .brand{font-weight:700;letter-spacing:.2px;color:var(--text)}header.top nav{display:flex;gap:4px;flex-wrap:wrap}
header.top nav a{padding:4px 10px;border-radius:6px;color:var(--muted)}header.top nav a.on{background:var(--panel2);color:var(--text)}
header.top .sp{flex:1}button.theme{background:none;border:1px solid var(--line);color:var(--muted);border-radius:6px;padding:3px 8px;cursor:pointer}
main{max-width:1280px;margin:0 auto;padding:16px}
.layout{display:grid;grid-template-columns:240px minmax(0,1fr);gap:16px}
@media (max-width:820px){.layout{grid-template-columns:minmax(0,1fr)}aside.side{max-height:none;position:static;order:2}
header.top{flex-wrap:wrap;gap:6px 12px}header.top .brand{flex:1 1 100%}header.top .sp{display:none}main{padding:12px}}
aside.side{position:sticky;top:62px;align-self:start;max-height:calc(100vh - 80px);overflow:auto;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:10px}
aside.side h4{margin:12px 6px 4px;font-size:12px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted)}
aside.side a{display:flex;justify-content:space-between;gap:8px;padding:4px 8px;border-radius:6px;color:var(--text)}
aside.side a.on,aside.side a:hover{background:var(--panel2);text-decoration:none}aside.side a small{color:var(--muted)}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px 16px;margin-bottom:14px}
h1{font-size:24px;margin:4px 0 8px}h2{font-size:18px;margin:4px 0 10px}h3{font-size:15px;margin:14px 0 6px}
.muted{color:var(--muted)}.small{font-size:13px}
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px}
.stat{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px}.stat b{display:block;font-size:26px}
.grid-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px}
.loc{display:block;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:10px 12px;color:var(--text)}
.loc:hover{border-color:var(--accent);text-decoration:none}.loc .k{font-size:12px;color:var(--muted)}
.pill{display:inline-block;padding:1px 8px;border-radius:999px;font-size:12px;border:1px solid var(--line);background:var(--panel2);white-space:nowrap}
.st-available{color:var(--ok);background:var(--ok-bg);border-color:transparent}.st-conditional{color:var(--warn);background:var(--warn-bg);border-color:transparent}
.st-unavailable{color:var(--bad);background:var(--bad-bg);border-color:transparent}
.b-common{}.b-uncommon{color:#2e7d9a}.b-rare{color:#7b4bb7}.b-ultra-rare{color:#c2185b}.b-boss{color:var(--accent)}
.tbl{width:100%;border-collapse:collapse}.tbl th,.tbl td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);vertical-align:top}
.tbl th{font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);background:var(--panel)}
.tbl td.nw{white-space:nowrap}
.tblwrap{overflow-x:auto}
.mon{display:flex;align-items:center;gap:8px;min-width:150px}.dot{width:22px;height:22px;border-radius:50%;flex:none;display:grid;place-items:center;font-size:9px;font-weight:700;color:#fff;text-shadow:0 0 2px #0008}
.types span{display:inline-block;font-size:11px;padding:0 6px;border-radius:4px;color:#fff;margin-right:3px;text-shadow:0 0 2px #0006}
.chips span{display:inline-block;font-size:12px;padding:1px 6px;margin:1px 2px 1px 0;border-radius:4px;background:var(--panel2);border:1px solid var(--line)}
.chips .or{background:none;border:none;color:var(--muted)}
.toolbar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:10px}
.toolbar input,.toolbar select{background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:6px;padding:6px 8px;font:inherit}
.toolbar input{min-width:0;flex:1 1 200px}.toolbar input[type=checkbox]{flex:none}.toolbar label{font-size:13px;color:var(--muted);display:flex;gap:4px;align-items:center}
.items{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:6px}
.item{display:flex;gap:8px;align-items:center;padding:6px 8px;background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--text)}
.item:hover{border-color:var(--accent);text-decoration:none}.item .n{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ico{width:32px;height:32px;image-rendering:pixelated;flex:none}.ico.big{width:64px;height:64px}
.noico{width:32px;height:32px;border-radius:6px;background:var(--panel2);border:1px dashed var(--line);flex:none;display:grid;place-items:center;font-size:10px;color:var(--muted)}
.sdot{width:9px;height:9px;border-radius:50%;flex:none}.sdot.available{background:var(--ok)}.sdot.conditional{background:var(--warn)}.sdot.unavailable{background:var(--bad)}
.recipes{display:flex;flex-wrap:wrap;gap:10px}
.recipe{background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:8px 10px}
.recipe .lbl{font-size:12px;color:var(--muted);margin-bottom:6px;display:flex;gap:6px;align-items:center}
.craft{display:flex;align-items:center;gap:10px}
.cgrid{display:grid;gap:2px}.slot{width:38px;height:38px;background:var(--slot);border:1px solid var(--slot-line);display:grid;place-items:center;position:relative}
.slot img{width:32px;height:32px;image-rendering:pixelated}.slot .cnt{position:absolute;right:1px;bottom:-1px;font-size:11px;font-weight:700;color:#fff;text-shadow:1px 1px 0 #000}
.slot .alt{position:absolute;left:1px;top:-2px;font-size:10px;color:var(--muted)}
.arrow{font-size:22px;color:var(--muted)}
.src{display:flex;gap:10px;align-items:flex-start;padding:7px 0;border-bottom:1px solid var(--line)}.src:last-child{border-bottom:none}
.src .kind{font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--muted);width:92px;flex:none;padding-top:2px}
.src .body{flex:1;min-width:0}.blocked{color:var(--bad);font-size:13px}
.banner{padding:10px 12px;border-radius:8px;margin:10px 0}.banner.unavailable{background:var(--bad-bg);color:var(--bad)}
.banner.conditional{background:var(--warn-bg);color:var(--warn)}.banner.available{background:var(--ok-bg);color:var(--ok)}
.head{display:flex;gap:14px;align-items:center}.head .id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted)}
details summary{cursor:pointer;color:var(--muted)}code{font-family:ui-monospace,monospace;font-size:12px}
.foot{color:var(--muted);font-size:12px;margin:24px 0 8px}
"""
JS = r"""
const FG = window.FG;
const $ = (s, el = document) => el.querySelector(s);
const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[c]));
const TYPE = %TYPES%;
const BUCKET_ORDER = {'common': 0, 'uncommon': 1, 'rare': 2, 'ultra-rare': 3, 'boss': 4};
const POS = {grounded: 'land', submerged: 'underwater', surface: 'water surface', seafloor: 'sea floor', fishing: 'fishing', lava_surface: 'lava'};
const STATUS_TEXT = {available: 'on the island', conditional: 'conditional', unavailable: 'no source here'};
function store(k, v) { try { if (v === undefined) return localStorage.getItem(k); localStorage.setItem(k, v); } catch (e) { return null; } }
function initTheme() {
const t = store('fg-theme'); if (t) document.documentElement.dataset.theme = t;
const b = $('button.theme'); if (!b) return;
b.onclick = () => { const cur = document.documentElement.dataset.theme ||
(matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
const n = cur === 'dark' ? 'light' : 'dark'; document.documentElement.dataset.theme = n; store('fg-theme', n); };
}
function hashArgs() { const o = {}; location.hash.slice(1).split('&').forEach(p => { const [k, v] = p.split('='); if (k) o[k] = decodeURIComponent(v || ''); }); return o; }
function pill(status, text) { return `<span class="pill st-${status}">${esc(text || STATUS_TEXT[status] || status)}</span>`; }
function itemInfo(id) { return FG.items[id] || FG.vanilla[id] || {name: id.split(':')[1].replace(/_/g, ' '), status: 'available'}; }
function icon(id, cls = 'ico') { const it = itemInfo(id); return it.icon ? `<img class="${cls}" src="${esc(it.icon)}" alt="">` : `<span class="noico">?</span>`; }
function itemLink(id) { const it = itemInfo(id); return `<a href="items.html#item=${encodeURIComponent(id)}" title="${esc(id)}">${esc(it.name)}</a>`; }
function monDot(sid) {
const sp = FG.species[sid] || {types: []}; const c = sp.types.map(t => TYPE[t] || '#888');
const bg = c.length > 1 ? `linear-gradient(135deg,${c[0]} 50%,${c[1]} 50%)` : (c[0] || '#888');
return `<span class="dot" style="background:${bg}">${sp.dex || ''}</span>`;
}
function monLink(sid, extra) { const sp = FG.species[sid]; return `<a href="pokemon.html#species=${encodeURIComponent(sid)}">${esc(sp ? sp.name : sid)}</a>${extra ? ' <span class="muted small">' + esc(extra) + '</span>' : ''}`; }
function types(sid) { const sp = FG.species[sid]; return sp ? `<span class="types">${sp.types.map(t => `<span style="background:${TYPE[t]}">${t}</span>`).join('')}</span>` : ''; }
function chips(list) { return `<span class="chips">${list.map(t => `<span>${esc(t)}</span>`).join('')}</span>`; }
function bucket(b) { const pct = FG.meta.buckets[b]; return `<span class="b-${b}">${esc(b === 'boss' ? 'alpha' : b)}</span>${pct !== undefined ? `<span class="muted small"> (${pct}%)</span>` : ''}`; }
// ---------- sources and uses ----------
function slot(ing, count) {
if (!ing) return '<div class="slot"></div>';
const id = ing.items[0]; const alts = ing.items.length;
const title = (ing.tag ? ing.tag + ': ' : '') + ing.items.slice(0, 12).map(i => itemInfo(i).name).join(', ') + (alts > 12 ? ' …' : '');
return `<a class="slot" href="items.html#item=${encodeURIComponent(id || '')}" title="${esc(title)}">${id ? icon(id, '') : ''}${alts > 1 ? '<span class="alt">+</span>' : ''}${count > 1 ? `<span class="cnt">${count}</span>` : ''}</a>`;
}
function recipeHtml(rid) {
const r = FG.recipes[rid]; if (!r) return `<code>${esc(rid)}</code>`;
let left;
if (r.grid) {
const w = Math.max(...r.grid.map(row => row.length));
left = `<div class="cgrid" style="grid-template-columns:repeat(${w},38px)">${r.grid.map(row => { const cells = []; for (let i = 0; i < w; i++) cells.push(slot(row[i])); return cells.join(''); }).join('')}</div>`;
} else {
const n = r.slots.length; const w = n > 3 ? 3 : n;
left = `<div class="cgrid" style="grid-template-columns:repeat(${w},38px)">${r.slots.map(s => slot(s)).join('')}</div>`;
}
return `<div class="recipe"><div class="lbl">${pill(r.status, '')}<span>${esc(r.label)}</span></div><div class="craft">${left}<span class="arrow">→</span>${slot({items: [r.result]}, r.count)}</div><div class="muted small" title="${esc(rid)}">${esc(itemInfo(r.result).name)}</div></div>`;
}
function sourceHtml(s) {
let body = esc(s.text);
if (s.chance) body += ` <span class="muted small">${esc(s.chance)}</span>`;
if (s.species) body = body.replace(esc(FG.species[s.species]?.name || '\u0000'), monLink(s.species));
if (s.blocked && s.blocked.length && s.status !== 'available') body += `<div class="blocked">Needs ${s.blocked.map(esc).join('; ')}</div>`;
return `<div class="src"><div class="kind">${esc(s.kind)}</div><div class="body">${body}</div>${pill(s.status, '')}</div>`;
}
// ---------- pokemon page ----------
function pokemonPage() {
const side = $('#side'); const main = $('#main');
const kinds = [['surface', 'Surface'], ['cave', 'Caves'], ['water', 'Water & coast'], ['end', 'The End (conditional)']];
let s = `<div class="toolbar"><input id="q" placeholder="Find a Pokémon…" list="monlist"></div>`;
s += `<datalist id="monlist">${Object.entries(FG.species).map(([id, sp]) => `<option value="${esc(sp.name)}">`).join('')}</datalist>`;
for (const [k, label] of kinds) {
const locs = FG.locations.filter(l => l.kind === k); if (!locs.length) continue;
s += `<h4>${label}</h4>` + locs.map(l => `<a href="#loc=${encodeURIComponent(l.id)}" data-loc="${esc(l.id)}">${esc(l.name)}<small>${new Set(l.spawns.flatMap(i => FG.spawns[i].m.map(m => m[0]))).size}</small></a>`).join('');
}
const missing = Object.values(FG.species).filter(sp => sp.wild === 'unavailable').length;
s += `<h4>Elsewhere</h4><a href="#missing">Not on the island<small>${missing}</small></a>`;
side.innerHTML = s;
$('#q').addEventListener('change', e => { const v = e.target.value.toLowerCase(); const hit = Object.entries(FG.species).find(([id, sp]) => sp.name.toLowerCase() === v || id === v); if (hit) location.hash = 'species=' + encodeURIComponent(hit[0]); });
const route = () => {
const a = hashArgs();
side.querySelectorAll('a').forEach(x => x.classList.toggle('on', x.getAttribute('href') === location.hash));
if (a.species) main.innerHTML = speciesView(a.species);
else if ('missing' in a) main.innerHTML = missingView();
else main.innerHTML = locationView(a.loc || FG.locations[0].id, a);
window.scrollTo(0, 0);
main.querySelectorAll('[data-filter]').forEach(el => el.addEventListener('input', () => filterRows(main)));
filterRows(main);
};
addEventListener('hashchange', route); route();
}
function filterRows(main) {
const b = $('#fb', main), p = $('#fp', main), q = $('#fq', main), x = $('#fx', main); if (!b) return;
main.querySelectorAll('tbody tr').forEach(tr => {
const ok = (!b.value || tr.dataset.b === b.value) && (!p.value || tr.dataset.p === p.value) && (!q.value || tr.dataset.n.includes(q.value.toLowerCase())) && !(x.checked && tr.dataset.x === 'true');
tr.style.display = ok ? '' : 'none';
});
}
function locationView(id) {
const l = FG.locations.find(x => x.id === id); if (!l) return '<p>Unknown location.</p>';
const rows = l.spawns.map(i => FG.spawns[i]).sort((a, b) => (a.x - b.x) || (BUCKET_ORDER[a.b] - BUCKET_ORDER[b.b]) || (b.w - a.w));
const species = new Set(rows.flatMap(r => r.m.map(m => m[0])));
let h = `<div class="card"><h1>${esc(l.name)}</h1><div class="muted">${esc(l.kind === 'cave' ? 'Cave biome' : l.kind === 'water' ? 'Water / coast biome' : l.kind === 'end' ? 'The End' : 'Surface biome')} · ${species.size} Pokémon · ${rows.length} spawn entries · <code>${esc(l.id)}</code></div>`;
if (l.status !== 'available') h += `<div class="banner ${l.status}">${esc(l.reason || STATUS_TEXT[l.status])}</div>`;
h += `<p class="small muted">Rarity: Cobblemon first picks a bucket (chance in brackets), then a spawn inside it by weight. Herds and alphas spawn as groups.</p></div>`;
h += `<div class="card"><div class="toolbar"><input id="fq" data-filter placeholder="Filter by name"><label>Rarity <select id="fb" data-filter><option value="">all</option>${Object.keys(BUCKET_ORDER).map(b => `<option value="${b}">${b === 'boss' ? 'alpha' : b}</option>`).join('')}</select></label><label><input type="checkbox" id="fx" data-filter> hide spawns that need special blocks or structures</label><label>Where <select id="fp" data-filter><option value="">all</option>${Object.entries(POS).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select></label></div>`;
h += `<div class="tblwrap"><table class="tbl"><thead><tr><th>Pokémon</th><th>Level</th><th>Rarity · weight</th><th>Where</th><th>Conditions</th></tr></thead><tbody>`;
for (const r of rows) {
const mons = r.m.map(m => `<div class="mon">${monDot(m[0])}<div>${monLink(m[0], m[1])}${r.m.length > 1 ? ` <span class="muted small">${m[3]} · Lv ${m[2][0]}–${m[2][1]}</span>` : ''}</div></div>`).join('');
const conds = r.c.map(c => c.length ? chips(c) : '<span class="muted small">anywhere</span>').join('<span class="muted small"> or </span>');
const anti = r.a.length ? `<div class="small muted">not: ${r.a.map(a => esc(a.join(', '))).join('; ')}</div>` : '';
const mult = r.mul.length ? `<div class="small muted">${r.mul.map(x => `×${x.multiplier} when ${esc(x.when.join(', '))}`).join('; ')}</div>` : '';
h += `<tr data-x="${r.x}" data-b="${r.b}" data-p="${r.p}" data-n="${esc(r.m.map(m => (FG.species[m[0]]?.name || m[0]).toLowerCase()).join(' '))}"><td>${mons}${r.t === 'pokemon-herd' ? `<span class="pill">herd up to ${r.h}</span>` : ''}</td><td class="nw">${r.lv[0]}–${r.lv[1]}</td><td>${bucket(r.b)}<div class="muted small">weight ${r.w}</div></td><td>${esc(POS[r.p] || r.p)}</td><td>${conds}${anti}${mult}${r.st !== 'available' ? pill(r.st) : ''}</td></tr>`;
}
return h + '</tbody></table></div></div>';
}
function speciesView(sid) {
const sp = FG.species[sid]; if (!sp) return '<p>Unknown species.</p>';
let h = `<div class="card"><div class="head">${monDot(sid)}<div><h1>${esc(sp.name)} <span class="muted">#${sp.dex}</span></h1>${types(sid)} <span class="id">${esc(sid)}</span></div></div>`;
h += `<div class="banner ${sp.status}">${sp.wild !== 'unavailable' ? 'Found in the wild on the island.' : sp.status !== 'unavailable' ? 'Not found in the wild here, but obtainable another way (below).' : sp.implemented ? 'No way to get it on this island.' : 'Not implemented in Cobblemon (no model), so it cannot be obtained.'}</div></div>`;
if (sp.locations.length) {
h += `<div class="card"><h2>Where to find it</h2><table class="tbl"><thead><tr><th>Location</th><th>Level</th><th>Rarity</th></tr></thead><tbody>`;
h += sp.locations.map(l => `<tr><td><a href="#loc=${encodeURIComponent(l.biome)}">${esc(l.name)}</a>${l.zones && l.zones.length ? ` <span class="muted small">${esc(l.zones.join(', '))}</span>` : ''}${l.special ? ' <span class="muted small">only near particular blocks</span>' : ''}</td><td>${l.level[0]}–${l.level[1]}</td><td>${l.buckets.map(bucket).join(', ')}</td></tr>`).join('');
h += `</tbody></table><details><summary>All ${sp.spawns.length} spawn entries with conditions</summary>${sp.spawns.map(i => FG.spawns[i]).map(r => `<div class="src"><div class="kind">${esc(r.id)}</div><div class="body">${bucket(r.b)} · weight ${r.w} · Lv ${r.lv[0]}–${r.lv[1]} · ${esc(POS[r.p] || r.p)}<br>${r.c.map(c => chips(c)).join(' or ')}<div class="muted small">${r.loc.map(i => esc(FG.locations[i]?.name || '')).join(', ') || 'no island biome'}</div></div>${pill(r.st)}</div>`).join('')}</details></div>`;
} else if (sp.why_not_wild.length) {
h += `<div class="card"><h2>Why it is not wild here</h2><ul>${sp.why_not_wild.map(w => `<li>${esc(w)}</li>`).join('')}</ul></div>`;
} else if (!sp.spawns.length) {
h += `<div class="card"><h2>Wild spawns</h2><p class="muted">Cobblemon has no wild spawn for it.</p></div>`;
}
if (sp.sources.length) h += `<div class="card"><h2>Other ways to get it</h2>${sp.sources.map(sourceHtml).join('')}</div>`;
if (sp.evolutions.length) h += `<div class="card"><h2>Evolves into</h2>${sp.evolutions.map(e => `<div class="src"><div class="kind">evolution</div><div class="body">${monLink(e.to)}${e.form ? ` <span class="muted small">(${esc(e.form)} form)</span>` : ''}: ${esc(e.how.join(', ') || 'level up')}</div></div>`).join('')}</div>`;
if (sp.drops.length) h += `<div class="card"><h2>Drops (when defeated in the wild)</h2><div class="items">${sp.drops.map(d => `<a class="item" href="items.html#item=${encodeURIComponent(d.item)}">${icon(d.item)}<span class="n">${esc(itemInfo(d.item).name)}</span><span class="muted small">${esc(d.chance)}</span></a>`).join('')}</div></div>`;
return h;
}
function missingView() {
const list = Object.entries(FG.species).filter(([id, sp]) => sp.wild === 'unavailable').sort((a, b) => a[1].dex - b[1].dex);
let h = `<div class="card"><h1>Not found in the wild on the island</h1><p class="muted">${list.length} species. Green: obtainable another way (evolution, fossil, starter, trade). Red: no way on this island.</p></div><div class="card"><div class="tblwrap"><table class="tbl"><thead><tr><th>Pokémon</th><th>How to get it here</th><th>Wild spawns need</th></tr></thead><tbody>`;
for (const [id, sp] of list) {
const how = sp.sources.filter(s => s.status !== 'unavailable').slice(0, 3).map(s => esc(s.text)).join('<br>') || (sp.implemented ? '' : '<span class="muted">not implemented in Cobblemon</span>');
h += `<tr><td><div class="mon">${monDot(id)}${monLink(id)}</div></td><td>${how} ${pill(sp.status, '')}</td><td class="small muted">${esc(sp.why_not_wild.slice(0, 3).join('; ')) || (sp.spawns.length ? '' : 'no wild spawn in Cobblemon')}</td></tr>`;
}
return h + '</tbody></table></div></div>';
}
// ---------- items page ----------
function itemsPage() {
const main = $('#main');
const cats = [...new Set(Object.values(FG.items).map(i => i.category))].sort();
const mods = [...new Set(Object.values(FG.items).map(i => i.mod))].sort();
const route = () => {
const a = hashArgs();
if (a.item) { main.innerHTML = itemView(a.item); window.scrollTo(0, 0); return; }
main.innerHTML = `<div class="card"><h1>Items</h1><p class="muted">Every item the pack's mods add, with how to get it on the island. Red items have no source here.</p><div class="toolbar"><input id="iq" placeholder="Search items…" value="${esc(a.q || '')}"><select id="ic"><option value="">All categories</option>${cats.map(c => `<option${a.cat === c ? ' selected' : ''}>${esc(c)}</option>`).join('')}</select><select id="im"><option value="">All mods</option>${mods.map(c => `<option${a.mod === c ? ' selected' : ''}>${esc(c)}</option>`).join('')}</select><select id="is"><option value="">Any status</option><option value="available">On the island</option><option value="conditional">Conditional</option><option value="unavailable"${a.status === 'unavailable' ? ' selected' : ''}>No source here</option></select></div><div id="count" class="muted small"></div></div><div class="items" id="list"></div>`;
const draw = () => {
const q = $('#iq').value.toLowerCase(), c = $('#ic').value, m = $('#im').value, s = $('#is').value;
const list = Object.entries(FG.items).filter(([id, it]) => (!q || it.name.toLowerCase().includes(q) || id.includes(q)) && (!c || it.category === c) && (!m || it.mod === m) && (!s || (it.status === s && !(s === 'unavailable' && it.technical))))
.sort((x, y) => x[1].category.localeCompare(y[1].category) || x[1].name.localeCompare(y[1].name));
$('#count').textContent = `${list.length} items`;
$('#list').innerHTML = list.map(([id, it]) => `<a class="item" href="#item=${encodeURIComponent(id)}" title="${esc(id)}">${icon(id)}<span class="n">${esc(it.name)}</span><span class="sdot ${it.technical ? '' : it.status}" title="${esc(it.technical ? 'creative/admin item' : STATUS_TEXT[it.status])}"></span></a>`).join('');
};
['#iq', '#ic', '#im', '#is'].forEach(sel => $(sel).addEventListener('input', draw)); draw();
};
addEventListener('hashchange', route); route();
}
function itemView(id) {
const it = itemInfo(id); const isMod = !!FG.items[id];
let h = `<p><a href="items.html">← All items</a></p><div class="card"><div class="head">${icon(id, 'ico big')}<div><h1>${esc(it.name)}</h1><span class="id">${esc(id)}</span> ${it.category ? `<span class="pill">${esc(it.category)}</span>` : ''}</div></div>`;
if (it.tooltip && it.tooltip.length) h += `<p>${it.tooltip.map(esc).join('<br>')}</p>`;
const msg = it.technical ? 'Creative or admin item; not meant to be obtained in survival.' : it.status === 'available' ? 'Obtainable on the island.' : it.status === 'conditional' ? 'Only obtainable under a condition (e.g. reaching the End).' : 'No source on this island' + (it.reason ? ': ' + it.reason + '.' : '.');
h += `<div class="banner ${it.technical ? 'conditional' : it.status}">${msg}</div></div>`;
const srcs = it.sources || [];
const recs = srcs.filter(s => s.recipe), other = srcs.filter(s => !s.recipe);
h += `<div class="card"><h2>How to get it</h2>`;
if (!srcs.length) h += `<p class="muted">No recipe, loot table, drop or worldgen feature produces it.</p>`;
if (recs.length) h += `<div class="recipes">${recs.map(s => recipeHtml(s.recipe)).join('')}</div>`;
if (other.length) h += `<div style="margin-top:8px">${other.map(sourceHtml).join('')}</div>`;
h += `</div>`;
const uses = (FG.items[id] && FG.items[id].uses) || [];
if (isMod || uses.length) {
const ur = uses.filter(u => u.recipe), uo = uses.filter(u => !u.recipe);
h += `<div class="card"><h2>What it is used for</h2>`;
if (!uses.length) h += `<p class="muted">No recipe or Cobblemon mechanic uses it.</p>`;
if (uo.length) h += uo.map(u => `<div class="src"><div class="kind">${esc(u.kind)}</div><div class="body">${u.species ? esc(u.text).replace(esc(FG.species[u.species]?.name || '\u0000'), monLink(u.species)) : esc(u.text)}</div></div>`).join('');
if (ur.length) h += `<h3>Recipes that use it (${ur.length})</h3><div class="recipes">${ur.slice(0, 60).map(u => recipeHtml(u.recipe)).join('')}</div>${ur.length > 60 ? `<p class="muted">… and ${ur.length - 60} more.</p>` : ''}`;
h += `</div>`;
}
if (it.tags && it.tags.length) h += `<div class="card"><h3>Tags</h3>${chips(it.tags)}</div>`;
return h;
}
document.addEventListener('DOMContentLoaded', () => {
initTheme();
const page = document.body.dataset.page;
if (page === 'pokemon') pokemonPage();
if (page === 'items') itemsPage();
});
"""
def slim(data):
"""The JSON as the pages need it (spawns as short records, location indices)."""
loc_index = {loc["id"]: i for i, loc in enumerate(data["locations"])}
spawns = []
for s in data["spawns"]:
spawns.append({
"id": s["id"], "t": s["type"], "b": s["bucket"], "w": s["weight"], "lv": s["level"], "p": s["position"],
"h": s["max_herd"], "st": s["status"],
"m": [[m["species"], " ".join(p for p in m["props"] if not p.startswith(("level", "levelRange"))),
m["level"], m["role"]] for m in s["members"]],
"c": [c["text"] for c in s["conditions"] if c["biomes"]] or [c["text"] for c in s["conditions"]],
"a": s["anticonditions"], "mul": s["multipliers"],
"loc": [loc_index[b] for b in s["biomes"] if b in loc_index],
# needs particular blocks or a structure nearby: listed after the plain spawns
"x": s["needs_blocks"],
})
species = {k: {kk: v[kk] for kk in ("name", "dex", "types", "implemented", "status", "wild", "locations", "spawns",
"sources", "evolutions", "drops", "why_not_wild")}
for k, v in data["species"].items()}
return {"meta": data["meta"], "locations": data["locations"], "spawns": spawns, "species": species,
"items": data["items"], "vanilla": data["vanilla"], "recipes": data["recipes"]}
def page(title, active, body, page_id=""):
nav = "".join(f'<a href="{href}"{" class=on" if active == href else ""}>{label}</a>'
for href, label in (("index.html", "Overview"), ("pokemon.html", "Pokémon"), ("items.html", "Items")))
return f"""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{html.escape(title)}</title><link rel="stylesheet" href="assets/guide.css"></head>
<body data-page="{page_id}"><header class="top"><a class="brand" href="index.html">Cobblemon Survival · Field Guide</a><nav>{nav}</nav>
<span class="sp"></span><button class="theme" type="button" title="Switch light/dark">◐</button></header>
{body}
<script src="data.js"></script><script src="assets/guide.js"></script></body></html>
"""
def index_body(data):
e = html.escape
species = data["species"]
wild = sum(1 for s in species.values() if s["wild"] == "available")
other = sum(1 for s in species.values() if s["wild"] != "available" and s["status"] == "available")
cond = sum(1 for s in species.values() if s["status"] == "conditional")
none = sum(1 for s in species.values() if s["status"] == "unavailable")
items = data["items"]
nosrc = sorted((i for i, v in items.items() if v["status"] == "unavailable" and not v["technical"]),
key=lambda i: (items[i]["category"], items[i]["name"]))
used_nosrc = [i for i in nosrc if items[i]["uses"]]
cards = []
for loc in data["locations"]:
n = len({m["species"] for i in loc["spawns"] for m in data["spawns"][i]["members"]})
kind = {"surface": "Surface", "cave": "Cave", "water": "Water / coast", "end": "The End"}.get(loc["kind"], loc["kind"])
cards.append(f'<a class="loc" href="pokemon.html#loc={e(loc["id"])}"><b>{e(loc["name"])}</b><div class="k">{kind} · {n} Pokémon'
f'{" · conditional" if loc["status"] != "available" else ""}</div></a>')
def item_row(i):
it = items[i]
img = f'<img class="ico" src="{e(it["icon"])}" alt="">' if it.get("icon") else '<span class="noico">?</span>'
why = it.get("reason") or "no source"
return (f'<a class="item" href="items.html#item={e(i)}" title="{e(i)}">{img}<span class="n">{e(it["name"])}'
f'<br><span class="muted small">{e(why)}</span></span></a>')
problems = "".join(f"<details><summary>{e(k)} ({len(v)})</summary><p class='small'><code>{e(', '.join(map(str, v[:400])))}</code></p></details>"
for k, v in data["problems"].items() if v)
struct = "".join(f"<tr><td>{e(s['name'])}</td><td><span class='pill st-{s['status']}'>{s['status']}</span></td><td class='small muted'>{e(s['reason'])}</td></tr>"
for s in data["island"]["structures"] if s["status"] != "unavailable")
return f"""<main>
<div class="card"><h1>Field guide</h1><p>Where to find every Pokémon on the island, how to get every item the mods add, and what each item is for,
generated from the pack's own data (Cobblemon spawn pools, species, recipes and loot tables, with <code>datapacks/core</code> on top).
Generated {e(data['meta']['generated'])}.</p>
<div class="stats"><div class="stat"><b>{wild}</b>Pokémon in the wild here</div><div class="stat"><b>{other}</b>more by evolution, fossils, starters</div>
<div class="stat"><b>{cond}</b>only in the End</div>
<div class="stat"><b>{none}</b>Pokémon with no way to get them here</div><div class="stat"><b>{len(items)}</b>mod items</div>
<div class="stat"><b>{len(nosrc)}</b>items with no source here</div></div>
<p class="small muted">Statuses: <span class="pill st-available">on the island</span> <span class="pill st-conditional">conditional</span> needs something that may not exist (the End, a stronghold inside the border) <span class="pill st-unavailable">no source here</span> ore, Nether or missing biome only.</p></div>
<div class="card"><h2>Locations</h2><div class="grid-cards">{''.join(cards)}</div>
<p><a href="pokemon.html#missing">Pokémon not found in the wild on the island →</a></p></div>
<div class="card"><h2>Items with no source on the island ({len(nosrc)})</h2>
<p class="muted small">{len(used_nosrc)} of them are used by a recipe or a Cobblemon mechanic. Creative/admin items are left out.</p>
<div class="items">{''.join(item_row(i) for i in nosrc)}</div></div>
<div class="card"><h2>Structures that can generate</h2><div class="tblwrap"><table class="tbl"><tbody>{struct}</tbody></table></div></div>
<div class="card"><h2>What the generator could not resolve</h2>{problems}</div>
<p class="foot">Unofficial fan project, not affiliated with Nintendo, Game Freak, Creatures Inc., The Pokémon Company, the Cobblemon team, Mojang Studios or Microsoft.
Icons are taken from the installed mods at build time and are their authors' work.</p>
</main>"""
def write(data, out):
(out / "assets").mkdir(parents=True, exist_ok=True)
(out / "assets/guide.css").write_text(CSS)
(out / "assets/guide.js").write_text(JS.replace("%TYPES%", json.dumps(TYPE_COLORS)))
(out / "data.js").write_text("window.FG=" + json.dumps(slim(data), ensure_ascii=False, separators=(",", ":")) + ";\n")
(out / "index.html").write_text(page("Field Guide", "index.html", index_body(data)))
(out / "pokemon.html").write_text(page("Pokémon by location · Field Guide", "pokemon.html",
'<main><div class="layout"><aside class="side" id="side"></aside><section id="main"></section></div></main>',
"pokemon"))
(out / "items.html").write_text(page("Items · Field Guide", "items.html", '<main id="main"></main>', "items"))
+260
View File
@@ -0,0 +1,260 @@
"""Cobblemon world spawns (data/*/spawn_pool_world), resolved against the island.
Mirrors Cobblemon 1.8.4: a spawn's conditions are OR'd and its anticonditions
veto; presets (spawn_detail_presets) are merged into every condition with
MergeMode.INSERT (lists appended, single values kept if already set) and their
anticondition is appended (SpawnDetailPreset.apply). A biome named in any
anticondition is never considered (BiomePrecalculation).
"""
import json
from .datasource import ROOT, norm_id, split_id
MERGED_FIELDS_SKIP = {"isRaining", "isThundering", "isSlimeChunk", "markers", "isPokeSnack"} # not in copyFrom()
TIME_RANGES = {"day", "night", "morning", "noon", "afternoon", "evening", "midnight", "predawn", "dawn", "dusk",
"twilight", "any"}
MOON = ["full", "waning gibbous", "last quarter", "waning crescent", "new", "waxing crescent", "first quarter",
"waxing gibbous"]
def parse_range(text, default=(1, 100)):
if text is None:
return list(default)
if isinstance(text, (int, float)):
return [int(text), int(text)]
parts = str(text).split("-")
try:
return [int(parts[0]), int(parts[-1])] if parts[0] else list(default)
except ValueError:
return list(default)
def parse_props(text):
"""'venusaur held_item=cobblemon:grass_gem alpha=true' -> species id and extra properties."""
tokens = str(text).split()
species = norm_id(tokens[0] if ":" in tokens[0] else "cobblemon:" + tokens[0]) if tokens else ""
return species, tokens[1:]
def as_list(v):
return [] if v is None else v if isinstance(v, list) else [v]
class Spawns:
def __init__(self, stack, tags, island, pools=None):
"""pools: {pool id: (file, json)} to read instead of the stack's visible spawn_pool_world files."""
self.stack, self.tags, self.island = stack, tags, island
self.btags, self.blocktags = tags["worldgen/biome"], tags["block"]
self.zones = load_zones()
self.unknown_zones = set()
self.presets = {}
for pid, rel in sorted(stack.ids("spawn_detail_presets").items()):
self.presets[split_id(pid)[1]] = stack.json(rel) or {}
self.entries = []
self.skipped = []
modids = stack.modids
if pools is None:
pools = {fid: (rel, stack.json(rel)) for fid, rel in sorted(stack.ids("spawn_pool_world").items())}
for fid, (rel, d) in pools.items():
d = d or {}
if d.get("enabled") is False:
self.skipped.append((fid, "disabled"))
continue
if not self._mods_ok(d, modids):
self.skipped.append((fid, "needs other mods"))
continue
for s in d.get("spawns", []):
if s.get("type") not in ("pokemon", "pokemon-herd") or not self._mods_ok(s, modids):
continue
self.entries.append(self._entry(fid, rel, s))
@staticmethod
def _mods_ok(d, modids):
return (all(m in modids for m in d.get("neededInstalledMods", []))
and not any(m in modids for m in d.get("neededUninstalledMods", [])))
def _entry(self, fid, rel, s):
conds = [dict(c) for c in as_list(s.get("condition")) + as_list(s.get("conditions"))]
antis = [dict(c) for c in as_list(s.get("anticondition")) + as_list(s.get("anticonditions"))]
bucket, position, weight = s.get("bucket"), s.get("spawnablePositionType", s.get("context")), s.get("weight")
for name in s.get("presets", []):
p = self.presets.get(name.split(":")[-1])
if p is None:
continue
bucket = p.get("bucket", bucket)
position = p.get("spawnablePositionType", p.get("context", position))
if p.get("condition"):
if conds:
for c in conds:
for k, v in p["condition"].items():
if k in MERGED_FIELDS_SKIP:
continue
if isinstance(v, list):
c[k] = list(c.get(k, [])) + v
else:
c.setdefault(k, v)
else:
conds.append(dict(p["condition"]))
if p.get("anticondition"):
antis.append(dict(p["anticondition"]))
if s.get("type") == "pokemon":
species, extra = parse_props(s.get("pokemon", ""))
members = [{"species": species, "props": extra, "level": parse_range(s.get("level", s.get("levelRange"))),
"role": "single"}]
else:
members = []
herd = parse_range(s.get("levelRange", s.get("level")))
for m in s.get("herdablePokemon", []):
species, extra = parse_props(m.get("pokemon", ""))
lv = parse_range(m.get("levelRange"), herd)
if m.get("isLeader"): # leaders list 1-100; the herd's own range is what they spawn at
lv = [max(lv[0], herd[0]), min(lv[1], herd[1])]
members.append({"species": species, "props": extra, "level": lv,
"role": "leader" if m.get("isLeader") else "follower", "weight": m.get("weight"),
"max": m.get("maxTimes")})
excluded = set()
for a in antis:
excluded |= self.btags.resolve_all(a.get("biomes", []))
out_conds, biomes, zones = [], set(), set()
for c in conds or [{}]:
oc = self._condition(c)
oc["biomes"] = sorted(set(oc["biomes"]) - excluded)
out_conds.append(oc)
biomes |= set(oc["biomes"])
zones |= set(oc["zones"])
mults = as_list(s.get("weightMultiplier")) + as_list(s.get("weightMultipliers"))
return {
"id": s.get("id") or fid, "file": rel, "pool": fid, "type": s["type"], "bucket": bucket,
"weight": weight, "percentage": s.get("percentage"), "position": position,
"level": parse_range(s.get("level", s.get("levelRange"))) if s.get("type") == "pokemon-herd" else members[0]["level"],
"max_herd": s.get("maxHerdSize"), "members": members, "presets": s.get("presets", []),
"conditions": out_conds, "anticonditions": [self._condition(a, anti=True) for a in antis],
"biomes": sorted(biomes), "zones": sorted(zones), "multipliers": [{"multiplier": m.get("multiplier"),
"when": self._condition(m.get("condition", {}))["text"]}
for m in mults],
"drops": s.get("drops"),
}
def _condition(self, c, anti=False):
"""Condition JSON -> island biomes, structure/block requirements and readable tokens."""
isl = self.island
if c.get("biomes"):
allb = self.btags.resolve_all(c["biomes"])
here = isl.island_biomes(allb)
else:
allb, here = None, sorted(b for b in isl.biomes if isl.biomes[b]["kind"] != "nether")
text = []
# the companion mod's zone condition (mod/.../zone/Zones.kt): "zones" wins over "zone"
zones = as_list(c["zones"]) if c.get("zones") is not None else as_list(c.get("zone"))
if zones:
text.append(("zones " if len(zones) > 1 else "zone ") + ", ".join(self._zone_label(z) for z in zones))
if c.get("dimensions"):
text.append("dimension " + ", ".join(c["dimensions"]))
if c.get("structures"):
st, which = isl.structure_status(c["structures"])
text.append("in/near " + ", ".join(_short(x) for x in c["structures"]))
# the structure decides where this can happen: only biomes the structures generate in
if not anti:
where = {b for w in which for b in isl.structures[w]["biomes"]}
here = [b for b in here if b in where]
else:
st, which = None, []
tr = c.get("timeRange")
if tr:
text.append(tr if tr in TIME_RANGES else f"time {tr}")
if c.get("moonPhase") is not None:
phases = []
for part in str(c["moonPhase"]).split(","):
lo, _, hi = part.partition("-")
if lo.strip().isdigit():
phases += [MOON[i % 8] for i in range(int(lo), int(hi or lo) + 1)]
else:
phases.append(part.strip())
text.append("moon: " + ", ".join(phases))
if c.get("canSeeSky") is True:
text.append("open sky")
elif c.get("canSeeSky") is False:
text.append("no sky (under cover)")
if c.get("isRaining") is True:
text.append("raining")
elif c.get("isRaining") is False:
text.append("not raining")
if c.get("isThundering"):
text.append("thunderstorm")
lo, hi = c.get("minSkyLight"), c.get("maxSkyLight")
if (lo, hi) not in ((None, None), (0, 15)) and not (lo in (None, 0) and hi in (None, 15)):
text.append(f"sky light {lo if lo is not None else 0}–{hi if hi is not None else 15}")
lo, hi = c.get("minLight"), c.get("maxLight")
if lo is not None or hi is not None:
text.append(f"light {lo if lo is not None else 0}–{hi if hi is not None else 15}")
lo, hi = c.get("minY"), c.get("maxY")
if lo is not None and hi is not None:
text.append(f"y {int(lo)}…{int(hi)}")
elif lo is not None:
text.append(f"y ≥ {int(lo)}")
elif hi is not None:
text.append(f"y ≤ {int(hi)}")
for axis in ("X", "Z"):
lo, hi = c.get("min" + axis), c.get("max" + axis)
if lo is not None or hi is not None:
text.append(f"{axis.lower()} {'' if lo is None else int(lo)}…{'' if hi is None else int(hi)}")
if c.get("minHeight") is not None or c.get("maxHeight") is not None:
text.append(f"space {c.get('minHeight', '')}–{c.get('maxHeight', '')} high")
near = c.get("neededNearbyBlocks") or []
base = c.get("neededBaseBlocks") or []
if base and base != ["#cobblemon:natural"]:
text.append("standing on " + ", ".join(_short(b) for b in base))
if near:
text.append("near " + ", ".join(_short(b) for b in near))
if c.get("fluid"):
text.append("in " + _short(c["fluid"]))
if c.get("isSlimeChunk"):
text.append("slime chunk")
if c.get("isPokeSnack") is True:
text.append("lured by a Poké Snack")
elif c.get("isPokeSnack") is False and not anti:
pass
if c.get("minLureLevel"):
text.append(f"Lure {c['minLureLevel']}+" + (f" (max {c['maxLureLevel']})" if c.get("maxLureLevel") else ""))
if c.get("bait"):
text.append("bait " + _short(c["bait"]))
if c.get("rodType"):
text.append("rod " + _short(c["rodType"]))
if c.get("markers"):
text.append("markers " + ", ".join(c["markers"]))
if anti and c.get("biomes"):
text.insert(0, "biomes " + ", ".join(_short(b) for b in c["biomes"]))
return {
"biomes": here, "all_ids": sorted(allb) if allb else [],
"structures": {"refs": c.get("structures", []), "status": st, "present": which},
"near": [sorted(self.blocktags.resolve(b)) for b in near],
"near_refs": near, "base": [sorted(self.blocktags.resolve(b)) for b in base], "base_refs": base,
"poke_snack": c.get("isPokeSnack") is True, "zones": zones, "text": text,
}
def _zone_label(self, zid):
z = self.zones.get(zid)
if not z:
self.unknown_zones.add(zid)
return zid
return f"{z['name']} (Lv {z['levels'][0]}–{z['levels'][1]})"
def load_zones():
"""{zone id: {name, levels}} from world/zones.json (surface zones and caves)."""
path = ROOT / "world/zones.json"
if not path.exists():
return {}
plan = json.loads(path.read_text())
return {z["id"]: {"name": z.get("name", z["id"]), "levels": z.get("levels", [None, None])}
for z in plan.get("zones", []) + plan.get("caves", [])}
def _short(ref):
"""'#cobblemon:natural' -> '#natural', 'minecraft:oak_leaves' -> 'oak leaves'."""
tag = ref.startswith("#")
ns, path = split_id(ref)
name = path.replace("_", " ").replace("/", ": ")
if tag:
return "#" + (name if ns in ("minecraft", "cobblemon", "c") else f"{ns}:{name}")
return name
+58
View File
@@ -0,0 +1,58 @@
"""Tag resolution over the data stack (biome, item, block, structure, ... tags).
Tags merge over all layers unless a layer sets "replace": true; entries can be
ids, #tags or {"id": ..., "required": false} objects. Unknown tags resolve to
an empty set and are recorded, so a report can list them.
"""
from .datasource import norm_id, split_id
class Tags:
def __init__(self, stack, registry):
self.stack, self.registry = stack, registry
self.cache = {}
self.missing = set()
def raw(self, tag):
"""The merged entries of one tag, without expanding nested tags."""
ns, path = split_id(tag)
entries = []
for data in self.stack.all_json(f"data/{ns}/tags/{self.registry}/{path}.json"):
if data.get("replace"):
entries = []
for v in data.get("values", []):
entries.append(v["id"] if isinstance(v, dict) else v)
return entries
def exists(self, tag):
ns, path = split_id(tag)
return self.stack.exists(f"data/{ns}/tags/{self.registry}/{path}.json")
def resolve(self, ref, _seen=None):
"""'#ns:tag' -> set of ids; a plain id -> {id}."""
if not ref.startswith("#"):
return {norm_id(ref)}
tag = norm_id(ref)
if tag in self.cache:
return self.cache[tag]
seen = _seen or set()
if tag in seen:
return set()
seen.add(tag)
if not self.exists(tag):
self.missing.add(tag)
out = set()
for entry in self.raw(tag):
out |= self.resolve(entry, seen) if entry.startswith("#") else {norm_id(entry)}
self.cache[tag] = out
return out
def resolve_all(self, refs):
out = set()
for r in ([refs] if isinstance(refs, str) else refs or []):
out |= self.resolve(r)
return out
def all_tags(self):
prefix = f"tags/{self.registry}"
return sorted("#" + k for k in self.stack.ids(prefix))
+580
View File
@@ -0,0 +1,580 @@
"""What can exist on the island: dimensions, biomes, structures, natural blocks, mobs.
Everything is derived from data: world/biomes.json and the world preset for
biomes and dimensions, structure and structure-set JSON for structures, biome
feature lists and NeoForge biome modifiers for natural blocks, biome spawners
and structure spawn overrides for mobs. Minecraft and Cobblemon decide a few
things in code; those are the small tables below, each saying where it is from.
Statuses: "available" (on the island), "conditional" (needs something that may
not exist, e.g. the End or a stronghold inside the border), "unavailable".
"""
import json
import re
from . import nbt
from .datasource import ROOT, norm_id
RANK = {"unavailable": 0, "conditional": 1, "available": 2}
END_BIOMES = ["minecraft:the_end", "minecraft:end_highlands", "minecraft:end_midlands",
"minecraft:small_end_islands", "minecraft:end_barrens"]
NETHER_BIOMES = ["minecraft:nether_wastes", "minecraft:soul_sand_valley", "minecraft:crimson_forest",
"minecraft:warped_forest", "minecraft:basalt_deltas"]
# Cobblemon 1.8.4 adds these placed features from code (CobblemonPlacedFeatures.register()),
# each to the biomes of one tag. None = all biomes; the feature filters itself.
COBBLEMON_CODED_FEATURES = {
"apricorn_trees": ["#cobblemon:has_feature/apricorns_dense", "#cobblemon:has_feature/apricorns_normal",
"#cobblemon:has_feature/apricorns_sparse"], # ApricornTreeFeature checks these
"saccharine_tree": ["#cobblemon:has_feature/saccharine_tree"],
"mints": None, "medicinal_leek": None,
"big_root": ["#minecraft:is_overworld"], "revival_herb": ["#cobblemon:has_feature/revival_herbs"],
"berry_groves": ["#minecraft:is_overworld"], "type_gems": ["#minecraft:is_overworld"],
"swamp_grains": ["#cobblemon:is_swamp"], "plains_grains": ["#cobblemon:is_plains"],
"galarica_nuts": ["#cobblemon:is_beach"], "habitats/treasure_hoard": ["#minecraft:is_overworld"],
"habitats/abandoned_fortress": ["#minecraft:is_nether"], "habitats/ancient_wellsprings": ["#cobblemon:is_lush"],
"habitats/chorus_briars": ["#cobblemon:is_end"], "habitats/deep_roots": ["#cobblemon:is_lush"],
"habitats/earthen_hives": ["#cobblemon:is_dripstone"], "habitats/exposed_geodes": ["#minecraft:is_overworld"],
"habitats/lost_ruins": ["#minecraft:is_overworld"], "habitats/quartz_spikes": ["#cobblemon:nether/is_wasteland"],
"habitats/rocky_tidepools": ["#cobblemon:is_stony_beach"], "habitats/root_nurseries": ["#cobblemon:is_swamp"],
"habitats/sandy_tidepools": ["#cobblemon:is_beach"], "habitats/skeletal_fall": ["#cobblemon:nether/is_soul_sand"],
"habitats/strange_crimson_fungal_dwellings": ["#cobblemon:nether/is_wasteland"],
"habitats/strange_warped_fungal_dwellings": ["#cobblemon:nether/is_wasteland"],
"habitats/volcanic_plumes": ["#minecraft:is_nether"], "habitats/rocky_outcrops": ["#cobblemon:is_mountain"],
"fossils/prehistoric_birch_tree": ["#cobblemon:has_block/birch_log"],
"fossils/prehistoric_dripstone_oasis": ["#cobblemon:is_dripstone"],
"fossils/prehistoric_enhydro_agate": ["#cobblemon:is_lush"],
"fossils/prehistoric_eroded_pillar": ["#cobblemon:has_block/red_sand"],
"fossils/prehistoric_frozen_pond": ["#cobblemon:is_glacial"],
"fossils/prehistoric_frozen_spike": ["#cobblemon:is_glacial"],
"fossils/prehistoric_hydrothermal_vents": ["#cobblemon:is_temperate_ocean"],
"fossils/prehistoric_lush_den": ["#cobblemon:is_jungle"], "fossils/prehistoric_mossy_pond": ["#cobblemon:is_lush"],
"fossils/prehistoric_mud_pit": ["#cobblemon:is_jungle"], "fossils/prehistoric_oak_tree": ["#cobblemon:has_block/oak_log"],
"fossils/prehistoric_powdered_deposit": ["#cobblemon:is_snowy"],
"fossils/prehistoric_rooted_pits": ["#cobblemon:is_swamp"], "fossils/prehistoric_sandy_den": ["#cobblemon:has_block/sand"],
"fossils/prehistoric_spruce_tree": ["#cobblemon:has_block/spruce_log"],
"fossils/prehistoric_submerged_impact": ["#cobblemon:is_temperate_ocean"],
"fossils/prehistoric_submerged_spike": ["#cobblemon:is_frozen_ocean"],
"fossils/prehistoric_preserved_skeleton": ["#cobblemon:is_frozen_ocean"],
"fossils/prehistoric_sunscorched_den": ["#cobblemon:has_block/red_sand"],
"fossils/prehistoric_sunscorched_remains": ["#cobblemon:has_block/red_sand"],
"fossils/prehistoric_suspicious_mounds": ["#cobblemon:is_plains"],
"fossils/prehistoric_underwater_fissure": ["#cobblemon:is_temperate_ocean"],
"fossils/prehistoric_vibrant_hydrothermal_vents": ["#cobblemon:is_warm_ocean"],
"ruins/crumbling_arch": ["#minecraft:is_overworld"], "ruins/deep_crypt": ["#cobblemon:is_deep_dark"],
"ruins/rooted_arch": ["#cobblemon:is_lush"],
}
# Blocks placed by feature (or tree decorator) code that do not appear in the feature's JSON,
# keyed by feature type. Lists may hold #block tags; a string is a regex over block ids.
CODE_FEATURE_BLOCKS = {
"cobblemon:apricorn_tree": ["cobblemon:apricorn_log", "cobblemon:apricorn_leaves"],
"cobblemon:saccharine_tree": ["cobblemon:saccharine_log", "cobblemon:saccharine_leaves"],
"cobblemon:type_gem": r"cobblemon:[a-z]+_gem_cluster", # TypeGemFeature picks a random gem cluster
"minecraft:glowstone_blob": ["minecraft:glowstone"],
"minecraft:basalt_columns": ["minecraft:basalt"], "minecraft:basalt_pillar": ["minecraft:basalt"],
"minecraft:ice_spike": ["minecraft:packed_ice"], "minecraft:iceberg": ["minecraft:packed_ice", "minecraft:blue_ice"],
"minecraft:blue_ice": ["minecraft:blue_ice"],
"minecraft:end_spike": ["minecraft:obsidian", "minecraft:iron_bars"], "minecraft:end_island": ["minecraft:end_stone"],
"minecraft:chorus_plant": ["minecraft:chorus_plant", "minecraft:chorus_flower"],
"minecraft:kelp": ["minecraft:kelp", "minecraft:kelp_plant"],
"minecraft:seagrass": ["minecraft:seagrass", "minecraft:tall_seagrass"], "minecraft:sea_pickle": ["minecraft:sea_pickle"],
"minecraft:coral_tree": ["#minecraft:coral_blocks", "#minecraft:corals", "#minecraft:wall_corals", "minecraft:sea_pickle"],
"minecraft:coral_claw": ["#minecraft:coral_blocks", "#minecraft:corals", "#minecraft:wall_corals", "minecraft:sea_pickle"],
"minecraft:coral_mushroom": ["#minecraft:coral_blocks", "#minecraft:corals", "#minecraft:wall_corals", "minecraft:sea_pickle"],
"minecraft:vines": ["minecraft:vine"], "minecraft:bamboo": ["minecraft:bamboo", "minecraft:podzol"],
"minecraft:monster_room": ["minecraft:cobblestone", "minecraft:mossy_cobblestone", "minecraft:spawner"],
"minecraft:desert_well": ["minecraft:sandstone", "minecraft:sandstone_slab", "minecraft:suspicious_sand"],
"minecraft:dripstone_cluster": ["minecraft:dripstone_block", "minecraft:pointed_dripstone"],
"minecraft:pointed_dripstone": ["minecraft:pointed_dripstone"], "minecraft:large_dripstone": ["minecraft:dripstone_block"],
"minecraft:sculk_patch": ["minecraft:sculk", "minecraft:sculk_vein", "minecraft:sculk_sensor",
"minecraft:sculk_shrieker", "minecraft:sculk_catalyst"],
"minecraft:underwater_magma": ["minecraft:magma_block"], "minecraft:freeze_top_layer": ["minecraft:ice", "minecraft:snow"],
"minecraft:beehive": ["minecraft:bee_nest"], "minecraft:cocoa": ["minecraft:cocoa"],
"minecraft:leave_vine": ["minecraft:vine"], "minecraft:trunk_vine": ["minecraft:vine"],
}
# Loot tables that Minecraft's structure and feature code references directly (not via templates).
CODE_LOOT = {
"minecraft:desert_pyramid": ["chests/desert_pyramid", "archaeology/desert_pyramid"],
"minecraft:jungle_temple": ["chests/jungle_temple", "chests/jungle_temple_dispenser"],
"minecraft:igloo": ["chests/igloo_chest"],
"minecraft:shipwreck": ["chests/shipwreck_map", "chests/shipwreck_supply", "chests/shipwreck_treasure"],
"minecraft:ocean_ruin": ["chests/underwater_ruin_big", "chests/underwater_ruin_small"],
"minecraft:buried_treasure": ["chests/buried_treasure"],
"minecraft:stronghold": ["chests/stronghold_corridor", "chests/stronghold_crossing", "chests/stronghold_library"],
"minecraft:mineshaft": ["chests/abandoned_mineshaft"],
"minecraft:fortress": ["chests/nether_bridge"],
"minecraft:woodland_mansion": ["chests/woodland_mansion"],
"minecraft:end_city": ["chests/end_city_treasure"],
"minecraft:ruined_portal": ["chests/ruined_portal"],
# placed features
"minecraft:monster_room": ["chests/simple_dungeon"], "minecraft:monster_room_deep": ["chests/simple_dungeon"],
"minecraft:desert_well": ["archaeology/desert_well"],
}
# Notable blocks that code-built structures place (only those that matter as a resource).
CODE_STRUCTURE_BLOCKS = {
"minecraft:ocean_monument": ["minecraft:gold_block", "minecraft:wet_sponge", "minecraft:sea_lantern",
"minecraft:prismarine", "minecraft:prismarine_bricks", "minecraft:dark_prismarine"],
"minecraft:mineshaft": ["minecraft:rail", "minecraft:cobweb", "minecraft:oak_planks", "minecraft:oak_fence"],
"minecraft:desert_pyramid": ["minecraft:tnt", "minecraft:suspicious_sand"],
"minecraft:stronghold": ["minecraft:end_portal_frame", "minecraft:bookshelf", "minecraft:iron_bars"],
"minecraft:jungle_temple": ["minecraft:dispenser", "minecraft:tripwire_hook", "minecraft:redstone_wire"],
"minecraft:ocean_ruin": ["minecraft:suspicious_sand", "minecraft:suspicious_gravel"],
"minecraft:ruined_portal": ["minecraft:obsidian", "minecraft:crying_obsidian", "minecraft:netherrack",
"minecraft:magma_block", "minecraft:gold_block"],
}
# Mobs that Minecraft spawns from code rather than biome spawners (kind, requirement, note).
SPECIAL_MOBS = {
"minecraft:villager": ("structure", "#minecraft:village", "lives in villages"),
"minecraft:iron_golem": ("structure", "#minecraft:village", "spawns in villages"),
"minecraft:cat": ("structure", "#minecraft:village", "strays in villages"),
"minecraft:wandering_trader": ("overworld", None, "visits players"),
"minecraft:trader_llama": ("overworld", None, "comes with the wandering trader"),
"minecraft:phantom": ("overworld", None, "attacks players who have not slept for three nights"),
"minecraft:pillager": ("overworld", None, "patrols"),
"minecraft:vindicator": ("structure", "#minecraft:village", "raids"),
"minecraft:evoker": ("structure", "#minecraft:village", "raids"),
"minecraft:ravager": ("structure", "#minecraft:village", "raids"),
"minecraft:witch": ("structure", "#minecraft:village", "raids"),
"minecraft:skeleton_horse": ("overworld", None, "skeleton traps in thunderstorms"),
"minecraft:zombie_villager": ("overworld", None, "zombie variant"),
"minecraft:warden": ("biome", "minecraft:deep_dark", "summoned by sculk shriekers"),
"minecraft:elder_guardian": ("structure", "minecraft:monument", "ocean monuments"),
"minecraft:ender_dragon": ("biome", "minecraft:the_end", "the End"),
"minecraft:shulker": ("structure", "minecraft:end_city", "End cities"),
"minecraft:breeze": ("structure", "minecraft:trial_chambers", "trial spawners"),
"minecraft:sniffer": ("item", "minecraft:sniffer_egg", "hatched from a sniffer egg"),
}
class Island:
def __init__(self, stack, tags, log=print):
self.stack, self.tags, self.log = stack, tags, log
self.btags = tags["worldgen/biome"]
self.notes = []
self._load_biomes()
self._load_structures()
self._load_features()
self._load_mobs()
# --- dimensions and biomes -----------------------------------------------------------
def _load_biomes(self):
cfg = json.loads((ROOT / "world/biomes.json").read_text())
world = json.loads((ROOT / "world/world.json").read_text())
self.world_type = world.get("level_type", "minecraft:normal")
preset = self.stack.get("worldgen/world_preset", self.world_type) or {}
dims = preset.get("dimensions", {})
self.dimensions = sorted(dims) or ["minecraft:overworld", "minecraft:the_nether", "minecraft:the_end"]
water = [norm_id(b) for b in cfg.get("water", [])]
allowed = [norm_id(b) for b in cfg.get("allowed", [])]
preset_biomes = set(re.findall(r'"biome":\s*"([^"]+)"', json.dumps(dims.get("minecraft:overworld", {}))))
extra = preset_biomes - set(allowed) - set(water)
if extra:
self.notes.append(f"world preset lists biomes not in world/biomes.json: {sorted(extra)}")
added_caves = {norm_id(a["biome"]) for a in cfg.get("add", [])
if a.get("parameters", {}).get("depth", [0, 0])[0] > 0}
caves = self.btags.resolve_all(["#c:is_underground", "#c:is_cave"]) | added_caves
self.biomes = {}
for b in allowed + water:
kind = "water" if b in water else "cave" if b in caves else "surface"
self.biomes[b] = {"dimension": "minecraft:overworld", "kind": kind, "status": "available"}
if "minecraft:the_end" in dims or not dims:
for b in END_BIOMES:
self.biomes[b] = {"dimension": "minecraft:the_end", "kind": "end", "status": "conditional",
"reason": "the End: needs a stronghold portal and Eyes of Ender"}
if "minecraft:the_nether" in dims or not dims:
for b in NETHER_BIOMES:
self.biomes.setdefault(b, {"dimension": "minecraft:the_nether", "kind": "nether", "status": "available"})
self.overworld = {b for b, v in self.biomes.items() if v["dimension"] == "minecraft:overworld"}
self.has_nether = "minecraft:the_nether" in dims
def biome_status(self, biomes):
best = "unavailable"
for b in biomes:
s = self.biomes.get(b, {}).get("status", "unavailable")
if RANK[s] > RANK[best]:
best = s
return best
def island_biomes(self, biomes):
return sorted(b for b in biomes if b in self.biomes)
# --- structures ------------------------------------------------------------------------
def _load_structures(self):
in_set, rings = set(), set()
for sid, rel in self.stack.ids("worldgen/structure_set").items():
d = self.stack.json(rel) or {}
members = [s["structure"] for s in d.get("structures", [])] if isinstance(d.get("structures"), list) else []
in_set.update(norm_id(m) for m in members)
if d.get("placement", {}).get("type") == "minecraft:concentric_rings":
rings.update(norm_id(m) for m in members)
stags = self.tags["worldgen/structure"]
self.structures = {}
for sid, rel in self.stack.ids("worldgen/structure").items():
d = self.stack.json(rel) or {}
biomes = self.btags.resolve_all(d.get("biomes", []))
here = self.island_biomes(biomes)
status, reason = self.biome_status(here), ""
if sid not in in_set:
status, reason = "unavailable", "not in any structure set (never generates)"
elif not here:
reason = "its biomes are not on the island"
elif sid in rings and status == "available":
status, reason = "conditional", "generates in rings 1,280+ blocks from the origin; may be outside the world border"
elif status == "conditional":
reason = "only in the End"
self.structures[sid] = {"type": d.get("type"), "biomes": here, "status": status, "reason": reason,
"tags": [], "loot": set(), "blocks": set(), "mobs": set(), "data": d}
for tag in stags.all_tags():
for sid in stags.resolve(tag):
if sid in self.structures:
self.structures[sid]["tags"].append(tag)
for sid, s in self.structures.items():
if s["status"] == "unavailable":
continue
contents = self.walk(s["data"], start=sid)
s["loot"] |= contents["loot"]
s["blocks"] |= contents["blocks"]
s["mobs"] |= contents["entities"]
s["loot"] |= {f"minecraft:{p}" for p in CODE_LOOT.get(s["type"], [])}
if s["type"] == "minecraft:ocean_ruin":
s["loot"].add(f"minecraft:archaeology/ocean_ruin_{s['data'].get('biome_temp', 'cold')}")
s["blocks"] |= set(CODE_STRUCTURE_BLOCKS.get(sid, []) + CODE_STRUCTURE_BLOCKS.get(s["type"], []))
for cat in s["data"].get("spawn_overrides", {}).values():
s["mobs"] |= {norm_id(sp["type"]) for sp in cat.get("spawns", [])}
def structure_status(self, refs):
"""Best status over structure ids or #tags (for spawn conditions and mob rules)."""
stags = self.tags["worldgen/structure"]
best, which = "unavailable", []
for ref in refs:
for sid in stags.resolve(ref):
s = self.structures.get(sid)
if s and RANK[s["status"]] > 0:
which.append(sid)
if RANK[s["status"]] > RANK[best]:
best = s["status"]
return best, sorted(set(which))
def walk(self, obj, start=""):
"""Follow template pools, processor lists, NBT templates and features from a JSON object;
collect loot tables, palette blocks and entity ids. Unknown strings are ignored."""
out = {"loot": set(), "blocks": set(), "entities": set()}
replaced = set() # template marker blocks that rule processors always swap out
rules = [] # (input block or None, output blocks) of rule processors
loot_ids = self.stack.ids("loot_table")
seen = set()
todo = list(_strings(obj))
while todo:
s = todo.pop()
if not isinstance(s, str) or ":" not in s and "/" not in s or s in seen:
continue
seen.add(s)
rid = norm_id(s)
if rid in loot_ids:
out["loot"].add(rid)
continue
for kind in ("worldgen/template_pool", "worldgen/processor_list", "worldgen/configured_feature",
"worldgen/placed_feature"):
d = self.stack.get(kind, rid)
if d is not None:
todo.extend(_strings(d))
if kind.endswith("feature"):
out["blocks"] |= _block_names(d)
elif kind.endswith("processor_list"):
found_rules, removed = _processor_blocks(d)
rules.extend(found_rules)
replaced |= removed
nbt_rel = self.stack.resource("structure", rid, ext=".nbt")
if self.stack.exists(nbt_rel):
try:
tpl = nbt.load(self.stack.read(nbt_rel))
except Exception as e: # a broken template must not stop the guide
self.notes.append(f"unreadable structure template {nbt_rel}: {e}")
continue
palettes = tpl.get("palettes") or [tpl.get("palette", [])]
for pal in palettes:
out["blocks"] |= {norm_id(p["Name"]) for p in pal if "Name" in p}
for b in tpl.get("blocks", []):
if "nbt" in b:
todo.extend(nbt.strings(b["nbt"]))
for e in tpl.get("entities", []):
eid = e.get("nbt", {}).get("id")
if eid:
out["entities"].add(norm_id(eid))
grew = True
while grew: # a rule only places its output where its input block exists
before = len(out["blocks"])
for inp, outs in rules:
if inp is None or inp in out["blocks"]:
out["blocks"] |= outs
grew = len(out["blocks"]) > before
out["blocks"] = {b for b in out["blocks"] - replaced if b not in (
"minecraft:air", "minecraft:structure_void", "minecraft:jigsaw", "minecraft:cave_air")}
return out
# --- natural blocks (features + terrain) -------------------------------------------------
def _load_features(self):
biome_features = {b: set() for b in self.biomes}
for b in self.biomes:
d = self.stack.get("worldgen/biome", b) or {}
for step in d.get("features", []):
biome_features[b].update(norm_id(f) for f in step if isinstance(f, str))
for name, refs in COBBLEMON_CODED_FEATURES.items():
# None means every biome of every dimension; the feature's own checks (dirt, water) keep it
# out of the End, so only overworld biomes are counted.
for b in (self.btags.resolve_all(refs) if refs else self.overworld) & set(self.biomes):
biome_features[b].add(f"cobblemon:{name}")
self.biome_mods = []
removed = set()
for mid, rel in self.stack.ids("neoforge/biome_modifier").items():
d = self.stack.json(rel) or {}
t = d.get("type")
if t not in ("neoforge:add_features", "neoforge:remove_features", "neoforge:add_spawns", "neoforge:remove_spawns"):
continue
biomes = self.holder_set(d.get("biomes"), self.btags)
self.biome_mods.append((mid, t, biomes, d))
if t.endswith("_features"):
feats = self.holder_set(d.get("features"), self.tags["worldgen/placed_feature"])
for b in biomes & set(self.biomes):
if t == "neoforge:add_features":
biome_features[b] |= feats
else:
removed |= biome_features[b] & feats
biome_features[b] -= feats
self.biome_features = biome_features
# Blocks that would generate if the pack had not switched their features off (ores):
# features removed by a biome modifier, and placed features overridden to zero placements.
for fid in self.stack.ids("worldgen/placed_feature"):
d = self.stack.get("worldgen/placed_feature", fid) or {}
if any(p.get("type") == "minecraft:count" and p.get("count") == 0 for p in d.get("placement", [])):
removed.add(fid)
self.removed_blocks = {}
for fid in removed:
for blk in self._feature_blocks(fid, ignore_zero=True)[0]:
self.removed_blocks.setdefault(blk, set()).add(fid)
self.natural = {} # block -> {"biomes": set, "via": set}
self.feature_loot = {} # loot table -> {"biomes", "via"}
feature_cache = {}
for b, feats in biome_features.items():
for f in feats:
if f not in feature_cache:
feature_cache[f] = self._feature_blocks(f)
blocks, filt, loot = feature_cache[f]
if filt and not filt(b):
continue
for blk in blocks:
n = self.natural.setdefault(blk, {"biomes": set(), "via": set()})
n["biomes"].add(b)
n["via"].add(f)
for lt in loot:
n = self.feature_loot.setdefault(lt, {"biomes": set(), "via": set()})
n["biomes"].add(b)
n["via"].add(f)
# terrain: whatever the noise settings place (stone, deepslate, sand, terracotta, ...)
for dim, biomes in (("minecraft:overworld", self.overworld),
("minecraft:the_end", {b for b in self.biomes if self.biomes[b]["kind"] == "end"})):
preset = self.stack.get("worldgen/world_preset", self.world_type) or {}
settings = preset.get("dimensions", {}).get(dim, {}).get("generator", {}).get("settings")
d = settings if isinstance(settings, dict) else self.stack.get("worldgen/noise_settings", settings or "")
for blk in _block_names(d or {}):
n = self.natural.setdefault(blk, {"biomes": set(), "via": set()})
n["biomes"] |= biomes
n["via"].add("terrain")
for blk in ("minecraft:bedrock", "minecraft:water", "minecraft:lava", "minecraft:grass_block", "minecraft:dirt"):
self.natural.setdefault(blk, {"biomes": set(self.overworld), "via": {"terrain"}})
for sid, s in self.structures.items():
if RANK[s["status"]] == 0:
continue
for blk in s["blocks"]:
n = self.natural.setdefault(blk, {"biomes": set(), "via": set()})
n["via"].add(f"structure:{sid}")
def _feature_blocks(self, fid, ignore_zero=False):
"""(blocks, biome filter, loot tables) for a placed feature, or nothing if it was zeroed."""
d = self.stack.get("worldgen/placed_feature", fid)
if d is None:
return set(), None, set()
placement = d.get("placement", [])
if not ignore_zero and any(p.get("type") == "minecraft:count" and p.get("count") == 0 for p in placement):
return set(), None, set()
included, excluded = self._biome_predicates(d)
blocks, loot, seen = set(), set(), set()
todo = [d.get("feature")]
while todo:
f = todo.pop()
if isinstance(f, str):
if f in seen:
continue
seen.add(f)
f = self.stack.get("worldgen/configured_feature", f) or self.stack.get("worldgen/placed_feature", f)
if not isinstance(f, dict):
continue
blocks |= _block_names(f)
inc, exc = self._biome_predicates(f)
included = included if inc is None else (inc if included is None else included & inc)
excluded |= exc
for node in _dicts(f):
extra = CODE_FEATURE_BLOCKS.get(node.get("type"))
if isinstance(extra, list):
blocks |= self.tags["block"].resolve_all(extra)
elif isinstance(extra, str):
pat = re.compile(extra)
blocks |= {k.replace(":blocks/", ":") for k in self.stack.ids("loot_table")
if ":blocks/" in k and pat.fullmatch(k.replace(":blocks/", ":"))}
if node.get("type") == "minecraft:multiface_growth" and isinstance(node.get("config", {}).get("block"), str):
blocks.add(norm_id(node["config"]["block"]))
if f.get("type") in ("cobblemon:structure", "minecraft:fossil"):
w = self.walk(f.get("config", {}))
blocks |= w["blocks"]
loot |= w["loot"]
for s in _strings(f):
if s != fid and (self.stack.get("worldgen/configured_feature", s) is not None
or self.stack.get("worldgen/placed_feature", s) is not None):
todo.append(s)
loot |= {f"minecraft:{p}" for p in CODE_LOOT.get(fid, [])}
def filt(b):
return (included is None or b in included) and b not in excluded
return blocks, (filt if included is not None or excluded else None), loot
def _biome_predicates(self, d):
"""Cobblemon's 'cobblemon:biome' predicates (includedBiomes/excludedBiomes) anywhere in a feature."""
inc, exc = None, set()
for node in _dicts(d):
if node.get("type") == "cobblemon:biome":
if node.get("includedBiomes"):
s = self.btags.resolve_all(node["includedBiomes"])
inc = s if inc is None else inc | s
exc |= self.btags.resolve_all(node.get("excludedBiomes", []))
return inc, exc
def holder_set(self, value, tags):
"""NeoForge holder sets: id, #tag, list, or {"type": "neoforge:any"|"and"|"or"|"not", ...}."""
if value is None:
return set()
if isinstance(value, str):
return tags.resolve(value) if value.startswith("#") else {norm_id(value)}
if isinstance(value, list):
out = set()
for v in value:
out |= self.holder_set(v, tags)
return out
t = value.get("type")
if t == "neoforge:any":
return set(self.biomes) if tags is self.btags else {"*"}
if t == "neoforge:or":
return set().union(*(self.holder_set(v, tags) for v in value.get("values", [])))
if t == "neoforge:and":
sets = [self.holder_set(v, tags) for v in value.get("values", [])]
return set.intersection(*sets) if sets else set()
if t == "neoforge:not":
base = set(self.biomes) if tags is self.btags else set()
return base - self.holder_set(value.get("value"), tags)
return set()
def natural_status(self, block):
n = self.natural.get(block)
if not n:
return "unavailable", []
biomes = self.island_biomes(n["biomes"])
st = self.biome_status(biomes)
for via in n["via"]:
if via.startswith("structure:"):
s = self.structures[via[10:]]["status"]
if RANK[s] > RANK[st]:
st = s
return st, biomes
# --- mobs --------------------------------------------------------------------------------
def _load_mobs(self):
self.mobs = {} # entity -> list of {"via": text, "status": s, "biomes": [...]} (item-gated ones resolved later)
for b in self.biomes:
d = self.stack.get("worldgen/biome", b) or {}
for cat, spawns in d.get("spawners", {}).items():
for sp in spawns:
self._add_mob(norm_id(sp["type"]), "biome", b, self.biomes[b]["status"])
for mid, t, biomes, d in self.biome_mods:
if t == "neoforge:add_spawns":
sps = d.get("spawners", [])
for sp in sps if isinstance(sps, list) else [sps]:
for b in biomes & set(self.biomes):
self._add_mob(norm_id(sp["type"]), "biome", b, self.biomes[b]["status"])
for sid, s in self.structures.items():
if RANK[s["status"]]:
for m in s["mobs"]:
self._add_mob(m, "structure", sid, s["status"])
self.item_mobs = {}
for mob, (kind, req, note) in SPECIAL_MOBS.items():
if kind == "overworld":
self._add_mob(mob, "special", note, "available")
elif kind == "biome" and req in self.biomes:
self._add_mob(mob, "special", note, self.biomes[req]["status"])
elif kind == "structure":
st, which = self.structure_status([req])
if RANK[st]:
self._add_mob(mob, "special", note, st)
elif kind == "item":
self.item_mobs[mob] = (req, note)
def _add_mob(self, mob, kind, where, status):
lst = self.mobs.setdefault(mob, [])
for e in lst:
if e["kind"] == kind and kind == "biome":
e["where"].add(where)
if RANK[status] > RANK[e["status"]]:
e["status"] = status
return
lst.append({"kind": kind, "where": {where}, "status": status})
def mob_status(self, mob):
best = "unavailable"
for e in self.mobs.get(mob, []):
if RANK[e["status"]] > RANK[best]:
best = e["status"]
return best
def _strings(obj):
if isinstance(obj, str):
yield obj
elif isinstance(obj, dict):
for v in obj.values():
yield from _strings(v)
elif isinstance(obj, list):
for v in obj:
yield from _strings(v)
def _dicts(obj):
if isinstance(obj, dict):
yield obj
for v in obj.values():
yield from _dicts(v)
elif isinstance(obj, list):
for v in obj:
yield from _dicts(v)
def _processor_blocks(plist):
"""([(input block or None, output blocks)], marker blocks the list always replaces)."""
rules, replaced = [], set()
procs = plist.get("processors", []) if isinstance(plist, dict) else plist
for proc in procs if isinstance(procs, list) else []:
if proc.get("processor_type") == "minecraft:block_ignore":
replaced |= _block_names(proc)
for rule in proc.get("rules", []):
pred = rule.get("input_predicate", {})
inp = norm_id(pred["block"]) if isinstance(pred.get("block"), str) else None
if pred.get("predicate_type") == "minecraft:blockstate_match":
inp = next(iter(_block_names(pred)), None)
rules.append((inp, _block_names(rule.get("output_state", {}))))
certain = pred.get("predicate_type") in ("minecraft:block_match", "minecraft:blockstate_match") or (
pred.get("predicate_type") == "minecraft:random_block_match" and pred.get("probability", 0) >= 1)
if certain and inp:
replaced.add(inp)
return rules, replaced
def _block_names(obj):
"""Block ids a feature or surface rule places: every {"Name": ...} block state in it."""
return {norm_id(d["Name"]) for d in _dicts(obj) if isinstance(d.get("Name"), str)}
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Build the island world template from world/world.json.
1. Prepare the local test server (build/server) with the pack, plus Chunky
(a pregeneration tool, added only for this run and removed afterwards).
2. Create the world (named after the recipe's "name") with its seed, world
type and gamerules, and pregenerate a circle of `pregen_radius` around
0,0 (the island plus a strip of sea; the sea corners inside the square
border generate when someone gets there).
3. In the same session: save, measure where the land ends
(tools/world_report.py) and set a square world border `border_margin`
blocks outside the outermost coast.
4. Copy the world to build/world-template without player data, then run
the world checks and render the map (build/island-map.png).
The test server's eula.txt must already accept the Minecraft EULA (run
`tools/smoke_test.py --accept-eula` once); this script never writes it.
For a quick check after a change, use tools/quick_check.py instead.
Usage: tools/make_world.py
"""
import argparse
import json
import shutil
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from mcserver import BUILD, ROOT, SERVER, Server, chunky, eula_accepted, prepare, scan_log # noqa: E402
import world_report # noqa: E402
from numpy import hypot as np_hypot # noqa: E402
TEMPLATE = BUILD / "world-template"
SKIP = shutil.ignore_patterns("playerdata", "advancements", "stats", "session.lock")
def border_for(world, recipe):
"""Square border around the land, `border_margin` past the outermost coast."""
data = world_report.analyse(world)
land = world_report.island_mask(data)
zs, xs = land.nonzero()
ox, oz = data["origin"]
x0, x1 = int(xs.min()) + ox, int(xs.max()) + ox + 1
z0, z1 = int(zs.min()) + oz, int(zs.max()) + oz + 1
far = float(np_hypot(xs + ox, zs + oz).max())
if far + recipe["border_margin"] > recipe["pregen_radius"]:
sys.exit(f"land reaches {far:.0f} blocks from 0,0; raise pregen_radius ({recipe['pregen_radius']}) in "
f"world/world.json to at least land + border_margin")
cx, cz = round((x0 + x1) / 2), round((z0 + z1) / 2)
size = max(x1 - x0, z1 - z0) + 2 * recipe["border_margin"]
print(f"land spans x {x0}..{x1}, z {z0}..{z1}, at most {far:.0f} blocks from 0,0; "
f"border center {cx},{cz} size {size}")
return cx, cz, size
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.parse_args()
if not eula_accepted():
sys.exit("build/server/eula.txt does not accept the Minecraft EULA yet; run "
"tools/smoke_test.py --accept-eula first if you accept it.")
recipe = json.loads((ROOT / "world/world.json").read_text())
args_file = prepare()
world = SERVER / recipe["name"]
shutil.rmtree(world, ignore_errors=True)
props = {"level-name": recipe["name"], "level-seed": recipe["seed"], "level-type": recipe["level_type"]}
with chunky(recipe), Server(args_file, props, heap="8G", stdout_name="make-world-stdout.log") as server:
print(f"server started in {server.wait_ready():.0f}s")
status = server.command("worldgen_editor status")
if "cobblesurvival_island" not in status:
raise RuntimeError(f"world did not get the island world type:\n{status}")
for rule, value in recipe.get("gamerules", {}).items():
print(f" {server.command(f'gamerule {rule} {str(value).lower()}').strip()}")
seconds = server.pregenerate(0, 0, recipe["pregen_radius"], shape="circle")
print(f"pregeneration finished in {seconds:.0f}s")
server.command("save-all flush")
cx, cz, size = border_for(world, recipe)
print(f" {server.command(f'worldborder center {cx} {cz}').strip()}")
print(f" {server.command(f'worldborder set {size}').strip()}")
server.command("save-all flush")
server.stop()
problems = scan_log(server.log)
if problems:
print("server log problems:\n" + "\n".join(f" - {p}" for p in problems))
shutil.rmtree(TEMPLATE, ignore_errors=True)
shutil.copytree(world, TEMPLATE, ignore=SKIP)
size = sum(f.stat().st_size for f in TEMPLATE.rglob("*") if f.is_file())
print(f"world template: {TEMPLATE.relative_to(ROOT)} ({size / 1e6:.0f} MB)")
result = subprocess.run([sys.executable, "-I", str(ROOT / "tools/world_report.py"), str(TEMPLATE)])
sys.exit(result.returncode or (1 if problems else 0))
if __name__ == "__main__":
main()
+260
View File
@@ -0,0 +1,260 @@
"""The local test server in build/server, shared by the smoke test and the
world builder: install, configure, start, talk to over RCON, stop."""
import contextlib
import hashlib
import json
import re
import secrets
import shutil
import socket
import struct
import subprocess
import sys
import time
import tomllib
import urllib.request
from pathlib import Path
from packwiz_sync import java, sync
ROOT = Path(__file__).resolve().parent.parent
BUILD = ROOT / "build"
SERVER = BUILD / "server"
CACHE = BUILD / ".cache"
PACK = tomllib.loads((ROOT / "pack/pack.toml").read_text())
class Rcon:
"""Minimal Source RCON client (the protocol Minecraft speaks)."""
def __init__(self, port, password):
self.sock = socket.create_connection(("127.0.0.1", port), timeout=120)
self.next_id = 0
self._send(3, password)
if self._recv()[0] == -1:
raise RuntimeError("RCON authentication failed")
def _send(self, kind, body):
self.next_id += 1
payload = struct.pack("<ii", self.next_id, kind) + body.encode() + b"\0\0"
self.sock.sendall(struct.pack("<i", len(payload)) + payload)
def _read(self, n):
data = b""
while len(data) < n:
chunk = self.sock.recv(n - len(data))
if not chunk:
raise ConnectionError("RCON connection closed")
data += chunk
return data
def _recv(self):
length = struct.unpack("<i", self._read(4))[0]
packet = self._read(length)
request_id, _kind = struct.unpack("<ii", packet[:8])
return request_id, packet[8:-2].decode("utf-8", "replace")
def command(self, cmd):
self._send(2, cmd)
return self._recv()[1]
def close(self):
self.sock.close()
def free_port():
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def download(url, dest):
if not dest.exists():
dest.parent.mkdir(parents=True, exist_ok=True)
with urllib.request.urlopen(url, timeout=120) as r, open(dest, "wb") as f:
shutil.copyfileobj(r, f)
return dest
def prepare():
"""Install NeoForge and the pack's server-side mods; check dependency ranges. Returns the args file."""
version = PACK["versions"]["neoforge"]
SERVER.mkdir(parents=True, exist_ok=True)
args_file = SERVER / "libraries/net/neoforged/neoforge" / version / "unix_args.txt"
if not args_file.exists():
installer = download(f"https://maven.neoforged.net/releases/net/neoforged/neoforge/{version}/"
f"neoforge-{version}-installer.jar", CACHE / f"neoforge-{version}-installer.jar")
subprocess.run([java(), "-jar", str(installer), "--install-server", str(SERVER)],
cwd=SERVER, check=True, stdout=subprocess.DEVNULL)
sync("server", SERVER, BUILD / "stage" / "full")
subprocess.run([sys.executable, "-I", str(ROOT / "tools/check_deps.py"), str(SERVER / "mods"),
"--neoforge", version, "--minecraft", PACK["versions"]["minecraft"]], check=True)
return args_file
@contextlib.contextmanager
def chunky(recipe):
"""Put the pinned Chunky jar (world/world.json) into the test server's mods for the duration."""
url, sha1 = recipe["chunky"]["url"], recipe["chunky"]["sha1"]
jar = download(url, CACHE / url.rsplit("/", 1)[1])
if hashlib.sha1(jar.read_bytes()).hexdigest() != sha1:
jar.unlink()
sys.exit(f"Chunky download does not match the pinned SHA-1 {sha1}")
target = SERVER / "mods" / jar.name
shutil.copy2(jar, target)
# A task left by an interrupted run makes `chunky start` ask about it instead of starting.
shutil.rmtree(SERVER / "config/chunky/tasks", ignore_errors=True)
try:
yield
finally:
target.unlink(missing_ok=True)
def eula_accepted():
eula = SERVER / "eula.txt"
return eula.exists() and "eula=true" in eula.read_text()
def scan_log(log):
"""Log lines that look like failures and match no entry in tools/smoke_allowlist.txt.
Also reads KubeJS's own logs next to the server log (logs/kubejs/*.log), where script
errors end up instead of the main log."""
allow = [re.compile(line) for line in (ROOT / "tools/smoke_allowlist.txt").read_text().splitlines()
if line.strip() and not line.startswith("#")]
bad = re.compile(r"/(ERROR|FATAL)\]|Missing or unsupported mandatory dependencies|"
r"Mod loading has failed|Failed to load datapack|Couldn't parse data file|"
r"Couldn't load|Exception in server tick loop|Failed to parse level-type|\[(ERR|ERROR)\s*\]")
lines = log.read_text(errors="replace").splitlines()
for kubejs_log in sorted((log.parent / "kubejs").glob("*.log")):
lines += [f"{kubejs_log.name}: {line}" for line in kubejs_log.read_text(errors="replace").splitlines()]
return [line for line in lines if bad.search(line) and not any(a.search(line) for a in allow)]
# Turns on the companion mod's /cobblesurvival selftest command (it is not there for players).
SELFTEST_JVM_ARGS = ["-Dcobblesurvival.selftest=true"]
class Server:
"""One run of the test server. Use as a context manager; the process is killed on exit if still alive."""
def __init__(self, args_file, properties, heap="4G", stdout_name="server-stdout.log", jvm_args=()):
self.args_file = args_file
self.jvm_args = list(jvm_args)
self.password, self.rcon_port = secrets.token_hex(12), free_port()
props = {
"enable-rcon": "true", "rcon.port": str(self.rcon_port), "rcon.password": self.password,
"server-port": str(free_port()), "server-ip": "127.0.0.1", "online-mode": "false",
"motd": "Cobblemon Survival Pack test server", "max-tick-time": "-1", "spawn-protection": "0",
"level-name": "world",
}
props.update(properties)
(SERVER / "server.properties").write_text("".join(f"{k}={v}\n" for k, v in props.items()))
self.log = SERVER / "logs/latest.log"
self.log.unlink(missing_ok=True)
self.heap = heap
self.stdout_name = stdout_name
self.proc = None
self.rcon = None
def __enter__(self):
cmd = [java(), f"-Xmx{self.heap}", *self.jvm_args, f"@{self.args_file.relative_to(SERVER)}", "nogui"]
self.started = time.time()
with open(SERVER / self.stdout_name, "w") as out:
self.proc = subprocess.Popen(cmd, cwd=SERVER, stdin=subprocess.DEVNULL,
stdout=out, stderr=subprocess.STDOUT)
return self
def wait_for(self, pattern, timeout):
deadline = time.time() + timeout
while time.time() < deadline:
if self.proc.poll() is not None:
return False
if self.log.exists() and re.search(pattern, self.log.read_text(errors="replace")):
return True
time.sleep(2)
return False
def wait_ready(self, timeout=900):
if not self.wait_for(r"Done \(\d", timeout):
raise RuntimeError(f"server did not finish starting (exit code {self.proc.poll()}); see {self.log}")
self.rcon = Rcon(self.rcon_port, self.password)
return time.time() - self.started
def command(self, cmd):
return self.rcon.command(cmd)
def count(self, pattern):
return len(re.findall(pattern, self.log.read_text(errors="replace"))) if self.log.exists() else 0
def pregenerate(self, x, z, radius, shape="square", report_every=60):
"""Run one Chunky task around x,z and wait until it is done; returns the seconds it took."""
done = r"Task finished for minecraft:overworld"
before = self.count(done)
for cmd in ("chunky world minecraft:overworld", f"chunky center {x} {z}", f"chunky shape {shape}",
f"chunky radius {radius}", "chunky start"):
reply = self.command(cmd).strip()
if cmd == "chunky start" and "task started" not in reply.lower():
raise RuntimeError(f"Chunky did not start: {reply}")
started, last, polled = time.time(), time.time(), time.time()
while self.count(done) <= before:
if self.proc.poll() is not None:
raise RuntimeError(f"server stopped during pregeneration; see {self.log}")
if time.time() - polled > 10:
polled = time.time()
if "no tasks running" in self.command("chunky progress").lower() and self.count(done) <= before:
raise RuntimeError("Chunky stopped without finishing the task")
if report_every and time.time() - last > report_every:
progress = re.findall(r"Task running for minecraft:overworld\. (.*)", self.log.read_text(errors="replace"))
print(f" {time.time() - started:5.0f}s {progress[-1] if progress else 'starting'}")
last = time.time()
time.sleep(1)
return time.time() - started
def run_checks(self, checks_file=ROOT / "tools/smoke_checks.json"):
"""Run the RCON checks from tools/smoke_checks.json; returns the failures."""
failures = []
for check in json.loads(checks_file.read_text()):
for cmd in check.get("setup", []):
self.command(cmd)
reply = self.command(check["command"])
for cmd in check.get("cleanup", []):
self.command(cmd)
ok = re.search(check["expect"], reply) is not None
label = check.get("name", "/" + check["command"])
print(f" [{'ok' if ok else 'FAIL'}] {label}: {reply[:200]!r}")
if not ok:
failures.append(f"{label}: {check['command']!r} did not match {check['expect']!r}")
return failures
def run_selftest(self, timeout=300):
"""Run the companion mod's in-game tests (needs SELFTEST_JVM_ARGS); returns the failures."""
reply = self.command("cobblesurvival selftest run").strip()
if "started" not in reply:
return [f"selftest did not start: {reply!r}"]
print(f" {reply}")
deadline = time.time() + timeout
while time.time() < deadline:
time.sleep(2)
status = self.command("cobblesurvival selftest status").strip()
if status.startswith("selftest finished"):
for line in re.findall(r"selftest (PASS \S+|FAIL .*)", self.log.read_text(errors="replace")):
print(f" [{'ok' if line.startswith('PASS') else 'FAIL'}] {line[5:]}")
return [] if "FAILED" not in status else [status]
if self.proc.poll() is not None:
return [f"server stopped during the selftest; see {self.log}"]
return [f"selftest did not finish in {timeout}s: {self.command('cobblesurvival selftest status').strip()}"]
def stop(self, timeout=300):
if self.rcon:
self.rcon.command("stop")
self.rcon.close()
self.rcon = None
self.proc.wait(timeout=timeout)
def __exit__(self, *exc):
if self.rcon:
self.rcon.close()
if self.proc and self.proc.poll() is None:
self.proc.kill()
self.proc.wait()
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Install or update a packwiz pack into a folder with packwiz-installer.
packwiz-installer wants a URL, so the pack folder is served on 127.0.0.1 for
the duration of the sync. It downloads Modrinth and CurseForge files from
their own CDNs and removes files that left the pack since the last sync.
Usage: tools/packwiz_sync.py <client|server> <target_dir> [pack_dir]
(pack_dir defaults to build/stage/full)
"""
import functools
import http.server
import shutil
import subprocess
import sys
import threading
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
CACHE = ROOT / "build" / ".cache"
# Pinned; the bootstrap runs with --bootstrap-no-update so neither self-updates.
TOOLS = {
"packwiz-installer-0.5.14.jar":
"https://github.com/packwiz/packwiz-installer/releases/download/v0.5.14/packwiz-installer.jar",
"packwiz-installer-bootstrap-0.0.3.jar":
"https://github.com/packwiz/packwiz-installer-bootstrap/releases/download/v0.0.3/packwiz-installer-bootstrap.jar",
}
def cached(name):
jar = CACHE / name
if not jar.exists():
CACHE.mkdir(parents=True, exist_ok=True)
with urllib.request.urlopen(TOOLS[name], timeout=120) as r, open(jar, "wb") as f:
shutil.copyfileobj(r, f)
return jar
def java():
for candidate in (Path.home() / ".var/app/org.prismlauncher.PrismLauncher/data/PrismLauncher/java/java-runtime-delta/bin/java",):
if candidate.exists():
return str(candidate)
return shutil.which("java")
class QuietHandler(http.server.SimpleHTTPRequestHandler):
def log_message(self, *args):
pass
def sync(side, target, pack_dir):
target.mkdir(parents=True, exist_ok=True)
handler = functools.partial(QuietHandler, directory=str(pack_dir))
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
try:
url = f"http://127.0.0.1:{server.server_port}/pack.toml"
subprocess.run([java(), "-jar", str(cached("packwiz-installer-bootstrap-0.0.3.jar")),
"--bootstrap-no-update",
"--bootstrap-main-jar", str(cached("packwiz-installer-0.5.14.jar")),
"-g", "-s", side, url],
cwd=target, check=True)
finally:
server.shutdown()
def main():
if len(sys.argv) < 3 or sys.argv[1] not in ("client", "server"):
sys.exit(__doc__)
pack_dir = Path(sys.argv[3]) if len(sys.argv) > 3 else ROOT / "build" / "stage" / "full"
sync(sys.argv[1], Path(sys.argv[2]).resolve(), pack_dir.resolve())
if __name__ == "__main__":
main()
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Write the WorldGen Editor preset for a Poké Ball-shaped island.
* Ball: one round island with a smooth coast.
* Band: two flat-ended ocean strips across the middle, west and east of the
button, so the ball splits into a top and a bottom half.
* Button: a ring channel of overlapping small ocean circles around a round
centre island (where the world spawn lands).
All sizes are in blocks and are placeholders until Adrian approves the
island (docs/balance.md).
Usage: tools/pokeball_preset.py [--radius 1500] [--band 160] [--button 280] [--ring 120]
"""
import argparse
import json
import math
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PRESET = ROOT / "pack/config/worldgen_editor/presets/cobblesurvival_island.json"
def ocean(name, x, z, rx, rz, shape_power=2.0):
return {"type": "ocean", "name": name, "x": round(x), "z": round(z), "radius_x": round(rx), "radius_z": round(rz),
"shape_power": shape_power, "roughness": 0.02, "shore_width": 0.12, "temperature": "standard",
"noise": {"seed": name.lower().replace(" ", "_"), "scale": 2.0}}
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--radius", type=int, default=1500, help="radius of the ball")
parser.add_argument("--band", type=int, default=160, help="width of the channel across the middle")
parser.add_argument("--button", type=int, default=280, help="radius of the centre island")
parser.add_argument("--ring", type=int, default=120, help="width of the ring channel around the button")
args = parser.parse_args()
entries = [{
"type": "island", "name": "Ball", "x": 0, "z": 0, "radius": args.radius,
"shape_power": 2.0, "roughness": 0.03, "shore_width": 0.1, "temperature": "standard",
"noise": {"seed": "ball", "scale": 2.0, "first_octave": -1, "amplitudes": [1.0, 0.45, 0.18]},
}]
ring_mid = args.button + args.ring / 2
# The band runs from the middle of the ring to just past the coast on each side.
band_inner, band_outer = ring_mid, args.radius * 1.05
half = (band_outer - band_inner) / 2
for side, sign in (("west", -1), ("east", 1)):
entries.append(ocean(f"Band {side}", sign * (band_inner + half), 0, half, args.band / 2, shape_power=6.0))
# Circles overlapping by about half their size, so the ring has no gaps.
circle = args.ring / 2
count = math.ceil(2 * math.pi * ring_mid / circle)
for i in range(count):
angle = 2 * math.pi * i / count
entries.append(ocean(f"Ring {i + 1}", ring_mid * math.cos(angle), ring_mid * math.sin(angle), circle, circle))
PRESET.write_text(json.dumps({"outer_ocean": "minecraft:deep_ocean", "entries": entries}, indent=2) + "\n")
print(f"wrote {PRESET.relative_to(ROOT)}: ball r{args.radius}, band {args.band}, "
f"button r{args.button}, ring {args.ring} ({count} circles)")
if __name__ == "__main__":
main()
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Create or update a Prism Launcher instance from build/stage/full.
Writes instance.cfg and mmc-pack.json (Minecraft + NeoForge from pack.toml),
then syncs the client-side files with packwiz-installer. Run tools/build.py
first. Run it again after changing the pack; options and saves in the
instance are left alone. Close the instance in Prism before syncing.
When build/world-template exists (tools/make_world.py), it is copied into the
instance as the save "Cobblemon Survival Island" unless that save already
exists; --replace-world overwrites it.
Usage: tools/prism_instance.py [--name NAME] [--prism-dir DIR] [--memory MB] [--replace-world]
"""
import argparse
import json
import shutil
import sys
import tomllib
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from packwiz_sync import sync # noqa: E402
ROOT = Path(__file__).resolve().parent.parent
PRISM_DIRS = (Path.home() / ".var/app/org.prismlauncher.PrismLauncher/data/PrismLauncher",
Path.home() / ".local/share/PrismLauncher")
def write_cfg(path, name, memory):
settings = {}
if path.exists():
for line in path.read_text().splitlines():
if "=" in line:
key, value = line.split("=", 1)
settings[key] = value
settings.update({
"InstanceType": "OneSix",
"name": name,
"iconKey": settings.get("iconKey", "default"),
"notes": "Dev instance of the Cobblemon Survival Pack, synced by tools/prism_instance.py",
"OverrideMemory": "true",
"MinMemAlloc": "1024",
"MaxMemAlloc": str(memory),
"AutomaticJava": "true",
})
body = "[General]\n" + "".join(f"{k}={v}\n" for k, v in sorted(settings.items()) if k != "[General]")
path.write_text(body)
def write_components(path, versions):
path.write_text(json.dumps({
"components": [
{"uid": "net.minecraft", "version": versions["minecraft"], "important": True},
{"uid": "net.neoforged", "version": versions["neoforge"]},
],
"formatVersion": 1,
}, indent=4) + "\n")
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--name", default="Cobblemon Survival Pack (dev)")
parser.add_argument("--prism-dir", type=Path, help="Prism data folder (auto-detected)")
parser.add_argument("--memory", type=int, default=6144, help="max heap in MB")
parser.add_argument("--replace-world", action="store_true", help="overwrite the island save in the instance")
args = parser.parse_args()
prism = args.prism_dir or next((d for d in PRISM_DIRS if (d / "instances").is_dir()), None)
if not prism:
sys.exit("Prism Launcher data folder not found; pass --prism-dir")
stage = ROOT / "build/stage/full"
if not (stage / "pack.toml").exists():
sys.exit("build/stage/full is missing; run tools/build.py first")
instance = prism / "instances" / args.name
game = instance / "minecraft"
game.mkdir(parents=True, exist_ok=True)
pack = tomllib.loads((stage / "pack.toml").read_text())
write_cfg(instance / "instance.cfg", args.name, args.memory)
write_components(instance / "mmc-pack.json", pack["versions"])
sync("client", game, stage)
template, save = ROOT / "build/world-template", game / "saves" / "Cobblemon Survival Island"
if (template / "level.dat").exists():
if save.exists() and not args.replace_world:
print(f"kept existing save {save.name} (use --replace-world to overwrite it)")
else:
shutil.rmtree(save, ignore_errors=True)
shutil.copytree(template, save)
print(f"installed world template as save {save.name}")
print(f"Prism instance ready: {instance}")
if __name__ == "__main__":
main()
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""Fast check after a change: one server boot, a sampled world, no full template.
1. Prepare the test server (mods, dependency check) and boot a fresh world
"quick-check" with the recipe's seed, world type and gamerules.
2. Run the smoke checks (tools/smoke_checks.json) and the companion mod's
in-game tests (`/cobblesurvival selftest`, e.g. the permadeath matrix; they
play on a platform high above spawn).
3. `/locate` every structure in world/world.json → quick_check.absent_structures
and fail if one is found (villages, ocean ruins, ... must not generate).
4. `/locate` the sample structures (quick_check.structures) and pregenerate a
square of structure_radius around each, plus spawn_radius around spawn.
5. Force-load the chunks around the sampled village for a few seconds, as a
nearby player would, so Lootr converts its loot chests.
6. Stop, scan the server log, and run tools/world_report.py --sample on the
sampled world: no ore, no vanilla mobs or mob sources, only listed biomes,
gamerules as in the recipe, and no vanilla loot chest left near the village.
The border, strongholds-inside-border and the map need the whole island:
tools/make_world.py checks those when the template is rebuilt.
Usage: tools/quick_check.py [--accept-eula] (run tools/build.py first)
"""
import argparse
import json
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from mcserver import ROOT, SELFTEST_JVM_ARGS, SERVER, Server, chunky, eula_accepted, prepare, scan_log # noqa: E402
LEVEL = "quick-check"
FOUND = re.compile(r"is at \[(-?\d+), [^,\]]+, (-?\d+)\]")
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--accept-eula", action="store_true", help="write eula=true for the test server")
args = parser.parse_args()
if args.accept_eula:
(SERVER / "eula.txt").parent.mkdir(parents=True, exist_ok=True)
(SERVER / "eula.txt").write_text("eula=true\n")
elif not eula_accepted():
sys.exit("build/server/eula.txt does not accept the Minecraft EULA yet; pass --accept-eula if you accept it.")
started = time.time()
recipe = json.loads((ROOT / "world/world.json").read_text())
sample = recipe["quick_check"]
args_file = prepare()
shutil.rmtree(SERVER / LEVEL, ignore_errors=True)
props = {"level-name": LEVEL, "level-seed": recipe["seed"], "level-type": recipe["level_type"]}
failures, village = [], None
with chunky(recipe), Server(args_file, props, heap="8G", stdout_name="quick-check-stdout.log",
jvm_args=SELFTEST_JVM_ARGS) as server:
try:
print(f"server started in {server.wait_ready():.0f}s")
for rule, value in recipe.get("gamerules", {}).items():
server.command(f"gamerule {rule} {str(value).lower()}")
failures += server.run_checks()
failures += server.run_selftest()
for structure in sample["absent_structures"]:
reply = server.command(f"locate structure {structure}").strip()
found = FOUND.search(reply)
print(f" [{'FAIL' if found else 'ok'}] {structure} absent: {reply[:120]!r}")
if found:
failures.append(f"{structure} generates but should not: {reply}")
areas = [("spawn", 0, 0, sample["spawn_radius"])]
for structure in sample["structures"]:
reply = server.command(f"locate structure {structure}").strip()
found = FOUND.search(reply)
if not found:
print(f" note: {structure} not found near spawn, not sampled ({reply[:80]!r})")
continue
areas.append((structure, int(found[1]), int(found[2]), sample["structure_radius"]))
for name, x, z, radius in areas:
seconds = server.pregenerate(x, z, radius, report_every=0)
print(f" sampled {name} at {x},{z} (radius {radius}) in {seconds:.0f}s")
village = next(((x, z, r) for name, x, z, r in areas if name == "#minecraft:village"), None)
if village:
x, z, r = village
server.command(f"forceload add {x - r} {z - r} {x + r} {z + r}")
time.sleep(5)
server.command(f"forceload remove {x - r} {z - r} {x + r} {z + r}")
server.command("save-all flush")
server.stop()
except Exception as e: # report; the context manager still kills the process
failures.append(f"{type(e).__name__}: {e}")
failures += [f"log: {line}" for line in scan_log(server.log)]
report = subprocess.run([sys.executable, "-I", str(ROOT / "tools/world_report.py"), str(SERVER / LEVEL),
"--sample", "--json", str(ROOT / "build/quick-check-report.json")])
if report.returncode:
failures.append("world report failed (see above)")
if village:
# Lootr only converts a chest once the 5x5 chunks around it are loaded, so check the inner part.
x, z, r = village
inner = r - 32
loot = json.loads((ROOT / "build/quick-check-report.json").read_text())["loot_containers"]
left = [p for p in loot["unconverted_at"] if abs(p[0] - x) <= inner and abs(p[2] - z) <= inner]
print(f" loot containers: {loot['lootr']} Lootr, {len(left)} vanilla left near the village")
if left:
failures.append(f"Lootr did not convert {len(left)} loot chests near the village: {left[:5]}")
print(f"\nquick check took {time.time() - started:.0f}s")
if failures:
print("QUICK CHECK FAILED")
print("\n".join(f" - {f}" for f in failures))
sys.exit(1)
print("QUICK CHECK PASSED")
if __name__ == "__main__":
main()
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""Compare world seeds by their island's biomes, without generating chunks.
Boots the test server once (world "seed-search"), runs
`/cobblesurvival biomepreview <seed> <radius> <step>` for every candidate, and
scores each island:
* coverage: every surface biome from world/biomes.json reaches its target
share of the land (TARGETS below);
* patches: few, large patches per biome instead of many crumbs;
* balance: no biome takes more than MAX_SHARE of the land;
* start: the land around 0,0 is gentle (plains, forest, cherry grove);
* caves: lush caves, dripstone caves, deep dark and basalt deltas all occur
under land.
Writes build/seed-search/report.json and candidates.png (best seeds side by
side). The coastline in the preview is approximate (see BiomePreview.kt).
Usage: tools/seed_search.py [--count N] [--prefix P] [--apply]
--apply writes the best seed into world/world.json
"""
import argparse
import json
import shutil
import sys
import time
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent))
from mcserver import BUILD, ROOT, SERVER, Server, eula_accepted, prepare # noqa: E402
from world_report import colour # noqa: E402
LEVEL = "seed-search"
OUT = BUILD / "seed-search"
RADIUS, STEP = 1700, 24
SURFACE = ["minecraft:plains", "minecraft:forest", "minecraft:cherry_grove", "minecraft:mangrove_swamp",
"minecraft:jungle", "minecraft:desert", "minecraft:badlands", "minecraft:windswept_hills",
"minecraft:jagged_peaks"]
TARGETS = {b: 0.05 for b in SURFACE} | {"minecraft:cherry_grove": 0.03, "minecraft:mangrove_swamp": 0.03,
"minecraft:jagged_peaks": 0.03, "minecraft:windswept_hills": 0.03}
CAVES = ["minecraft:lush_caves", "minecraft:dripstone_caves", "minecraft:deep_dark", "minecraft:basalt_deltas"]
GENTLE = {"minecraft:plains", "minecraft:forest", "minecraft:cherry_grove"}
MAX_SHARE = 0.35
START_RADIUS = 350
def patches(mask):
"""Number of 4-connected patches of at least 3 cells."""
seen = np.zeros_like(mask)
count = 0
for start in zip(*np.nonzero(mask)):
if seen[start]:
continue
seen[start] = True
stack, size = [start], 0
while stack:
z, x = stack.pop()
size += 1
for nz, nx in ((z + 1, x), (z - 1, x), (z, x + 1), (z, x - 1)):
if 0 <= nz < mask.shape[0] and 0 <= nx < mask.shape[1] and mask[nz, nx] and not seen[nz, nx]:
seen[nz, nx] = True
stack.append((nz, nx))
count += size >= 3
return count
def score(preview):
n = preview["size"]
names = preview["biomes"]
surface = np.array(preview["surface"]).reshape(n, n)
land = np.array(preview["height"]).reshape(n, n) >= preview["sea_level"]
total = land.sum() or 1
shares, pieces = {}, {}
for b in SURFACE:
mask = land & (surface == names.index(b)) if b in names else np.zeros_like(land)
shares[b] = float(mask.sum() / total)
pieces[b] = patches(mask)
coverage = sum(min(shares[b] / TARGETS[b], 1.0) for b in SURFACE)
fragmentation = sum(max(p - 1, 0) for p in pieces.values()) / len(SURFACE)
balance = max(0.0, max(shares.values()) - MAX_SHARE) * 10
coords = (np.arange(n) * preview["step"] - preview["radius"])
near = (np.hypot(*np.meshgrid(coords, coords)) < START_RADIUS) & land
gentle_ids = [names.index(b) for b in GENTLE if b in names]
start = float(np.isin(surface[near], gentle_ids).mean()) if near.any() else 0.0
caves = {b: 0 for b in CAVES}
for grid in preview["caves"].values():
layer = np.array(grid).reshape(n, n)[land]
for b in CAVES:
if b in names:
caves[b] += int((layer == names.index(b)).sum())
complete = all(shares[b] >= 0.01 for b in SURFACE) and all(caves.values())
total_score = coverage + 2 * start - 0.15 * fragmentation - balance + (5 if complete else 0)
return {"seed": preview["seed"], "score": round(total_score, 3), "complete": complete,
"land_cells": int(total), "start_gentle": round(start, 2),
"shares": {b.split(":")[1]: round(v, 3) for b, v in shares.items()},
"patches": {b.split(":")[1]: v for b, v in pieces.items()},
"cave_cells": {b.split(":")[1]: v for b, v in caves.items()}}
def tile(preview, label, size=300):
from PIL import Image, ImageDraw
n = preview["size"]
surface = np.array(preview["surface"]).reshape(n, n)
height = np.array(preview["height"]).reshape(n, n)
lut = np.array([colour(b) for b in preview["biomes"]], dtype=np.uint8)
rgb = lut[surface]
rgb[height < preview["sea_level"]] = (20, 50, 120)
img = Image.fromarray(rgb).resize((size, size), Image.NEAREST)
ImageDraw.Draw(img).text((6, 6), label, fill=(255, 255, 255), stroke_width=2, stroke_fill=(0, 0, 0))
return img
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--count", type=int, default=60)
parser.add_argument("--prefix", default="isle")
parser.add_argument("--apply", action="store_true")
args = parser.parse_args()
if not eula_accepted():
sys.exit("build/server/eula.txt does not accept the Minecraft EULA yet; run tools/smoke_test.py --accept-eula first.")
recipe = json.loads((ROOT / "world/world.json").read_text())
seeds = [recipe["seed"]] + [f"{args.prefix}-{i}" for i in range(1, args.count + 1)]
args_file = prepare()
shutil.rmtree(SERVER / LEVEL, ignore_errors=True)
shutil.rmtree(OUT, ignore_errors=True)
OUT.mkdir(parents=True)
results, previews = [], {}
props = {"level-name": LEVEL, "level-seed": recipe["seed"], "level-type": recipe["level_type"]}
with Server(args_file, props, stdout_name="seed-search-stdout.log") as server:
server.wait_ready()
for i, seed in enumerate(seeds, 1):
started = time.time()
reply = server.command(f"cobblesurvival biomepreview {seed} {RADIUS} {STEP}")
if "written" not in reply:
raise RuntimeError(f"preview failed for {seed}: {reply}")
preview = json.loads((SERVER / LEVEL / "cobblesurvival/biome_preview" / f"{seed}.json").read_text())
previews[seed] = preview
results.append(score(preview))
r = results[-1]
print(f"[{i}/{len(seeds)}] {seed:16} score {r['score']:6.2f} complete={r['complete']!s:5} "
f"start={r['start_gentle']:.2f} ({time.time() - started:.1f}s)")
server.stop()
results.sort(key=lambda r: -r["score"])
(OUT / "report.json").write_text(json.dumps(results, indent=2) + "\n")
from PIL import Image
best = results[:8]
sheet = Image.new("RGB", (4 * 300, 2 * 300), (24, 24, 24))
for k, r in enumerate(best):
sheet.paste(tile(previews[r["seed"]], f"#{k + 1} {r['seed']} {r['score']}"), ((k % 4) * 300, (k // 4) * 300))
sheet.save(OUT / "candidates.png")
print(f"\nbest: {best[0]['seed']} (score {best[0]['score']}); report: {(OUT / 'report.json').relative_to(ROOT)}")
print(json.dumps(best[0], indent=2))
if args.apply:
recipe["seed"] = best[0]["seed"]
(ROOT / "world/world.json").write_text(json.dumps(recipe, indent=2) + "\n")
print(f"world/world.json seed set to {best[0]['seed']}")
if __name__ == "__main__":
main()
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Run the companion mod's in-game tests on their own: one server boot on a fresh flat
world, `/cobblesurvival selftest run`, wait for the results, stop, scan the log.
The tests (mod/src/main/kotlin/dev/cobblesurvival/selftest/) play real situations on the
live server with every mod and config of the pack, e.g. the permadeath matrix: Pokémon
fainting from a fall, lava, poison, in wild and trainer battles, with and without a
Revival Charm. tools/quick_check.py runs them too; this is the faster loop while
working on them.
Usage: tools/selftest.py [--accept-eula] (run tools/build.py first)
"""
import argparse
import shutil
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from mcserver import SELFTEST_JVM_ARGS, SERVER, Server, eula_accepted, prepare, scan_log # noqa: E402
LEVEL = "selftest"
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--accept-eula", action="store_true", help="write eula=true for the test server")
args = parser.parse_args()
if args.accept_eula:
(SERVER / "eula.txt").parent.mkdir(parents=True, exist_ok=True)
(SERVER / "eula.txt").write_text("eula=true\n")
elif not eula_accepted():
sys.exit("build/server/eula.txt does not accept the Minecraft EULA yet; pass --accept-eula if you accept it.")
started = time.time()
args_file = prepare()
shutil.rmtree(SERVER / LEVEL, ignore_errors=True)
flat = '{"layers":[{"block":"minecraft:bedrock","height":1},{"block":"minecraft:stone","height":3},' \
'{"block":"minecraft:grass_block","height":1}],"biome":"minecraft:plains"}'
props = {"level-name": LEVEL, "level-type": "minecraft:flat", "generator-settings": flat}
failures = []
with Server(args_file, props, stdout_name="selftest-stdout.log", jvm_args=SELFTEST_JVM_ARGS) as server:
try:
print(f"server started in {server.wait_ready():.0f}s")
failures += server.run_selftest()
server.stop()
except Exception as e: # report; the context manager still kills the process
failures.append(f"{type(e).__name__}: {e}")
failures += [f"log: {line}" for line in scan_log(server.log)]
print(f"\nselftest took {time.time() - started:.0f}s")
if failures:
print("SELFTEST FAILED")
print("\n".join(f" - {f}" for f in failures))
sys.exit(1)
print("SELFTEST PASSED")
if __name__ == "__main__":
main()
+9
View File
@@ -0,0 +1,9 @@
# Regexes for log lines the smoke test may ignore. One per line.
# Every entry needs a comment saying why it is harmless; delete entries once
# the cause is gone.
# Cobblemon 1.8.4 bug: habitats/sunflowerbed_clearing5.nbt has a jigsaw block whose final
# state is "minecraft:dirt_block" (no such block). Logged at worldgen; the habitat still generates.
Failed to parse jigsaw replacement state 'minecraft:dirt_block'
# Cobblemon 1.8.4 logs this at ERROR on every player's first join (no save files yet, so the
# backup is missing too) and starts them with empty data. The self-tests join fresh players.
\.old File \S+ for [0-9a-f-]+ is corrupt or missing\. Data is lost
+92
View File
@@ -0,0 +1,92 @@
[
{
"command": "cobblesurvival status",
"expect": "^cobblesurvival \\S+ ok; cobblemon 1\\.8\\."
},
{
"command": "datapack list enabled",
"expect": "cobblesurvival"
},
{
"command": "help pokegive",
"expect": "pokegive"
},
{
"command": "help spawnpokemon",
"expect": "spawnpokemon"
},
{
"command": "help kubejs",
"expect": "kubejs"
},
{
"command": "help ftbquests",
"expect": "ftbquests"
},
{
"command": "worldgen_editor status",
"expect": "cobblesurvival_island"
},
{
"command": "execute in minecraft:the_nether run time query gametime",
"expect": "(?i)unknown dimension|invalid dimension|not found"
},
{
"command": "execute in minecraft:the_end run time query gametime",
"expect": "^The time is"
},
{
"name": "lighting an obsidian frame makes no Nether portal",
"setup": [
"forceload add 0 32",
"fill 0 250 32 3 254 32 minecraft:obsidian",
"fill 1 251 32 2 253 32 minecraft:air",
"setblock 1 251 32 minecraft:fire"
],
"command": "execute if block 1 251 32 minecraft:nether_portal",
"cleanup": [
"fill 0 250 32 3 254 32 minecraft:air",
"forceload remove 0 32"
],
"expect": "Test failed"
},
{
"name": "vanilla mobs can't join the world",
"setup": [
"forceload add 0 32",
"summon minecraft:zombie 0 100 32"
],
"command": "execute if entity @e[type=minecraft:zombie]",
"cleanup": [
"kill @e[type=minecraft:zombie]",
"forceload remove 0 32"
],
"expect": "Test failed"
},
{
"name": "other mods' animals can't join the world (Cold Sweat chameleon)",
"setup": [
"forceload add 0 32",
"summon cold_sweat:chameleon 0 100 32"
],
"command": "execute if entity @e[type=cold_sweat:chameleon]",
"cleanup": [
"kill @e[type=cold_sweat:chameleon]",
"forceload remove 0 32"
],
"expect": "Test failed"
},
{
"name": "the Revival Charm item exists",
"setup": [
"forceload add 0 32",
"summon minecraft:item 0 100 32 {Item:{id:\"cobblesurvival:revival_charm\",count:1},NoGravity:1b}"
],
"command": "execute if entity @e[type=minecraft:item,nbt={Item:{id:\"cobblesurvival:revival_charm\"}}]",
"cleanup": [
"kill @e[type=minecraft:item]",
"forceload remove 0 32"
],
"expect": "Test passed"
}
]
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Server smoke test: boot a dedicated NeoForge server with the pack, check it
over RCON, scan the log, stop.
Steps
1. Install the NeoForge server named in pack/pack.toml (cached in build/.cache).
2. Install the server-side mods from build/stage/full with packwiz-installer
and check their dependency ranges (tools/check_deps.py).
3. Use the world template from build/world-template when it exists
(tools/make_world.py), otherwise generate a fresh island world.
4. Start the server with RCON on a free local port and wait for "Done (".
5. Run every check in tools/smoke_checks.json over RCON (a check may have
"setup" and "cleanup" command lists around its command).
6. Stop the server, then fail on log lines that match no entry in
tools/smoke_allowlist.txt.
Running a dedicated server needs eula=true in eula.txt. The script only writes
it when called with --accept-eula, which states that you accept the Minecraft
EULA (https://aka.ms/MinecraftEULA) for this test server.
--load-only needs no EULA. NeoForge discovers every mod jar and prints its mod
list before the server checks eula.txt, then stops at the EULA notice; mods
are only constructed after that check. So it proves the loader reads every
jar (with tools/check_deps.py covering dependency ranges), not that the mods
start; construction, world, datapack and RCON checks need the full run.
Usage: tools/smoke_test.py --accept-eula [--keep] [--timeout SECONDS]
tools/smoke_test.py --load-only
"""
import argparse
import json
import re
import shutil
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from mcserver import BUILD, ROOT, SERVER, Server, eula_accepted, prepare, scan_log # noqa: E402
TEMPLATE = BUILD / "world-template"
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--accept-eula", action="store_true", help="write eula=true for the test server")
parser.add_argument("--load-only", action="store_true", help="stop at the EULA check (no EULA needed)")
parser.add_argument("--keep", action="store_true", help="keep the server world between runs")
parser.add_argument("--timeout", type=int, default=900, help="seconds to wait for startup")
args = parser.parse_args()
if not args.accept_eula and not args.load_only:
sys.exit("Refusing to start: pass --accept-eula to accept the Minecraft EULA for this test server, "
"or --load-only to stop at the EULA check.")
args_file = prepare()
world = SERVER / "world"
if not args.keep:
shutil.rmtree(world, ignore_errors=True)
if (TEMPLATE / "level.dat").exists():
shutil.copytree(TEMPLATE, world)
print(f"using world template {TEMPLATE.relative_to(ROOT)}")
if args.load_only:
if eula_accepted():
sys.exit("eula.txt already accepts the EULA here; run the full test with --accept-eula instead.")
else:
(SERVER / "eula.txt").write_text("eula=true\n")
recipe = json.loads((ROOT / "world/world.json").read_text())
failures = []
with Server(args_file, {"level-type": recipe["level_type"], "level-seed": recipe["seed"]},
stdout_name="smoke-stdout.log") as server:
try:
if args.load_only:
server.proc.wait(timeout=args.timeout)
text = server.log.read_text(errors="replace") if server.log.exists() else ""
for pattern, what in ((r"Cobblemon Survival Pack Companion \S+ \(cobblesurvival\)",
"companion mod in the mod list"),
(r"You need to agree to the EULA", "stop at the EULA check")):
if not re.search(pattern, text):
failures.append(f"missing {what} in the log")
print(f"mod discovery finished in {time.time() - server.started:.0f}s")
else:
print(f"server started in {server.wait_ready(args.timeout):.0f}s")
failures += server.run_checks()
server.stop()
except Exception as e: # report; the context manager still kills the process
failures.append(f"{type(e).__name__}: {e}")
if server.log.exists():
failures += [f"log: {line}" for line in scan_log(server.log)]
if failures:
print("\nSMOKE TEST FAILED")
print("\n".join(f" - {f}" for f in failures))
print(f" log: {server.log}")
sys.exit(1)
print("\nLOAD CHECK PASSED (mod discovery only; the full smoke test still needs --accept-eula)"
if args.load_only else "\nSMOKE TEST PASSED")
if __name__ == "__main__":
main()
+386
View File
@@ -0,0 +1,386 @@
#!/usr/bin/env python3
"""Check and picture a generated world (default: build/world-template).
* Ore scan: every block in every stored chunk is checked; any ore block
("*_ore", ancient debris, raw ore blocks from large veins) fails the check.
* Biome set: every biome stored in any chunk section (surface and caves)
must be listed in world/biomes.json ("allowed" or "water").
* End access: at least one stronghold must start inside the world border.
* Gamerules in level.dat match world/world.json, and no vanilla mob is
stored in the world (only Pokémon; item frames, minecarts etc. don't count).
* No mob sources in blocks: no spawners, trial spawners or infested blocks,
and no sculk shrieker that can summon the Warden.
* Loot containers: Lootr containers (they refill) and vanilla ones still
waiting for conversion are counted. Lootr converts a container once the
chunks around it are loaded (a player nearby), so a pregenerated world
keeps vanilla ones until then; tools/quick_check.py tests the conversion.
* Border fit: all land must lie inside the world border, and the border may
stand at most `border_margin` (world/world.json) plus a little slack
outside the outermost land on its longer axis.
* Biome map: a top-down PNG (biome colours, hill shading, water depth,
border, island outline from the preset, legend) for review.
--sample is for partly generated worlds (tools/quick_check.py): it skips the
border, stronghold and map, which only make sense for the whole island.
Usage: tools/world_report.py [WORLD] [--map OUT.png] [--json OUT.json] [--no-map] [--sample]
"""
import argparse
import json
import math
import os
import re
import sys
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent))
import anvil # noqa: E402
ROOT = Path(__file__).resolve().parent.parent
ORE = re.compile(r"(_ore|:ancient_debris|:raw_(iron|copper|gold)_block)$")
MOB_BLOCK = re.compile(r":(spawner|trial_spawner|infested_\w+)$")
LOOT_CONTAINERS = {"minecraft:chest", "minecraft:trapped_chest", "minecraft:barrel", "minecraft:shulker_box"}
SEA_LEVEL = 63
BORDER_SLACK = 32 # blocks: coastline and border are both measured on a 4-block grid
def scan_region(path):
"""Per-region arrays at block resolution plus ore counts."""
rx, rz = (int(v) for v in Path(path).name.split(".")[1:3])
floor = np.full((512, 512), -10_000, dtype=np.int16)
top = np.full((512, 512), -10_000, dtype=np.int16)
biome = np.full((128, 128), -1, dtype=np.int16)
names, ores, chunks, present, strongholds = {}, {}, 0, {}, []
loot = {"lootr": 0, "unconverted": 0, "unconverted_at": []}
for cx, cz, chunk in anvil.region_chunks(path):
for be in chunk.get("block_entities", []):
if be.get("id", "").startswith("lootr:"):
loot["lootr"] += 1
elif be.get("id") in LOOT_CONTAINERS and "LootTable" in be:
loot["unconverted"] += 1
loot["unconverted_at"].append([be["x"], be["y"], be["z"]])
for structure in chunk.get("structures", {}).get("starts", {}):
strongholds.append((structure, cx * 16 + 8, cz * 16 + 8))
if not str(chunk.get("Status", "")).endswith("full"):
continue
chunks += 1
ox, oz = (cx - rx * 32) * 16, (cz - rz * 32) * 16
of = anvil.heightmap(chunk, "OCEAN_FLOOR")
mb = anvil.heightmap(chunk, "MOTION_BLOCKING")
if of is None or mb is None:
continue
floor[oz:oz + 16, ox:ox + 16] = of
top[oz:oz + 16, ox:ox + 16] = mb
sections = {s["Y"]: s for s in chunk.get("sections", [])}
for section in sections.values():
for name in section.get("biomes", {}).get("palette", []):
present[name] = present.get(name, 0) + 1
palette, indices = anvil.section_blocks(section)
entries = section.get("block_states", {}).get("palette", [])
hits = {i: name for i, name in enumerate(palette) if ORE.search(name) or MOB_BLOCK.search(name)}
hits.update({i: "minecraft:sculk_shrieker[can_summon=true]" for i, e in enumerate(entries)
if e["Name"] == "minecraft:sculk_shrieker" and e.get("Properties", {}).get("can_summon") == "true"})
for i, name in hits.items():
count = int(np.count_nonzero(indices == i))
if count:
ores[name] = ores.get(name, 0) + count
decoded = {}
for qz in range(4):
for qx in range(4):
y = int(mb[qz * 4 + 2, qx * 4 + 2]) - 1
section = sections.get(y // 16)
if section is None:
continue
if y // 16 not in decoded:
decoded[y // 16] = anvil.section_biomes(section)
palette, indices = decoded[y // 16]
name = palette[int(indices[((y % 16) // 4) * 16 + qz * 4 + qx])]
biome[oz // 4 + qz, ox // 4 + qx] = names.setdefault(name, len(names))
return rx, rz, floor, top, biome, list(names), ores, chunks, present, strongholds, loot
# Entities that aren't mobs: decorations, vehicles, and projectiles (a trial chamber's dispenser trap fires
# potions and arrows when a Pokémon steps on its plate, and they can be saved mid-flight).
NOT_MOBS = re.compile(r":(item|item_frame|glow_item_frame|painting|armor_stand|.*minecart.*|.*boat|"
r"marker|interaction|block_display|item_display|text_display|end_crystal|falling_block|"
r"potion|arrow|spectral_arrow|trident|snowball|egg|ender_pearl|eye_of_ender|experience_orb|"
r"experience_bottle|fireball|small_fireball|dragon_fireball|wither_skull|wind_charge|"
r"breeze_wind_charge|firework_rocket|llama_spit|shulker_bullet|fishing_bobber|tnt|"
r"area_effect_cloud|lightning_bolt|evoker_fangs|ominous_item_spawner)$")
def scan_entities(path):
counts = {}
for _, _, chunk in anvil.region_chunks(path):
for entity in chunk.get("Entities", []):
counts[entity["id"]] = counts.get(entity["id"], 0) + 1
return counts
def analyse(world):
regions = sorted(Path(world, "region").glob("r.*.*.mca"))
if not regions:
sys.exit(f"no region files in {world}/region")
coords = [tuple(int(v) for v in p.name.split(".")[1:3]) for p in regions]
x0, z0 = min(c[0] for c in coords), min(c[1] for c in coords)
w, h = (max(c[0] for c in coords) - x0 + 1) * 512, (max(c[1] for c in coords) - z0 + 1) * 512
floor = np.full((h, w), -10_000, dtype=np.int16)
top = np.full((h, w), -10_000, dtype=np.int16)
biome = np.full((h // 4, w // 4), -1, dtype=np.int16)
names, ores, chunks, present, strongholds = [], {}, 0, {}, []
loot = {"lootr": 0, "unconverted": 0, "unconverted_at": []}
with ProcessPoolExecutor(max_workers=os.cpu_count()) as pool:
for rx, rz, f, t, b, local, o, n, pr, sh, lt in pool.map(scan_region, regions):
for k, v in lt.items():
loot[k] += v
strongholds += sh
for k, v in pr.items():
present[k] = present.get(k, 0) + v
ix, iz = (rx - x0) * 512, (rz - z0) * 512
floor[iz:iz + 512, ix:ix + 512] = f
top[iz:iz + 512, ix:ix + 512] = t
remap = np.array([names.index(name) if name in names else names.append(name) or len(names) - 1
for name in local] + [-1], dtype=np.int16)
biome[iz // 4:iz // 4 + 128, ix // 4:ix // 4 + 128] = remap[b]
for k, v in o.items():
ores[k] = ores.get(k, 0) + v
chunks += n
entities = {}
for counts in pool.map(scan_entities, sorted(Path(world, "entities").glob("r.*.*.mca"))):
for k, v in counts.items():
entities[k] = entities.get(k, 0) + v
return {"origin": (x0 * 512, z0 * 512), "floor": floor, "top": top, "biome": biome,
"biome_names": names, "ores": ores, "chunks": chunks, "sections_per_biome": present,
"strongholds": strongholds, "entities": entities, "loot": loot}
def border_of(level):
return level.get("BorderCenterX", 0.0), level.get("BorderCenterZ", 0.0), level.get("BorderSize", 5.9999968e7)
def land_mask(data):
generated = data["floor"] > -10_000
return generated & (data["top"] == data["floor"]) & (data["floor"] >= SEA_LEVEL), generated
def island_mask(data, cell=16, min_share=0.01):
"""Land that belongs to a large land mass. Icebergs, sea stacks and wrecks
out at sea are dropped so they don't stretch the border. Land is grouped on
a `cell`-block grid, grown by one cell so rivers don't split the island."""
land, _ = land_mask(data)
h, w = land.shape[0] // cell, land.shape[1] // cell
cells = land[:h * cell, :w * cell].reshape(h, cell, w, cell).any(axis=(1, 3))
grown = cells.copy()
grown[1:] |= cells[:-1]
grown[:-1] |= cells[1:]
grown[:, 1:] |= cells[:, :-1]
grown[:, :-1] |= cells[:, 1:]
labels = np.zeros((h, w), dtype=np.int32)
sizes = [0]
for start in zip(*np.nonzero(grown)):
if labels[start]:
continue
sizes.append(0)
labels[start] = len(sizes) - 1
stack = [start]
while stack:
z, x = stack.pop()
sizes[-1] += 1
for nz, nx in ((z + 1, x), (z - 1, x), (z, x + 1), (z, x - 1)):
if 0 <= nz < h and 0 <= nx < w and grown[nz, nx] and not labels[nz, nx]:
labels[nz, nx] = labels[start]
stack.append((nz, nx))
keep = np.array(sizes) >= min_share * sum(sizes)
keep[0] = False
big = np.repeat(np.repeat(keep[labels], cell, axis=0), cell, axis=1)
out = np.zeros_like(land)
out[:h * cell, :w * cell] = land[:h * cell, :w * cell] & big
return out
def check_border(data, level, margin):
zs, xs = np.nonzero(island_mask(data))
ox, oz = data["origin"]
bounds = (int(xs.min()) + ox, int(zs.min()) + oz, int(xs.max()) + ox + 1, int(zs.max()) + oz + 1)
cx, cz, size = border_of(level)
half = size / 2
gaps = {"west": bounds[0] - (cx - half), "north": bounds[1] - (cz - half),
"east": (cx + half) - bounds[2], "south": (cz + half) - bounds[3]}
problems = [f"land beyond the {side} border by {-gap:.0f} blocks" for side, gap in gaps.items() if gap < 0]
starts = [s for s in data["strongholds"] if abs(s[1] - cx) < half and abs(s[2] - cz) < half]
inside = [[x, z] for name, x, z in starts if name == "minecraft:stronghold"]
structures = {}
for name, _, _ in starts:
structures[name] = structures.get(name, 0) + 1
if not inside:
problems.append("no stronghold inside the border, so the End can't be reached")
if min(gaps["west"], gaps["east"]) > margin + BORDER_SLACK and min(gaps["north"], gaps["south"]) > margin + BORDER_SLACK:
problems.append(f"border is more than {margin + BORDER_SLACK} blocks from the coast on every side")
return {"land_bounds": bounds, "strongholds_inside_border": inside,
"structures_inside_border": dict(sorted(structures.items(), key=lambda kv: -kv[1])),
"border": {"center": [cx, cz], "size": size},
"gaps": {k: round(v) for k, v in gaps.items()}, "problems": problems}
# Approximate colours in the style of common seed-map tools.
COLOURS = {
"ocean": (0, 0, 112), "deep_ocean": (0, 0, 48), "cold_ocean": (32, 32, 112), "deep_cold_ocean": (32, 32, 56),
"frozen_ocean": (112, 112, 214), "deep_frozen_ocean": (64, 64, 144), "lukewarm_ocean": (0, 0, 144),
"deep_lukewarm_ocean": (0, 0, 64), "warm_ocean": (0, 0, 172), "river": (48, 96, 255),
"frozen_river": (160, 160, 255), "beach": (250, 222, 85), "snowy_beach": (250, 240, 192),
"stony_shore": (162, 162, 132), "plains": (141, 179, 96), "sunflower_plains": (181, 219, 136),
"forest": (5, 102, 33), "flower_forest": (45, 142, 73), "birch_forest": (48, 116, 68),
"old_growth_birch_forest": (88, 156, 108), "dark_forest": (64, 81, 26), "meadow": (96, 164, 69),
"cherry_grove": (255, 145, 200), "swamp": (7, 249, 178), "mangrove_swamp": (44, 204, 142),
"taiga": (11, 102, 89), "old_growth_pine_taiga": (89, 102, 81), "old_growth_spruce_taiga": (129, 142, 121),
"snowy_taiga": (49, 85, 74), "snowy_plains": (255, 255, 255), "ice_spikes": (180, 220, 220),
"grove": (187, 211, 221), "snowy_slopes": (220, 220, 230), "frozen_peaks": (160, 180, 255),
"jagged_peaks": (220, 220, 220), "stony_peaks": (123, 143, 116), "windswept_hills": (96, 96, 96),
"windswept_forest": (80, 112, 80), "windswept_gravelly_hills": (136, 136, 136),
"desert": (250, 148, 24), "savanna": (189, 178, 95), "savanna_plateau": (167, 157, 100),
"windswept_savanna": (229, 218, 135), "badlands": (217, 69, 21), "eroded_badlands": (255, 109, 61),
"wooded_badlands": (176, 151, 101), "jungle": (83, 123, 9), "sparse_jungle": (98, 139, 23),
"bamboo_jungle": (118, 142, 20), "mushroom_fields": (255, 0, 255), "dripstone_caves": (134, 96, 67),
"lush_caves": (102, 140, 50), "deep_dark": (20, 40, 50),
}
def colour(name):
short = name.split(":", 1)[-1]
if short in COLOURS:
return COLOURS[short]
seed = sum(ord(c) * 31 ** i for i, c in enumerate(name)) & 0xFFFFFF
return (seed >> 16 & 255, seed >> 8 & 255, seed & 255)
def render_map(data, level, preset, out, scale=2):
from PIL import Image, ImageDraw, ImageFont
full_land, _ = land_mask(data)
land, generated = full_land[::scale, ::scale], (data["floor"] > -10_000)[::scale, ::scale]
names = data["biome_names"]
lut = np.array([colour(n) for n in names] + [(0, 0, 0)], dtype=np.float32)
per_cell = 4 // scale
rgb = lut[np.repeat(np.repeat(data["biome"], per_cell, axis=0), per_cell, axis=1)]
height = data["floor"][::scale, ::scale].astype(np.float32)
gz, gx = np.gradient(np.where(generated, height, SEA_LEVEL))
shade = np.clip(1.0 + (gx * -0.6 + gz * -0.6) * 0.18 * scale, 0.55, 1.35)
depth = np.clip((data["top"][::scale, ::scale] - data["floor"][::scale, ::scale]).astype(np.float32), 0, 40)
water = generated & ~land
rgb = np.where(land[..., None], rgb * shade[..., None], rgb)
tint = np.array([30, 80, 170], dtype=np.float32) * (1.0 - depth / 55.0)[..., None]
rgb = np.where(water[..., None], rgb * 0.35 + tint * 0.65, rgb)
rgb = np.where(generated[..., None], rgb, 24)
img = Image.fromarray(np.clip(rgb, 0, 255).astype(np.uint8))
ox, oz = data["origin"]
to_px = lambda x, z: ((x - ox) / scale, (z - oz) / scale) # noqa: E731
draw = ImageDraw.Draw(img)
try:
font = ImageFont.truetype("DejaVuSans.ttf", 18)
small = ImageFont.truetype("DejaVuSans.ttf", 14)
except OSError:
font = small = ImageFont.load_default()
for entry in (e for e in preset["entries"] if e.get("type", "island") == "island"):
rx = entry.get("radius_x", entry.get("radius", 0)) * entry.get("stretch_x", 1.0)
rz = entry.get("radius_z", entry.get("radius", 0)) * entry.get("stretch_z", 1.0)
pts = []
for i in range(73):
a = math.radians(i * 5)
x, z = rx * math.cos(a), rz * math.sin(a)
r = math.radians(entry.get("rotation", 0))
pts.append(to_px(entry["x"] + x * math.cos(r) + z * math.sin(r),
entry["z"] - x * math.sin(r) + z * math.cos(r)))
draw.line(pts, fill=(255, 255, 255), width=1)
px, pz = to_px(entry["x"], entry["z"])
draw.text((px, pz - 24), entry.get("name", "").split(" (")[0], fill=(255, 255, 255), font=font,
anchor="mm", stroke_width=3, stroke_fill=(0, 0, 0))
cx, cz, size = border_of(level)
a, b = to_px(cx - size / 2, cz - size / 2), to_px(cx + size / 2, cz + size / 2)
draw.rectangle([a, b], outline=(255, 60, 60), width=3)
sx, sz = to_px(level.get("SpawnX", 0), level.get("SpawnZ", 0))
draw.ellipse([sx - 6, sz - 6, sx + 6, sz + 6], fill=(255, 230, 0), outline=(0, 0, 0), width=2)
counts = np.bincount(data["biome"][full_land[::4, ::4]].clip(0).ravel(), minlength=len(names))
total = counts.sum() or 1
order = [i for i in np.argsort(-counts) if counts[i] / total >= 0.001]
legend_w = 300
canvas = Image.new("RGB", (img.width + legend_w, max(img.height, 40 + 22 * len(order))), (24, 24, 24))
canvas.paste(img, (0, 0))
d = ImageDraw.Draw(canvas)
d.text((img.width + 14, 12), "Land biomes (share of land)", fill=(240, 240, 240), font=small)
for row, i in enumerate(order):
y = 40 + row * 22
d.rectangle([img.width + 14, y, img.width + 30, y + 16], fill=tuple(int(c) for c in lut[i]))
d.text((img.width + 38, y), f"{names[i].split(':', 1)[-1]} {100 * counts[i] / total:.1f}%",
fill=(230, 230, 230), font=small)
y = 40 + len(order) * 22 + 16
d.text((img.width + 14, y), f"1 px = {scale} blocks", fill=(180, 180, 180), font=small)
d.text((img.width + 14, y + 20), "red: world border", fill=(255, 120, 120), font=small)
d.text((img.width + 14, y + 40), "white: island outline (preset)", fill=(230, 230, 230), font=small)
d.text((img.width + 14, y + 60), "yellow dot: world spawn", fill=(255, 230, 0), font=small)
canvas.save(out, optimize=True)
return {names[i]: round(100 * counts[i] / total, 2) for i in order}
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("world", nargs="?", type=Path, default=ROOT / "build/world-template")
parser.add_argument("--map", type=Path, default=ROOT / "build/island-map.png")
parser.add_argument("--json", type=Path, default=ROOT / "build/island-report.json")
parser.add_argument("--no-map", action="store_true")
parser.add_argument("--sample", action="store_true", help="partly generated world: skip border, strongholds, map")
args = parser.parse_args()
recipe = json.loads((ROOT / "world/world.json").read_text())
preset = json.loads((ROOT / recipe["preset"]).read_text())
level = anvil.read_level_dat(args.world)
data = analyse(args.world)
border = {"problems": []} if args.sample else check_border(data, level, recipe["border_margin"])
land, generated = land_mask(data)
rules = level.get("GameRules", {})
wrong_rules = {r: rules.get(r) for r, v in recipe.get("gamerules", {}).items() if rules.get(r) != str(v).lower()}
vanilla_mobs = {k: v for k, v in sorted(data["entities"].items(), key=lambda kv: -kv[1])
if not k.startswith("cobblemon:") and not NOT_MOBS.search(k)}
biomes = json.loads((ROOT / "world/biomes.json").read_text())
listed = set(biomes["allowed"]) | set(biomes["water"])
unlisted = {b: n for b, n in data["sections_per_biome"].items() if b not in listed}
report = {
"chunks_scanned": data["chunks"],
"ore_blocks": {k: v for k, v in data["ores"].items() if ORE.search(k)},
"mob_source_blocks": {k: v for k, v in data["ores"].items() if not ORE.search(k)},
"unlisted_biomes": unlisted,
"gamerules_not_as_recipe": wrong_rules,
"vanilla_mobs_in_world": vanilla_mobs,
"loot_containers": data["loot"],
"missing_listed_biomes": sorted(set(biomes["allowed"]) - set(data["sections_per_biome"])),
"sections_per_biome": dict(sorted(data["sections_per_biome"].items(), key=lambda kv: -kv[1])),
"land_km2": round(int(land.sum()) / 1e6, 2),
"spawn": [level.get("SpawnX"), level.get("SpawnY"), level.get("SpawnZ")],
**border,
}
if not args.no_map and not args.sample:
report["land_biomes_percent"] = render_map(data, level, preset, args.map)
report["map"] = str(args.map)
args.json.write_text(json.dumps(report, indent=2) + "\n")
print(json.dumps({k: v for k, v in report.items() if k != "land_biomes_percent"}, indent=2))
failures = ([f"{n} {name}" for name, n in data["ores"].items()] + border["problems"]
+ [f"unlisted biome {b} in {n} chunk sections" for b, n in unlisted.items()]
+ [f"gamerule {r} is {v}, recipe says {recipe['gamerules'][r]}" for r, v in wrong_rules.items()]
+ [f"{n} vanilla {mob} in the world" for mob, n in vanilla_mobs.items()])
if failures:
print("\nWORLD CHECK FAILED")
print("\n".join(f" - {f}" for f in failures))
sys.exit(1)
if report["missing_listed_biomes"] and not args.sample:
print(f"note: listed biomes not on this island: {', '.join(report['missing_listed_biomes'])}")
print(f"\nWORLD CHECK PASSED: {data['chunks']} chunks, no ore, no vanilla mobs or mob sources, only listed biomes"
+ ("" if args.sample else ", border fits the coast"))
if __name__ == "__main__":
main()
+354
View File
@@ -0,0 +1,354 @@
#!/usr/bin/env python3
"""Lay the zones of world/zones.json onto the island and draw them.
Reads the surface-biome grid of the recipe's seed (written by
`/cobblesurvival biomepreview`, which tools/seed_search.py runs for every
candidate) and assigns every cell to the first zone whose rule matches, in the
order of world/zones.json. Writes:
build/zone-map.png the island coloured by zone, with a legend
build/zone-layout.json the zone of every grid cell plus area per zone
datapacks/core/data/cobblesurvival/zones/island.json
the same grid for the companion mod (zone lookup,
the "zone" spawn condition)
Usage: tools/zone_layout.py [--preview PATH] [--scale PX_PER_CELL]
"""
import argparse
import json
import math
import sys
from collections import deque
from pathlib import Path
import numpy as np
ROOT = Path(__file__).resolve().parent.parent
SAMPLE_RADIUS = 1550 # survey points stay this close to 0,0, well inside the world border
DATAPACK_LAYOUT = ROOT / "datapacks/core/data/cobblesurvival/zones/island.json"
PREVIEWS = ROOT / "build/server/seed-search/cobblesurvival/biome_preview"
def components(mask):
"""Label 8-connected True cells; returns (labels, sizes) with labels -1 outside the mask."""
labels = np.full(mask.shape, -1, dtype=np.int32)
sizes = []
rows, cols = mask.shape
for start in zip(*np.nonzero(mask)):
if labels[start] != -1:
continue
label, queue = len(sizes), deque([start])
labels[start] = label
size = 0
while queue:
r, c = queue.popleft()
size += 1
for dr in (-1, 0, 1):
for dc in (-1, 0, 1):
rr, cc = r + dr, c + dc
if 0 <= rr < rows and 0 <= cc < cols and mask[rr, cc] and labels[rr, cc] == -1:
labels[rr, cc] = label
queue.append((rr, cc))
sizes.append(size)
return labels, sizes
def drop_small(mask, min_cells):
labels, sizes = components(mask)
keep = np.array([s >= min_cells for s in sizes] + [False])
return keep[labels]
def dilate(mask, cells):
out = mask.copy()
for _ in range(cells):
grown = out.copy()
grown[1:, :] |= out[:-1, :]
grown[:-1, :] |= out[1:, :]
grown[:, 1:] |= out[:, :-1]
grown[:, :-1] |= out[:, 1:]
grown[1:, 1:] |= out[:-1, :-1]
grown[:-1, :-1] |= out[1:, 1:]
grown[1:, :-1] |= out[:-1, 1:]
grown[:-1, 1:] |= out[1:, :-1]
out = grown
return out
def inherit_from_land(zone, mask):
"""Cells in mask take the most common zone among their assigned neighbours, spreading inwards."""
todo = mask & (zone == -1)
while todo.any():
changed = False
for r, c in zip(*np.nonzero(todo)):
around = zone[max(r - 1, 0):r + 2, max(c - 1, 0):c + 2]
known = around[around >= 0]
if known.size:
zone[r, c] = np.bincount(known).argmax()
changed = True
todo = mask & (zone == -1)
if not changed:
break
def assign(preview, plan):
n, step, radius = preview["size"], preview["step"], preview["radius"]
names = preview["biomes"]
surface = np.array(preview["surface"]).reshape(n, n)
water_biomes = [b for spec in plan["zones"] for b in spec["rule"].get("water", [])] + plan["inherit"]
# Zones follow biomes, not terrain height: a lake in the forest belongs to the forest's zone.
land = ~np.isin(surface, [names.index(b) for b in water_biomes if b in names])
coords = -radius + np.arange(n) * step
x = np.broadcast_to(coords[None, :], (n, n))
z = np.broadcast_to(coords[:, None], (n, n))
dist = np.hypot(x, z)
def biome_mask(biomes):
ids = [names.index(b) for b in biomes if b in names]
return np.isin(surface, ids)
zone = np.full((n, n), -1, dtype=np.int32)
for index, spec in enumerate(plan["zones"]):
rule, free = spec["rule"], zone == -1
if "within" in rule:
mask = land & free & (dist <= rule["within"])
elif "biomes" in rule:
mask = drop_small(land & free & biome_mask(rule["biomes"]), plan["min_cells"])
for biome, blocks in rule.get("absorb", {}).items():
near = dilate(mask, math.ceil(blocks / step))
mask |= land & free & near & biome_mask([biome])
elif "region" in rule:
r = rule["region"]
mask = land & free
if "x_min" in r: mask &= x >= r["x_min"]
if "x_max" in r: mask &= x < r["x_max"]
if "z_min" in r: mask &= z >= r["z_min"]
if "z_max" in r: mask &= z < r["z_max"]
elif "water" in rule:
inherit_from_land(zone, biome_mask(plan["inherit"]))
mask = ~land & (zone == -1) & biome_mask(rule["water"])
else:
sys.exit(f"zone {spec['id']}: unknown rule {rule}")
zone[mask] = index
return zone, land, np.array(preview["height"]).reshape(n, n)
def summary(zone, land, plan, step):
total_land = int(land.sum()) or 1
out = {}
for index, spec in enumerate(plan["zones"]):
mask = zone == index
_, sizes = components(mask)
out[spec["id"]] = {
"cells": int(mask.sum()),
"km2": round(float(mask.sum()) * step * step / 1e6, 2),
"land_percent": round(100 * float((mask & land).sum()) / total_land, 1),
"pieces": len(sizes),
}
out["_unassigned_land_cells"] = int((land & (zone == -1)).sum())
return out
def hex_rgb(h):
return tuple(int(h[i:i + 2], 16) for i in (1, 3, 5))
def render(zone, land, height, plan, stats, preview, out, scale):
from PIL import Image, ImageDraw, ImageFont
n = zone.shape[0]
colours = np.array([hex_rgb(z["color"]) for z in plan["zones"]] + [(40, 40, 40)], dtype=np.float32)
rgb = colours[zone]
# Hill shading on land, so mountains read as mountains.
shade = np.zeros_like(height, dtype=np.float32)
shade[1:, 1:] = (height[1:, 1:] - height[:-1, :-1]) / 12.0
above_sea = height >= preview["sea_level"]
rgb[above_sea] *= np.clip(1.0 + shade[above_sea], 0.7, 1.25)[:, None]
# Zone borders darker.
edge = np.zeros_like(land)
edge[1:, :] |= zone[1:, :] != zone[:-1, :]
edge[:, 1:] |= zone[:, 1:] != zone[:, :-1]
rgb[edge] *= 0.55
img = Image.fromarray(np.clip(rgb, 0, 255).astype(np.uint8)).resize((n * scale, n * scale), Image.NEAREST)
try:
font = ImageFont.truetype("DejaVuSans.ttf", 15)
bold = ImageFont.truetype("DejaVuSans-Bold.ttf", 13)
except OSError:
font = bold = ImageFont.load_default()
legend_w = 470
canvas = Image.new("RGB", (img.width + legend_w, img.height), (24, 24, 24))
canvas.paste(img, (0, 0))
draw = ImageDraw.Draw(canvas)
# Name each land zone on its biggest piece.
for index, spec in enumerate(plan["zones"]):
if "water" in spec["rule"]:
continue
labels, sizes = components(zone == index)
if not sizes:
continue
rows, cols = np.nonzero(labels == int(np.argmax(sizes)))
cx, cy = (cols.mean() + 0.5) * scale, (rows.mean() + 0.5) * scale
text = spec["name"]
w = draw.textlength(text, font=bold)
draw.rectangle((cx - w / 2 - 3, cy - 9, cx + w / 2 + 3, cy + 9), fill=(0, 0, 0))
draw.text((cx - w / 2, cy - 8), text, fill=(255, 255, 255), font=bold)
# Spawn.
r0 = preview["radius"]
sx, sz = (0 + r0) / preview["step"] * scale, (32 + r0) / preview["step"] * scale
draw.ellipse((sx - 5, sz - 5, sx + 5, sz + 5), fill=(255, 220, 0), outline=(0, 0, 0))
x0, y = img.width + 16, 16
draw.text((x0, y), "Zones (levels are placeholders)", fill=(255, 255, 255), font=font)
y += 30
for spec in plan["zones"]:
s = stats[spec["id"]]
draw.rectangle((x0, y + 2, x0 + 16, y + 18), fill=hex_rgb(spec["color"]), outline=(0, 0, 0))
lv = f"Lv {spec['levels'][0]}-{spec['levels'][1]}"
where = f"{s['land_percent']}% of land" if "water" not in spec["rule"] else f"{s['km2']} sq km"
draw.text((x0 + 24, y), f"{spec['name']} {lv} ({where})", fill=(235, 235, 235), font=font)
draw.text((x0 + 24, y + 18), f"hazard: {spec['hazard']}"[:58], fill=(170, 170, 170), font=font)
y += 44
y += 6
draw.text((x0, y), "Underground (by cave biome, island-wide):", fill=(255, 255, 255), font=font)
y += 24
for cave in plan["caves"]:
draw.text((x0 + 8, y), f"{cave['name']} Lv {cave['levels'][0]}-{cave['levels'][1]} {cave['hazard']}"[:62],
fill=(235, 235, 235), font=font)
y += 22
y += 10
draw.text((x0, y), f"1 cell = {preview['step']} blocks; north is up; yellow dot: spawn", fill=(170, 170, 170), font=font)
canvas.save(out)
def samples(zone, plan, preview):
"""{zone id: [x, y or None, z]}: a point deep inside each zone, for the spawn survey."""
n, step, radius = preview["size"], preview["step"], preview["radius"]
coords = -radius + np.arange(n) * step
near = np.hypot(coords[None, :], coords[:, None]) <= SAMPLE_RADIUS # inside the world border
dry = np.array(preview["height"]).reshape(n, n) > preview["sea_level"]
out = {}
for index, spec in enumerate(plan["zones"]):
mask = (zone == index) & near
if "water" not in spec["rule"]:
mask &= dry # land zones: stand on land, not on a lake inside the zone
if not mask.any():
continue
# Peel the zone from its edges inwards; the last cells left are the deepest.
inner = mask
while True:
shrunk = inner & ~dilate(~inner, 1)
if not shrunk.any():
break
inner = shrunk
rows, cols = np.nonzero(inner)
r, c = rows[len(rows) // 2], cols[len(cols) // 2]
out[spec["id"]] = [int(-radius + c * step), None, int(-radius + r * step)]
names = preview["biomes"]
for cave in plan["caves"]:
if cave["biome"] not in names:
continue
biome = names.index(cave["biome"])
best = None
for y, cells in preview["caves"].items():
grid = (np.array(cells).reshape(n, n) == biome) & near
if not grid.any():
continue
inner = grid
depth = 0
while True:
shrunk = inner & ~dilate(~inner, 1)
if not shrunk.any():
break
inner, depth = shrunk, depth + 1
if best is None or depth > best[0]:
rows, cols = np.nonzero(inner)
r, c = rows[len(rows) // 2], cols[len(cols) // 2]
best = (depth, [int(-radius + c * step), int(y), int(-radius + r * step)])
if best:
out[cave["id"]] = best[1]
return out
def biome_samples(zone, plan, preview):
"""{zone id: [x, None, z]}: for zones made of biomes, a point deep inside their first biome (the hazard's own
biome, e.g. the jagged peaks of Frostpeak), for the hazard tests."""
n, step, radius = preview["size"], preview["step"], preview["radius"]
names = preview["biomes"]
surface = np.array(preview["surface"]).reshape(n, n)
dry = np.array(preview["height"]).reshape(n, n) > preview["sea_level"]
out = {}
for index, spec in enumerate(plan["zones"]):
biomes = spec["rule"].get("biomes")
if not biomes or biomes[0] not in names:
continue
mask = (zone == index) & dry & (surface == names.index(biomes[0]))
if not mask.any():
continue
inner = mask
while True:
shrunk = inner & ~dilate(~inner, 1)
if not shrunk.any():
break
inner = shrunk
rows, cols = np.nonzero(inner)
r, c = rows[len(rows) // 2], cols[len(cols) // 2]
out[spec["id"]] = [int(-radius + c * step), None, int(-radius + r * step)]
return out
def write_datapack(zone, plan, preview):
runs = []
for value in zone.flatten().tolist():
if runs and runs[-1][0] == value:
runs[-1][1] += 1
else:
runs.append([value, 1])
keep = ("id", "name", "levels")
DATAPACK_LAYOUT.parent.mkdir(parents=True, exist_ok=True)
DATAPACK_LAYOUT.write_text(json.dumps({
"_comment": "Generated by tools/zone_layout.py from world/zones.json; do not edit by hand.",
"seed": preview["seed"], "radius": preview["radius"], "step": preview["step"], "size": preview["size"],
"zones": [{k: z[k] for k in keep} for z in plan["zones"]],
"caves": [{**{k: c[k] for k in keep}, "biome": c["biome"]} for c in plan["caves"]],
"samples": samples(zone, plan, preview),
"biome_samples": biome_samples(zone, plan, preview),
"grid_rle": runs,
}, separators=(",", ":")) + "\n")
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--preview", type=Path, help="biome preview JSON (default: the recipe's seed)")
parser.add_argument("--scale", type=int, default=6, help="pixels per grid cell in the map")
args = parser.parse_args()
recipe = json.loads((ROOT / "world/world.json").read_text())
preview_path = args.preview or PREVIEWS / f"{recipe['seed']}.json"
if not preview_path.exists():
sys.exit(f"{preview_path} is missing: run tools/seed_search.py (or /cobblesurvival biomepreview "
f"{recipe['seed']} 1700 24 on the test server) first.")
preview = json.loads(preview_path.read_text())
plan = json.loads((ROOT / "world/zones.json").read_text())
zone, land, height = assign(preview, plan)
stats = summary(zone, land, plan, preview["step"])
ids = [z["id"] for z in plan["zones"]]
(ROOT / "build").mkdir(exist_ok=True)
(ROOT / "build/zone-layout.json").write_text(json.dumps({
"seed": preview["seed"], "radius": preview["radius"], "step": preview["step"], "size": preview["size"],
"zones": ids, "grid": zone.flatten().tolist(), "stats": stats,
}))
render(zone, land, height, plan, stats, preview, ROOT / "build/zone-map.png", args.scale)
write_datapack(zone, plan, preview)
for spec in plan["zones"]:
s = stats[spec["id"]]
print(f" {spec['name']:18s} Lv {spec['levels'][0]:2d}-{spec['levels'][1]:2d} "
f"{s['land_percent']:5.1f}% of land {s['km2']:5.2f} km² {s['pieces']} piece(s)")
if stats["_unassigned_land_cells"]:
print(f" warning: {stats['_unassigned_land_cells']} land cells match no zone")
print(f"wrote build/zone-map.png, build/zone-layout.json and {DATAPACK_LAYOUT.relative_to(ROOT)}")
if __name__ == "__main__":
main()
+263
View File
@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""Give every zone its own Pokémon: write one spawn pool per zone from Cobblemon's own spawn data.
Cobblemon's spawn files are switched off for the island (datapacks/core/pack.mcmeta filters
data/cobblemon/spawn_pool_world/). In their place this tool writes
datapacks/core/data/cobblesurvival/spawn_pool_world/<zone>.json:
1. Every evolution line that Cobblemon lets spawn somewhere on the island gets one home
zone. Lines with the fewest possible biomes are placed first. A line's score for a zone
is how much of the zone is made of the line's biomes, times how much of the line's own
range the zone covers (square root), times how well the line's strength
(base-stat total of its strongest form, as a rank among all lines) matches the zone's
level band, divided by how full the zone already is against its "lines" target. So strong
lines (pseudo-legendaries) end up in high-level zones. Legendary, mythical, Ultra Beast
and Paradox Pokémon are left out. Lines that can spawn in nearly every biome (Cobblemon's
catch-all tags) are placed last: by strength into the land zones, weakest first, filling
each zone up to its target.
2. In its home zone a line keeps Cobblemon's spawn entries (bucket, weight, time, light,
presets) for the biomes the zone has, plus "zone": "<id>". Levels are clamped into the
zone's band, so the band picks the evolution stages: a stage whose levels miss the band
doesn't spawn there. If no stage fits, the closest one spawns across the whole band.
3. world/zones.json → "species" pins a line to a zone by hand: {"pikachu": "southwold"}.
4. In cave zones, sky-light minimums, "can see sky" and the "natural" ground preset are dropped.
Also writes docs/zone-species.md, the list per zone for review.
Needs: build/zone-layout.json (tools/zone_layout.py), build/field-guide/fieldguide.json
(tools/fieldguide/build.py; its "hidden_spawns" say which island biomes each of Cobblemon's own,
filtered-out entries can use) and the
Cobblemon jar in build/server/mods (any test-server run).
Usage: tools/zone_spawns.py
"""
import collections
import copy
import json
import re
import sys
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "datapacks/core/data/cobblesurvival/spawn_pool_world"
REPORT = ROOT / "docs/zone-species.md"
EXCLUDED_LABELS = {"legendary", "mythical", "ultra_beast", "paradox", "restricted"}
MIN_CELLS = 5 # a zone "has" a biome when at least this many grid cells of it lie in the zone
# A line whose entries cover this share of the island's biomes lives "anywhere" (Cobblemon's catch-all
# tags): its biomes say nothing about a home, so it only goes to a land zone, by strength. Its cave and
# water entries still spawn underground and in the rivers of that zone.
ALL_ROUNDER = 0.8
def cobblemon_jar():
jars = sorted((ROOT / "build/server/mods").glob("Cobblemon-*.jar"))
if not jars:
sys.exit("no Cobblemon jar in build/server/mods: run tools/build.py and tools/selftest.py (or the quick check) first")
return jars[-1]
def load_entries(jar):
"""{(pool, id): entry} for every spawn entry of Cobblemon's world spawn pools."""
entries = {}
with zipfile.ZipFile(jar) as z:
for name in z.namelist():
m = re.fullmatch(r"data/cobblemon/spawn_pool_world/(.+)\.json", name)
if not m:
continue
data = json.loads(z.read(name))
if not data.get("enabled", True) or data.get("neededInstalledMods"):
continue
pool = f"cobblemon:{m[1]}"
for entry in data.get("spawns", []):
# Same key as the field guide, which names an entry without an id after its file.
entries[(pool, entry.get("id") or pool)] = entry
return entries
def zone_biomes(plan, layout, preview):
"""{zone: {biome: share of the zone's cells}}, ignoring biomes with fewer than MIN_CELLS cells."""
names = preview["biomes"]
counts = collections.defaultdict(collections.Counter)
for zone_index, biome in zip(layout["grid"], preview["surface"]):
if zone_index >= 0:
counts[layout["zones"][zone_index]][names[biome]] += 1
shares = {}
for zid, c in counts.items():
kept = {b: n for b, n in c.items() if n >= MIN_CELLS}
total = sum(kept.values())
shares[zid] = {b: n / total for b, n in kept.items()}
for cave in plan["caves"]:
shares[cave["id"]] = {cave["biome"]: 1.0}
return shares
def base_stat_totals(jar):
"""{species id: base-stat total} from Cobblemon's species files."""
totals = {}
with zipfile.ZipFile(jar) as z:
for name in z.namelist():
m = re.fullmatch(r"data/cobblemon/species/[^/]+/([^/]+)\.json", name)
if m:
stats = json.loads(z.read(name)).get("baseStats", {})
totals[f"cobblemon:{m[1]}"] = sum(stats.values())
return totals
def load_before(zone, room, stretch, target):
"""How many lines a land zone may hold once all-rounders are added."""
return target[zone] - room[zone] + room[zone] * stretch
def line_root(species_id, species):
seen = set()
while species_id in species and species[species_id].get("pre") and species_id not in seen:
seen.add(species_id)
species_id = species[species_id]["pre"]
return species_id
def main():
plan = json.loads((ROOT / "world/zones.json").read_text())
layout = json.loads((ROOT / "build/zone-layout.json").read_text())
recipe = json.loads((ROOT / "world/world.json").read_text())
preview = json.loads((ROOT / f"build/server/seed-search/cobblesurvival/biome_preview/{recipe['seed']}.json").read_text())
guide = json.loads((ROOT / "build/field-guide/fieldguide.json").read_text())
species = guide["species"]
jar = cobblemon_jar()
entries = load_entries(jar)
bst = base_stat_totals(jar)
zones = {z["id"]: z for z in plan["zones"]} | {c["id"]: c for c in plan["caves"]}
shares = zone_biomes(plan, layout, preview)
biomes_of = {zid: set(s) for zid, s in shares.items()}
island_biomes = set().union(*biomes_of.values())
land_zones = {z["id"] for z in plan["zones"] if "water" not in z["rule"]}
cave_zones = {c["id"] for c in plan["caves"]}
mids = {zid: sum(z["levels"]) / 2 for zid, z in zones.items()}
low, high = min(mids.values()), max(mids.values())
tier = {zid: (m - low) / (high - low) for zid, m in mids.items()}
# Island biomes of every Cobblemon entry, from the field guide (it resolves biome tags). The guide lists
# pools that datapacks/core's filter hides, Cobblemon's own, under "hidden_spawns"; older guides had
# them in "spawns".
cobblemon_spawns = guide.get("hidden_spawns") or guide["spawns"]
lines = collections.defaultdict(list) # line root -> [(entry, island biomes, species id)]
for s in cobblemon_spawns:
if s["type"] != "pokemon" or s["status"] != "available" or not s["biomes"]:
continue
entry = entries.get((s["pool"], s["id"]))
if entry is None:
continue
member = s["members"][0]["species"]
if EXCLUDED_LABELS & set(species.get(member, {}).get("labels", [])):
continue
lines[line_root(member, species)].append((entry, set(s["biomes"]), member, s["level"]))
pinned = {f"cobblemon:{k}" if ":" not in k else k: v for k, v in plan.get("species", {}).items()}
wanted = {zid: z.get("lines", 20) for zid, z in zones.items()}
scale = len(lines) / sum(wanted.values())
target = {zid: n * scale for zid, n in wanted.items()}
# Strength of a line: the base-stat total of its strongest member, as a rank from 0 (weakest) to 1.
strength = {root: max(bst.get(m, 0) for _, _, m, _ in members) for root, members in lines.items()}
ranked = sorted(strength, key=strength.get)
rank = {root: i / max(len(ranked) - 1, 1) for i, root in enumerate(ranked)}
load = collections.Counter()
home = {}
biomes_of_line = {root: set().union(*(b for _, b, _, _ in members)) for root, members in lines.items()}
all_rounders = {root for root, b in biomes_of_line.items()
if root not in pinned and len(b) >= ALL_ROUNDER * len(island_biomes)}
for root, zid in pinned.items():
if root in lines:
home[root] = zid
load[zid] += 1
# 1. Lines with a habitat, the most specific first.
for root in sorted(lines, key=lambda r: (len(biomes_of_line[r]), r)):
if root in home or root in all_rounders:
continue
line_biomes = biomes_of_line[root]
def score(zid):
habitat = sum(share for b, share in shares[zid].items() if b in line_biomes)
# How much of the line's own range this zone covers.
specific = len(line_biomes & biomes_of[zid]) / len(line_biomes)
fit = 1 - abs(rank[root] - tier[zid])
return habitat * specific ** 0.5 * fit ** 2 / (1 + load[zid] / target[zid])
candidates = [zid for zid in zones if line_biomes & biomes_of[zid]]
if candidates:
home[root] = max(candidates, key=score)
load[home[root]] += 1
# 2. All-rounders fill the land zones up to their targets, weakest lines into the lowest zones.
by_tier = sorted(land_zones, key=lambda z: tier[z])
room = {z: max(0.0, target[z] - load[z]) for z in by_tier}
total_room = sum(room.values()) or 1
stretch = max(1.0, len(all_rounders) / total_room)
zone_iter = iter(by_tier)
current = next(zone_iter)
for root in sorted(all_rounders, key=lambda r: rank[r]):
while load[current] >= load_before(current, room, stretch, target) and current != by_tier[-1]:
current = next(zone_iter)
home[root] = current
load[current] += 1
pools = collections.defaultdict(list)
report = collections.defaultdict(list)
for root, zid in home.items():
lo, hi = zones[zid]["levels"]
usable = [(e, b, m, lv) for e, b, m, lv in lines[root] if b & biomes_of[zid]]
fitting = [(e, m, max(lv[0], lo), min(lv[1], hi)) for e, b, m, lv in usable if lv[0] <= hi and lv[1] >= lo]
if not fitting:
# No stage reaches the band: the closest stage spawns across all of it.
e, b, m, lv = min(usable, key=lambda u: min(abs(u[3][0] - hi), abs(u[3][1] - lo)))
fitting = [(e, m, lo, hi)]
for entry, member, a, b in fitting:
spawn = copy.deepcopy(entry)
spawn["id"] = f"{zid}-{entry.get('id') or member.split(':')[1]}-{len(pools[zid])}"
spawn["level"] = f"{a}-{b}"
condition = spawn.setdefault("condition", {})
condition["zone"] = zid
if zid in cave_zones:
# Many entries come from Cobblemon's surface spawns (e.g. "spooky" biomes include the deep
# dark); underground they could never see the sky.
condition.pop("minSkyLight", None)
if condition.get("canSeeSky") is True:
del condition["canSeeSky"]
# "natural" only allows natural ground; cave floors (basalt deltas: magma, smooth basalt) often aren't.
if "natural" in spawn.get("presets", []):
spawn["presets"] = [x for x in spawn["presets"] if x != "natural"]
pools[zid].append(spawn)
names = sorted({species[m]["name"] for _, m, _, _ in fitting})
report[zid].append((species.get(root, {}).get("name", root), names))
OUT.mkdir(parents=True, exist_ok=True)
for old in OUT.glob("*.json"):
old.unlink()
for zid, spawns in pools.items():
(OUT / f"{zid}.json").write_text(json.dumps({
"_comment": "Generated by tools/zone_spawns.py from Cobblemon's spawn data; do not edit by hand.",
"enabled": True, "neededInstalledMods": [], "neededUninstalledMods": [],
"spawns": sorted(spawns, key=lambda s: s["id"]),
}, indent=1) + "\n")
lines_out = ["# Pokémon per zone", "",
"Generated by `tools/zone_spawns.py` from Cobblemon's spawn data and `world/zones.json`; "
"do not edit by hand. Each evolution line spawns in one zone; the stages listed are the ones "
"whose levels reach the zone's band. Pin a line to another zone in `world/zones.json` → "
"`species`.", ""]
for zid, zone in zones.items():
rows = sorted(report.get(zid, []))
lines_out += [f"## {zone['name']} (Lv {zone['levels'][0]}–{zone['levels'][1]}, {len(rows)} lines)", ""]
lines_out += [", ".join(f"{root}" + (f" ({', '.join(n)})" if n != [root] else "") for root, n in rows) or "none", ""]
REPORT.write_text("\n".join(lines_out))
unplaced = [r for r in lines if r not in home]
for zid, zone in zones.items():
print(f" {zone['name']:18s} {len(report.get(zid, [])):3d} lines {len(pools.get(zid, [])):4d} spawn entries")
print(f"{len(home)} lines placed, {len(unplaced)} with no matching zone; wrote {OUT.relative_to(ROOT)} and {REPORT.relative_to(ROOT)}")
if __name__ == "__main__":
main()