Files
claudeandClaude Opus 5.5 d29e177f9d Phase 9: safe start island, better quest book, every useful item obtainable
- Button Isle is peaceful: wild Pokémon only fight a player who hit them first
  (tuning/server.json peaceful_zones); its apricorns and coal make Poké Balls
  (4 apricorns + coal or charcoal), and the world report checks it has enough.
- Quest book: no boilerplate; each zone page says what is particular to it, and
  every zone that needs gear or a field move to be safe has its own quest. New
  page on the bigger mods (Cold Sweat, Fight or Flight, Cobblebase, breeding,
  veins, shared loot, maps and info).
- The Mire's poison stops 10 blocks below the surface.
- Player battles never cost a Pokémon (CobbleHardcoreMon config, mixins, and the
  companion mod's own permadeath).
- tools/item_audit.py: every item with a use has a source on the island; new
  sources for the gaps it found (species additions, three recipes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 15:04:09 +01:00

560 lines
30 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: the underground
veins and the companion mod's ore nodes), and each vein's blocks must lie
in the zone of its ore tier (world/zones.json → "ores").
A surface tier's blocks must lie in its zone's grid cells (the mod checks
every block against the zone's boxes; at a cave border a vein can reach
into the cave biome). A cave tier's blocks must lie within two biome cells
(8 blocks) of its cave biome: the mod checks the biome only at a vein's
centre, and Minecraft blends biomes over a few blocks. Untracked ore fails
the check, and so (except with --sample) do an ore tier without nodes, a
surface tier without underground veins, and a chunk that never got its
veins and nodes.
* Start island: Button Isle has enough apricorns and coal ore for Poké Balls
(four apricorns and coal or charcoal make one; Adrian, 2026-11-10).
* 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 collections import defaultdict
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)$")
CAVE_BIOMES = sorted(zone_grid.load().caves)
NEAR_CELLS = 2 # biome cells (4 blocks) around a vein block in which its cave biome may lie
# 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, full = {}, {}, 0, {}, [], [], []
caves = {} # (chunk x, chunk z) -> 96x4x4 biome cells (y, z, x): index into CAVE_BIOMES + 1, 0 = no cave biome
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
full.append((cx, cz))
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
biome_palette = section.get("biomes", {}).get("palette", [])
if any(b in CAVE_BIOMES for b in biome_palette):
biome_palette, biome_indices = anvil.section_biomes(section)
ids = np.array([CAVE_BIOMES.index(b) + 1 if b in CAVE_BIOMES else 0 for b in biome_palette], dtype=np.int8)
cells = caves.setdefault((cx, cz), np.zeros((96, 4, 4), dtype=np.int8))
y0 = (section["Y"] * 16 - anvil.MIN_Y) // 4
cells[y0:y0 + 4] = ids[biome_indices].reshape(4, 4, 4)
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))
# Which cave biomes lie within NEAR_CELLS biome cells of each vein block (chunks of this region only).
near_at = []
for x, y, z, name, biome_name in ore_at:
near = set()
qx0, qy0, qz0 = x >> 2, (y - anvil.MIN_Y) >> 2, z >> 2
for qx in range(qx0 - NEAR_CELLS, qx0 + NEAR_CELLS + 1):
for qz in range(qz0 - NEAR_CELLS, qz0 + NEAR_CELLS + 1):
cells = caves.get((qx >> 2, qz >> 2))
if cells is None:
continue
column = cells[max(0, qy0 - NEAR_CELLS):qy0 + NEAR_CELLS + 1, qz & 3, qx & 3]
near.update(CAVE_BIOMES[i - 1] for i in np.unique(column) if i)
near_at.append((x, y, z, name, biome_name, tuple(sorted(near))))
return rx, rz, floor, top, biome, list(names), ores, chunks, present, strongholds, loot, near_at, full
# 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, full = [], {}, 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, fc in pool.map(scan_region, regions):
ore_at += oa
full += fc
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, "full_chunks": full, "chunks": chunks,
"sections_per_biome": present,
"strongholds": strongholds, "entities": entities, "loot": loot}
def read_veins(world):
"""({(x, y, z): vein id}, {(chunk x, chunk z) the mod has filled}) from Regenerating Ore Veins' saved data."""
path = Path(world, "data/regenerating_ore_veins_veins.dat")
if not path.exists():
return {}, set()
import gzip
data = anvil.parse_nbt(gzip.decompress(path.read_bytes()))
data = data.get("data", data)
filled = set()
for packed in data.get("GeneratedChunks", []): # ChunkPos.toLong: x in the low 32 bits, z in the high
packed = int(packed)
x, z = packed & 0xFFFFFFFF, (packed >> 32) & 0xFFFFFFFF
filled.add((x - (1 << 32) if x >= 1 << 31 else x, z - (1 << 32) if z >= 1 << 31 else z))
veins = {}
for entry in 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, filled
def read_node_chunks(world):
"""The chunks the companion mod has put its ore nodes into (ore/OreNodes.kt)."""
path = Path(world, "data/cobblesurvival_ore_nodes.dat")
if not path.exists():
return set()
import gzip
data = anvil.parse_nbt(gzip.decompress(path.read_bytes()))
done = set()
for packed in data.get("data", data).get("done", []):
packed = int(packed)
x, z = packed & 0xFFFFFFFF, (packed >> 32) & 0xFFFFFFFF
done.add((x - (1 << 32) if x >= 1 << 31 else x, z - (1 << 32) if z >= 1 << 31 else z))
return done
def check_veins(world, ore_at, full_chunks, 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()
cave_biome = {c["id"]: c["biome"] for c in plan["caves"]}
veins, filled = read_veins(world)
nodes_done = read_node_chunks(world)
per_tier, untracked, stray, kinds = {}, {}, {}, set()
for x, y, z, name, biome, near_caves in ore_at:
vein = veins.get((x, y, z))
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.removesuffix("_node").removesuffix("_deep")
kinds.add((tier, "node" if vein.endswith("_node") else "vein"))
zone = grid.zone_at(x, z, biome) or "none"
counts = per_tier.setdefault(tier, {})
counts[zone] = counts.get(zone, 0) + 1
want = plan["ores"].get(tier, {}).get("zone")
if want in cave_biome:
ok = zone == want or cave_biome[want] in near_caves
else:
ok = want is not None and grid.zone_at(x, z) == want
if not ok:
stray.setdefault((vein, zone, want), []).append((x, y, z))
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 ore nodes in the world" for tier in plan["ores"] if (tier, "node") not in kinds]
problems += [f"ore tier {tier} has no underground veins in the world" for tier, spec in plan["ores"].items()
if "veins" in spec and (tier, "vein") not in kinds]
unfilled = [c for c in full_chunks if c not in filled or c not in nodes_done]
if unfilled:
problems.append(f"{len(unfilled)} chunks never got their ore veins and nodes (e.g. chunk {unfilled[0]})")
problems += [f"{len(at)} blocks of vein {vein} in zone {zone}, not {want or '(no tier)'} (e.g. {at[0]})"
for (vein, zone, want), at in sorted(stray.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
# Adrian, 2026-11-10: the start island alone makes Poké Balls (four apricorns and coal or charcoal, the pack's
# poke_ball_from_coal recipe), with some exploring. Coal grows back in its veins; apricorns grow back on their trees.
START_MINIMUM = {"apricorns": 100, "coal ore": 500}
def check_start_isle(world):
"""Apricorns and coal ore on Button Isle (the zone within world/zones.json's radius of 0,0), from its stored chunks."""
plan = json.loads((ROOT / "world/zones.json").read_text())
radius = next(z for z in plan["zones"] if z["id"] == "button_isle")["rule"]["within"]
found = defaultdict(int)
span = range(-((radius + 511) // 512), (radius + 511) // 512)
for rx in span:
for rz in span:
path = world / "region" / f"r.{rx}.{rz}.mca"
if not path.exists():
continue
for cx, cz, chunk in anvil.region_chunks(path):
x0, z0 = cx * 16, cz * 16
if math.hypot(x0 + 8, z0 + 8) > radius + 12:
continue
inside = np.hypot(np.arange(16)[None, :] + x0 + .5, np.arange(16)[:, None] + z0 + .5) <= radius
for section in chunk.get("sections", []):
palette, indices = anvil.section_blocks(section)
wanted = [i for i, name in enumerate(palette) if name.endswith("_apricorn") or name.endswith("coal_ore")]
cube = indices.reshape(16, 16, 16)
for i in wanted:
kind = "apricorns" if palette[i].endswith("_apricorn") else "coal ore"
found[kind] += int(((cube == i) & inside[None, :, :]).sum())
problems = [f"Button Isle has {found[k]} {k}, fewer than {n}" for k, n in START_MINIMUM.items() if found[k] < n]
return dict(found), problems
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"], data["full_chunks"], args.sample)
start_isle, start_problems = check_start_isle(args.world)
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,
"start_isle_ball_materials": start_isle,
"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
+ ([] if args.sample else start_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()