Ore veins: make_world.py loads the island again row by row until Regenerating Ore Veins has filled every chunk (/cobblesurvival veins unfilled), cave tiers sit at their biome's depths, small zones get denser veins, showcase veins are full size. The world report checks surface tiers against the zone grid, cave tiers within two biome cells of their cave, and fails on unfilled chunks; the template passes (37,689 chunks). Drops: tools/drop_audit.py checks every creature drop a recipe or brewing needs (docs/pokemon-drops.md). Blaze rods, ghast tears, breeze rods, shulker shells and nether stars now drop from Pokemon through species additions (self-test drop_additions_apply). The field guide counts vanilla mobs as absent, applies species additions and reads the veins as sources. Cobblebase tried (loads, self-tests pass) and left out until Adrian decides its scope. Phase 4 checklist with the open questions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
501 lines
27 KiB
Python
501 lines
27 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").
|
|
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 any vein
|
|
and a chunk the mod never put veins into.
|
|
* 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)$")
|
|
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 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)
|
|
per_tier, untracked, stray = {}, {}, {}
|
|
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("_showcase").removesuffix("_deep")
|
|
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 vein in the world" for tier in plan["ores"] if tier not in per_tier]
|
|
unfilled = [c for c in full_chunks if c not in filled]
|
|
if unfilled:
|
|
problems.append(f"{len(unfilled)} chunks never got their ore veins (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
|
|
|
|
|
|
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)
|
|
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()
|