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