- 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>
560 lines
30 KiB
Python
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()
|