Files
cobblemon-survival-pack/tools/world_report.py
T
claudeandClaude Opus 5.5 5d275e4e14 Phase 4: regenerating ore veins, each tier in one zone
Regenerating Ore Veins 1.4.0 (CurseForge only, MIT). tools/zone_ores.py
writes its config from world/zones.json: surface zones as boxes from the
zone grid, cave tiers by cave biome, 21 tiers including Cobblemon's
evolution stones (their natural ores are off). The world report now
allows ore only in tracked veins inside their tier's zone. Self-tests:
a mined vein regrows after its chunk unloads and after a restart (new
after_restart pass in selftest.py and quick_check.py). make_world places
showcase veins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 02:30:34 +01:00

471 lines
25 KiB
Python

#!/usr/bin/env python3
"""Check and picture a generated world (default: build/world-template).
* Ore scan: every block in every stored chunk is checked. Ore ("*_ore",
ancient debris, raw ore blocks from large veins) is only allowed as part of
a regenerating vein (Regenerating Ore Veins' saved data), and each vein's
blocks must lie in the zone of its ore tier (world/zones.json → "ores"),
or at most two radii from a block of the same vein that does: the mod
checks the biome only at a vein's centre, so its edge can reach over the
border of a cave zone. Untracked ore fails the check, and so (except with
--sample) does an ore tier without any vein.
* 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
import zone_grid # noqa: E402
ROOT = Path(__file__).resolve().parent.parent
ORE = re.compile(r"(_ore|:ancient_debris|:raw_(iron|copper|gold)_block)$")
# Blocks the regenerating veins are made of (amethyst isn't "ore" by name but is a tier).
VEIN_BLOCKS = {b for v in json.loads((ROOT / "pack/config/regenerating_ore_veins/veins.json").read_text())
for b in v["blocks"]}
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, ore_at = {}, {}, 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) or name in VEIN_BLOCKS}
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
if count and (ORE.search(name) or name in VEIN_BLOCKS):
biome_palette, biome_indices = anvil.section_biomes(section)
for idx in np.flatnonzero(indices == i).tolist():
y, z, x = idx // 256, (idx // 16) % 16, idx % 16
biome_name = biome_palette[int(biome_indices[(y // 4) * 16 + (z // 4) * 4 + x // 4])]
ore_at.append((cx * 16 + x, section["Y"] * 16 + y, cz * 16 + z, name, biome_name))
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, ore_at
# 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, ore_at = [], {}, 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, oa in pool.map(scan_region, regions):
ore_at += oa
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, "ore_at": ore_at, "chunks": chunks, "sections_per_biome": present,
"strongholds": strongholds, "entities": entities, "loot": loot}
def read_veins(world):
"""{(x, y, z): vein id} from Regenerating Ore Veins' saved data (empty if the mod never ran)."""
path = Path(world, "data/regenerating_ore_veins_veins.dat")
if not path.exists():
return {}
import gzip
data = anvil.parse_nbt(gzip.decompress(path.read_bytes()))
veins = {}
for entry in data.get("data", data).get("Entries", []):
pos = entry["Pos"]
if isinstance(pos, dict):
key = (pos["X"], pos["Y"], pos["Z"])
elif isinstance(pos, int): # BlockPos.asLong
x, z, y = pos >> 38, (pos << 26) >> 38, ((pos & 0xFFF) ^ 0x800) - 0x800
key = (x, y, ((z + (1 << 25)) % (1 << 26)) - (1 << 25))
else:
key = tuple(int(v) for v in pos)
veins[key] = entry.get("VeinId", "")
return veins
def check_veins(world, ore_at, sample):
"""Every ore block belongs to a tracked vein in its tier's zone; returns (blocks per tier and zone, problems)."""
plan = json.loads((ROOT / "world/zones.json").read_text())
grid = zone_grid.load()
tier_zone = {}
for tier, spec in plan["ores"].items():
tier_zone[tier] = tier_zone[f"{tier}_deep"] = (spec["zone"], spec["radius"][1])
veins = read_veins(world)
per_tier, untracked, stray = {}, {}, []
blocks = {(x, y, z): (name, biome) for x, y, z, name, biome in ore_at}
zone_of = {pos: grid.zone_at(pos[0], pos[2], biome) for pos, (_, biome) in blocks.items()}
for pos, (name, _) in blocks.items():
vein = veins.get(pos)
if vein is None:
if ORE.search(name): # amethyst outside a vein is a natural geode
untracked[name] = untracked.get(name, 0) + 1
continue
tier = vein[:-5] if vein.endswith("_deep") else vein
counts = per_tier.setdefault(tier, {})
counts[zone_of[pos] or "none"] = counts.get(zone_of[pos] or "none", 0) + 1
want, radius = tier_zone.get(vein, (None, 0))
if zone_of[pos] == want:
continue
reach = 2 * radius
near = any(veins.get(q) == vein and zone_of.get(q) == want
for q in ((pos[0] + dx, pos[1] + dy, pos[2] + dz) for dx in range(-reach, reach + 1)
for dy in range(-reach, reach + 1) for dz in range(-reach, reach + 1)))
if not near:
stray.append((vein, pos, zone_of[pos]))
problems = [f"{n} {name} outside any regenerating vein" for name, n in sorted(untracked.items())]
if not sample:
problems += [f"ore tier {tier} has no vein in the world" for tier in plan["ores"] if tier not in per_tier]
by_vein = {}
for vein, pos, zone in stray:
by_vein.setdefault((vein, zone), []).append(pos)
problems += [f"{len(at)} blocks of vein {vein} in zone {zone}, not {tier_zone.get(vein, ('(no tier)',))[0]} "
f"(e.g. {at[0]})" for (vein, zone), at in sorted(by_vein.items(), key=str)]
return per_tier, problems
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}
vein_counts, vein_problems = check_veins(args.world, data["ore_at"], args.sample)
report = {
"chunks_scanned": data["chunks"],
"ore_blocks": {k: v for k, v in data["ores"].items() if ORE.search(k) or k in VEIN_BLOCKS},
"vein_blocks_per_tier_and_zone": vein_counts,
"mob_source_blocks": {k: v for k, v in data["ores"].items() if MOB_BLOCK.search(k) or "shrieker" in 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() if MOB_BLOCK.search(name) or "shrieker" in name]
+ vein_problems
+ 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, ore only in regenerating veins in their zones, "
f"no vanilla mobs or mob sources, only listed biomes"
+ ("" if args.sample else ", border fits the coast"))
if __name__ == "__main__":
main()