Free NeoForge 1.21.1 modpack on Cobblemon 1.8.4 built with packwiz, plus the Kotlin companion mod (cobblesurvival) and its built-in data pack. - Phase 0: packwiz pack with Modrinth/CurseForge cross-links, build and export tooling, Prism instance sync, test server with smoke checks. - Phase 1: Poke Ball shaped island (WorldGen Editor preset, seed isle-26), Adrian's biome list, no natural ore, structures without ore or vanilla mobs, no Nether portals, world template builder and world report. - Phase 2: permadeath (CobbleHardcoreMon plus the companion mod's gap fixes), Revival Charm, expensive Healing Machine, Lootr refreshing loot, in-game self-tests on Minecraft's GameTest framework. - Phase 3 so far: 14 zones with their own spawn pools and level bands (tools/zone_layout.py, tools/zone_spawns.py, "zone" spawn condition), Cold Sweat heat and cold as gates with boss-drop insulation recipes, spawn survey and hazard self-tests in tools/quick_check.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
387 lines
21 KiB
Python
387 lines
21 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; any ore block
|
|
("*_ore", ancient debris, raw ore blocks from large veins) fails the check.
|
|
* Biome set: every biome stored in any chunk section (surface and caves)
|
|
must be listed in world/biomes.json ("allowed" or "water").
|
|
* End access: at least one stronghold must start inside the world border.
|
|
* Gamerules in level.dat match world/world.json, and no vanilla mob is
|
|
stored in the world (only Pokémon; item frames, minecarts etc. don't count).
|
|
* No mob sources in blocks: no spawners, trial spawners or infested blocks,
|
|
and no sculk shrieker that can summon the Warden.
|
|
* Loot containers: Lootr containers (they refill) and vanilla ones still
|
|
waiting for conversion are counted. Lootr converts a container once the
|
|
chunks around it are loaded (a player nearby), so a pregenerated world
|
|
keeps vanilla ones until then; tools/quick_check.py tests the conversion.
|
|
* Border fit: all land must lie inside the world border, and the border may
|
|
stand at most `border_margin` (world/world.json) plus a little slack
|
|
outside the outermost land on its longer axis.
|
|
* Biome map: a top-down PNG (biome colours, hill shading, water depth,
|
|
border, island outline from the preset, legend) for review.
|
|
|
|
--sample is for partly generated worlds (tools/quick_check.py): it skips the
|
|
border, stronghold and map, which only make sense for the whole island.
|
|
|
|
Usage: tools/world_report.py [WORLD] [--map OUT.png] [--json OUT.json] [--no-map] [--sample]
|
|
"""
|
|
import argparse
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import sys
|
|
from concurrent.futures import ProcessPoolExecutor
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
import anvil # noqa: E402
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
ORE = re.compile(r"(_ore|:ancient_debris|:raw_(iron|copper|gold)_block)$")
|
|
MOB_BLOCK = re.compile(r":(spawner|trial_spawner|infested_\w+)$")
|
|
LOOT_CONTAINERS = {"minecraft:chest", "minecraft:trapped_chest", "minecraft:barrel", "minecraft:shulker_box"}
|
|
SEA_LEVEL = 63
|
|
BORDER_SLACK = 32 # blocks: coastline and border are both measured on a 4-block grid
|
|
|
|
|
|
def scan_region(path):
|
|
"""Per-region arrays at block resolution plus ore counts."""
|
|
rx, rz = (int(v) for v in Path(path).name.split(".")[1:3])
|
|
floor = np.full((512, 512), -10_000, dtype=np.int16)
|
|
top = np.full((512, 512), -10_000, dtype=np.int16)
|
|
biome = np.full((128, 128), -1, dtype=np.int16)
|
|
names, ores, chunks, present, strongholds = {}, {}, 0, {}, []
|
|
loot = {"lootr": 0, "unconverted": 0, "unconverted_at": []}
|
|
for cx, cz, chunk in anvil.region_chunks(path):
|
|
for be in chunk.get("block_entities", []):
|
|
if be.get("id", "").startswith("lootr:"):
|
|
loot["lootr"] += 1
|
|
elif be.get("id") in LOOT_CONTAINERS and "LootTable" in be:
|
|
loot["unconverted"] += 1
|
|
loot["unconverted_at"].append([be["x"], be["y"], be["z"]])
|
|
for structure in chunk.get("structures", {}).get("starts", {}):
|
|
strongholds.append((structure, cx * 16 + 8, cz * 16 + 8))
|
|
if not str(chunk.get("Status", "")).endswith("full"):
|
|
continue
|
|
chunks += 1
|
|
ox, oz = (cx - rx * 32) * 16, (cz - rz * 32) * 16
|
|
of = anvil.heightmap(chunk, "OCEAN_FLOOR")
|
|
mb = anvil.heightmap(chunk, "MOTION_BLOCKING")
|
|
if of is None or mb is None:
|
|
continue
|
|
floor[oz:oz + 16, ox:ox + 16] = of
|
|
top[oz:oz + 16, ox:ox + 16] = mb
|
|
sections = {s["Y"]: s for s in chunk.get("sections", [])}
|
|
for section in sections.values():
|
|
for name in section.get("biomes", {}).get("palette", []):
|
|
present[name] = present.get(name, 0) + 1
|
|
palette, indices = anvil.section_blocks(section)
|
|
entries = section.get("block_states", {}).get("palette", [])
|
|
hits = {i: name for i, name in enumerate(palette) if ORE.search(name) or MOB_BLOCK.search(name)}
|
|
hits.update({i: "minecraft:sculk_shrieker[can_summon=true]" for i, e in enumerate(entries)
|
|
if e["Name"] == "minecraft:sculk_shrieker" and e.get("Properties", {}).get("can_summon") == "true"})
|
|
for i, name in hits.items():
|
|
count = int(np.count_nonzero(indices == i))
|
|
if count:
|
|
ores[name] = ores.get(name, 0) + count
|
|
decoded = {}
|
|
for qz in range(4):
|
|
for qx in range(4):
|
|
y = int(mb[qz * 4 + 2, qx * 4 + 2]) - 1
|
|
section = sections.get(y // 16)
|
|
if section is None:
|
|
continue
|
|
if y // 16 not in decoded:
|
|
decoded[y // 16] = anvil.section_biomes(section)
|
|
palette, indices = decoded[y // 16]
|
|
name = palette[int(indices[((y % 16) // 4) * 16 + qz * 4 + qx])]
|
|
biome[oz // 4 + qz, ox // 4 + qx] = names.setdefault(name, len(names))
|
|
return rx, rz, floor, top, biome, list(names), ores, chunks, present, strongholds, loot
|
|
|
|
|
|
# Entities that aren't mobs: decorations, vehicles, and projectiles (a trial chamber's dispenser trap fires
|
|
# potions and arrows when a Pokémon steps on its plate, and they can be saved mid-flight).
|
|
NOT_MOBS = re.compile(r":(item|item_frame|glow_item_frame|painting|armor_stand|.*minecart.*|.*boat|"
|
|
r"marker|interaction|block_display|item_display|text_display|end_crystal|falling_block|"
|
|
r"potion|arrow|spectral_arrow|trident|snowball|egg|ender_pearl|eye_of_ender|experience_orb|"
|
|
r"experience_bottle|fireball|small_fireball|dragon_fireball|wither_skull|wind_charge|"
|
|
r"breeze_wind_charge|firework_rocket|llama_spit|shulker_bullet|fishing_bobber|tnt|"
|
|
r"area_effect_cloud|lightning_bolt|evoker_fangs|ominous_item_spawner)$")
|
|
|
|
|
|
def scan_entities(path):
|
|
counts = {}
|
|
for _, _, chunk in anvil.region_chunks(path):
|
|
for entity in chunk.get("Entities", []):
|
|
counts[entity["id"]] = counts.get(entity["id"], 0) + 1
|
|
return counts
|
|
|
|
|
|
def analyse(world):
|
|
regions = sorted(Path(world, "region").glob("r.*.*.mca"))
|
|
if not regions:
|
|
sys.exit(f"no region files in {world}/region")
|
|
coords = [tuple(int(v) for v in p.name.split(".")[1:3]) for p in regions]
|
|
x0, z0 = min(c[0] for c in coords), min(c[1] for c in coords)
|
|
w, h = (max(c[0] for c in coords) - x0 + 1) * 512, (max(c[1] for c in coords) - z0 + 1) * 512
|
|
floor = np.full((h, w), -10_000, dtype=np.int16)
|
|
top = np.full((h, w), -10_000, dtype=np.int16)
|
|
biome = np.full((h // 4, w // 4), -1, dtype=np.int16)
|
|
names, ores, chunks, present, strongholds = [], {}, 0, {}, []
|
|
loot = {"lootr": 0, "unconverted": 0, "unconverted_at": []}
|
|
with ProcessPoolExecutor(max_workers=os.cpu_count()) as pool:
|
|
for rx, rz, f, t, b, local, o, n, pr, sh, lt in pool.map(scan_region, regions):
|
|
for k, v in lt.items():
|
|
loot[k] += v
|
|
strongholds += sh
|
|
for k, v in pr.items():
|
|
present[k] = present.get(k, 0) + v
|
|
ix, iz = (rx - x0) * 512, (rz - z0) * 512
|
|
floor[iz:iz + 512, ix:ix + 512] = f
|
|
top[iz:iz + 512, ix:ix + 512] = t
|
|
remap = np.array([names.index(name) if name in names else names.append(name) or len(names) - 1
|
|
for name in local] + [-1], dtype=np.int16)
|
|
biome[iz // 4:iz // 4 + 128, ix // 4:ix // 4 + 128] = remap[b]
|
|
for k, v in o.items():
|
|
ores[k] = ores.get(k, 0) + v
|
|
chunks += n
|
|
entities = {}
|
|
for counts in pool.map(scan_entities, sorted(Path(world, "entities").glob("r.*.*.mca"))):
|
|
for k, v in counts.items():
|
|
entities[k] = entities.get(k, 0) + v
|
|
return {"origin": (x0 * 512, z0 * 512), "floor": floor, "top": top, "biome": biome,
|
|
"biome_names": names, "ores": ores, "chunks": chunks, "sections_per_biome": present,
|
|
"strongholds": strongholds, "entities": entities, "loot": loot}
|
|
|
|
|
|
def border_of(level):
|
|
return level.get("BorderCenterX", 0.0), level.get("BorderCenterZ", 0.0), level.get("BorderSize", 5.9999968e7)
|
|
|
|
|
|
def land_mask(data):
|
|
generated = data["floor"] > -10_000
|
|
return generated & (data["top"] == data["floor"]) & (data["floor"] >= SEA_LEVEL), generated
|
|
|
|
|
|
def island_mask(data, cell=16, min_share=0.01):
|
|
"""Land that belongs to a large land mass. Icebergs, sea stacks and wrecks
|
|
out at sea are dropped so they don't stretch the border. Land is grouped on
|
|
a `cell`-block grid, grown by one cell so rivers don't split the island."""
|
|
land, _ = land_mask(data)
|
|
h, w = land.shape[0] // cell, land.shape[1] // cell
|
|
cells = land[:h * cell, :w * cell].reshape(h, cell, w, cell).any(axis=(1, 3))
|
|
grown = cells.copy()
|
|
grown[1:] |= cells[:-1]
|
|
grown[:-1] |= cells[1:]
|
|
grown[:, 1:] |= cells[:, :-1]
|
|
grown[:, :-1] |= cells[:, 1:]
|
|
labels = np.zeros((h, w), dtype=np.int32)
|
|
sizes = [0]
|
|
for start in zip(*np.nonzero(grown)):
|
|
if labels[start]:
|
|
continue
|
|
sizes.append(0)
|
|
labels[start] = len(sizes) - 1
|
|
stack = [start]
|
|
while stack:
|
|
z, x = stack.pop()
|
|
sizes[-1] += 1
|
|
for nz, nx in ((z + 1, x), (z - 1, x), (z, x + 1), (z, x - 1)):
|
|
if 0 <= nz < h and 0 <= nx < w and grown[nz, nx] and not labels[nz, nx]:
|
|
labels[nz, nx] = labels[start]
|
|
stack.append((nz, nx))
|
|
keep = np.array(sizes) >= min_share * sum(sizes)
|
|
keep[0] = False
|
|
big = np.repeat(np.repeat(keep[labels], cell, axis=0), cell, axis=1)
|
|
out = np.zeros_like(land)
|
|
out[:h * cell, :w * cell] = land[:h * cell, :w * cell] & big
|
|
return out
|
|
|
|
|
|
def check_border(data, level, margin):
|
|
zs, xs = np.nonzero(island_mask(data))
|
|
ox, oz = data["origin"]
|
|
bounds = (int(xs.min()) + ox, int(zs.min()) + oz, int(xs.max()) + ox + 1, int(zs.max()) + oz + 1)
|
|
cx, cz, size = border_of(level)
|
|
half = size / 2
|
|
gaps = {"west": bounds[0] - (cx - half), "north": bounds[1] - (cz - half),
|
|
"east": (cx + half) - bounds[2], "south": (cz + half) - bounds[3]}
|
|
problems = [f"land beyond the {side} border by {-gap:.0f} blocks" for side, gap in gaps.items() if gap < 0]
|
|
starts = [s for s in data["strongholds"] if abs(s[1] - cx) < half and abs(s[2] - cz) < half]
|
|
inside = [[x, z] for name, x, z in starts if name == "minecraft:stronghold"]
|
|
structures = {}
|
|
for name, _, _ in starts:
|
|
structures[name] = structures.get(name, 0) + 1
|
|
if not inside:
|
|
problems.append("no stronghold inside the border, so the End can't be reached")
|
|
if min(gaps["west"], gaps["east"]) > margin + BORDER_SLACK and min(gaps["north"], gaps["south"]) > margin + BORDER_SLACK:
|
|
problems.append(f"border is more than {margin + BORDER_SLACK} blocks from the coast on every side")
|
|
return {"land_bounds": bounds, "strongholds_inside_border": inside,
|
|
"structures_inside_border": dict(sorted(structures.items(), key=lambda kv: -kv[1])),
|
|
"border": {"center": [cx, cz], "size": size},
|
|
"gaps": {k: round(v) for k, v in gaps.items()}, "problems": problems}
|
|
|
|
|
|
# Approximate colours in the style of common seed-map tools.
|
|
COLOURS = {
|
|
"ocean": (0, 0, 112), "deep_ocean": (0, 0, 48), "cold_ocean": (32, 32, 112), "deep_cold_ocean": (32, 32, 56),
|
|
"frozen_ocean": (112, 112, 214), "deep_frozen_ocean": (64, 64, 144), "lukewarm_ocean": (0, 0, 144),
|
|
"deep_lukewarm_ocean": (0, 0, 64), "warm_ocean": (0, 0, 172), "river": (48, 96, 255),
|
|
"frozen_river": (160, 160, 255), "beach": (250, 222, 85), "snowy_beach": (250, 240, 192),
|
|
"stony_shore": (162, 162, 132), "plains": (141, 179, 96), "sunflower_plains": (181, 219, 136),
|
|
"forest": (5, 102, 33), "flower_forest": (45, 142, 73), "birch_forest": (48, 116, 68),
|
|
"old_growth_birch_forest": (88, 156, 108), "dark_forest": (64, 81, 26), "meadow": (96, 164, 69),
|
|
"cherry_grove": (255, 145, 200), "swamp": (7, 249, 178), "mangrove_swamp": (44, 204, 142),
|
|
"taiga": (11, 102, 89), "old_growth_pine_taiga": (89, 102, 81), "old_growth_spruce_taiga": (129, 142, 121),
|
|
"snowy_taiga": (49, 85, 74), "snowy_plains": (255, 255, 255), "ice_spikes": (180, 220, 220),
|
|
"grove": (187, 211, 221), "snowy_slopes": (220, 220, 230), "frozen_peaks": (160, 180, 255),
|
|
"jagged_peaks": (220, 220, 220), "stony_peaks": (123, 143, 116), "windswept_hills": (96, 96, 96),
|
|
"windswept_forest": (80, 112, 80), "windswept_gravelly_hills": (136, 136, 136),
|
|
"desert": (250, 148, 24), "savanna": (189, 178, 95), "savanna_plateau": (167, 157, 100),
|
|
"windswept_savanna": (229, 218, 135), "badlands": (217, 69, 21), "eroded_badlands": (255, 109, 61),
|
|
"wooded_badlands": (176, 151, 101), "jungle": (83, 123, 9), "sparse_jungle": (98, 139, 23),
|
|
"bamboo_jungle": (118, 142, 20), "mushroom_fields": (255, 0, 255), "dripstone_caves": (134, 96, 67),
|
|
"lush_caves": (102, 140, 50), "deep_dark": (20, 40, 50),
|
|
}
|
|
|
|
|
|
def colour(name):
|
|
short = name.split(":", 1)[-1]
|
|
if short in COLOURS:
|
|
return COLOURS[short]
|
|
seed = sum(ord(c) * 31 ** i for i, c in enumerate(name)) & 0xFFFFFF
|
|
return (seed >> 16 & 255, seed >> 8 & 255, seed & 255)
|
|
|
|
|
|
def render_map(data, level, preset, out, scale=2):
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
full_land, _ = land_mask(data)
|
|
land, generated = full_land[::scale, ::scale], (data["floor"] > -10_000)[::scale, ::scale]
|
|
names = data["biome_names"]
|
|
lut = np.array([colour(n) for n in names] + [(0, 0, 0)], dtype=np.float32)
|
|
per_cell = 4 // scale
|
|
rgb = lut[np.repeat(np.repeat(data["biome"], per_cell, axis=0), per_cell, axis=1)]
|
|
height = data["floor"][::scale, ::scale].astype(np.float32)
|
|
gz, gx = np.gradient(np.where(generated, height, SEA_LEVEL))
|
|
shade = np.clip(1.0 + (gx * -0.6 + gz * -0.6) * 0.18 * scale, 0.55, 1.35)
|
|
depth = np.clip((data["top"][::scale, ::scale] - data["floor"][::scale, ::scale]).astype(np.float32), 0, 40)
|
|
water = generated & ~land
|
|
rgb = np.where(land[..., None], rgb * shade[..., None], rgb)
|
|
tint = np.array([30, 80, 170], dtype=np.float32) * (1.0 - depth / 55.0)[..., None]
|
|
rgb = np.where(water[..., None], rgb * 0.35 + tint * 0.65, rgb)
|
|
rgb = np.where(generated[..., None], rgb, 24)
|
|
img = Image.fromarray(np.clip(rgb, 0, 255).astype(np.uint8))
|
|
|
|
ox, oz = data["origin"]
|
|
to_px = lambda x, z: ((x - ox) / scale, (z - oz) / scale) # noqa: E731
|
|
draw = ImageDraw.Draw(img)
|
|
try:
|
|
font = ImageFont.truetype("DejaVuSans.ttf", 18)
|
|
small = ImageFont.truetype("DejaVuSans.ttf", 14)
|
|
except OSError:
|
|
font = small = ImageFont.load_default()
|
|
for entry in (e for e in preset["entries"] if e.get("type", "island") == "island"):
|
|
rx = entry.get("radius_x", entry.get("radius", 0)) * entry.get("stretch_x", 1.0)
|
|
rz = entry.get("radius_z", entry.get("radius", 0)) * entry.get("stretch_z", 1.0)
|
|
pts = []
|
|
for i in range(73):
|
|
a = math.radians(i * 5)
|
|
x, z = rx * math.cos(a), rz * math.sin(a)
|
|
r = math.radians(entry.get("rotation", 0))
|
|
pts.append(to_px(entry["x"] + x * math.cos(r) + z * math.sin(r),
|
|
entry["z"] - x * math.sin(r) + z * math.cos(r)))
|
|
draw.line(pts, fill=(255, 255, 255), width=1)
|
|
px, pz = to_px(entry["x"], entry["z"])
|
|
draw.text((px, pz - 24), entry.get("name", "").split(" (")[0], fill=(255, 255, 255), font=font,
|
|
anchor="mm", stroke_width=3, stroke_fill=(0, 0, 0))
|
|
cx, cz, size = border_of(level)
|
|
a, b = to_px(cx - size / 2, cz - size / 2), to_px(cx + size / 2, cz + size / 2)
|
|
draw.rectangle([a, b], outline=(255, 60, 60), width=3)
|
|
sx, sz = to_px(level.get("SpawnX", 0), level.get("SpawnZ", 0))
|
|
draw.ellipse([sx - 6, sz - 6, sx + 6, sz + 6], fill=(255, 230, 0), outline=(0, 0, 0), width=2)
|
|
|
|
counts = np.bincount(data["biome"][full_land[::4, ::4]].clip(0).ravel(), minlength=len(names))
|
|
total = counts.sum() or 1
|
|
order = [i for i in np.argsort(-counts) if counts[i] / total >= 0.001]
|
|
legend_w = 300
|
|
canvas = Image.new("RGB", (img.width + legend_w, max(img.height, 40 + 22 * len(order))), (24, 24, 24))
|
|
canvas.paste(img, (0, 0))
|
|
d = ImageDraw.Draw(canvas)
|
|
d.text((img.width + 14, 12), "Land biomes (share of land)", fill=(240, 240, 240), font=small)
|
|
for row, i in enumerate(order):
|
|
y = 40 + row * 22
|
|
d.rectangle([img.width + 14, y, img.width + 30, y + 16], fill=tuple(int(c) for c in lut[i]))
|
|
d.text((img.width + 38, y), f"{names[i].split(':', 1)[-1]} {100 * counts[i] / total:.1f}%",
|
|
fill=(230, 230, 230), font=small)
|
|
y = 40 + len(order) * 22 + 16
|
|
d.text((img.width + 14, y), f"1 px = {scale} blocks", fill=(180, 180, 180), font=small)
|
|
d.text((img.width + 14, y + 20), "red: world border", fill=(255, 120, 120), font=small)
|
|
d.text((img.width + 14, y + 40), "white: island outline (preset)", fill=(230, 230, 230), font=small)
|
|
d.text((img.width + 14, y + 60), "yellow dot: world spawn", fill=(255, 230, 0), font=small)
|
|
canvas.save(out, optimize=True)
|
|
return {names[i]: round(100 * counts[i] / total, 2) for i in order}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("world", nargs="?", type=Path, default=ROOT / "build/world-template")
|
|
parser.add_argument("--map", type=Path, default=ROOT / "build/island-map.png")
|
|
parser.add_argument("--json", type=Path, default=ROOT / "build/island-report.json")
|
|
parser.add_argument("--no-map", action="store_true")
|
|
parser.add_argument("--sample", action="store_true", help="partly generated world: skip border, strongholds, map")
|
|
args = parser.parse_args()
|
|
|
|
recipe = json.loads((ROOT / "world/world.json").read_text())
|
|
preset = json.loads((ROOT / recipe["preset"]).read_text())
|
|
level = anvil.read_level_dat(args.world)
|
|
data = analyse(args.world)
|
|
border = {"problems": []} if args.sample else check_border(data, level, recipe["border_margin"])
|
|
land, generated = land_mask(data)
|
|
rules = level.get("GameRules", {})
|
|
wrong_rules = {r: rules.get(r) for r, v in recipe.get("gamerules", {}).items() if rules.get(r) != str(v).lower()}
|
|
vanilla_mobs = {k: v for k, v in sorted(data["entities"].items(), key=lambda kv: -kv[1])
|
|
if not k.startswith("cobblemon:") and not NOT_MOBS.search(k)}
|
|
biomes = json.loads((ROOT / "world/biomes.json").read_text())
|
|
listed = set(biomes["allowed"]) | set(biomes["water"])
|
|
unlisted = {b: n for b, n in data["sections_per_biome"].items() if b not in listed}
|
|
report = {
|
|
"chunks_scanned": data["chunks"],
|
|
"ore_blocks": {k: v for k, v in data["ores"].items() if ORE.search(k)},
|
|
"mob_source_blocks": {k: v for k, v in data["ores"].items() if not ORE.search(k)},
|
|
"unlisted_biomes": unlisted,
|
|
"gamerules_not_as_recipe": wrong_rules,
|
|
"vanilla_mobs_in_world": vanilla_mobs,
|
|
"loot_containers": data["loot"],
|
|
"missing_listed_biomes": sorted(set(biomes["allowed"]) - set(data["sections_per_biome"])),
|
|
"sections_per_biome": dict(sorted(data["sections_per_biome"].items(), key=lambda kv: -kv[1])),
|
|
"land_km2": round(int(land.sum()) / 1e6, 2),
|
|
"spawn": [level.get("SpawnX"), level.get("SpawnY"), level.get("SpawnZ")],
|
|
**border,
|
|
}
|
|
if not args.no_map and not args.sample:
|
|
report["land_biomes_percent"] = render_map(data, level, preset, args.map)
|
|
report["map"] = str(args.map)
|
|
args.json.write_text(json.dumps(report, indent=2) + "\n")
|
|
print(json.dumps({k: v for k, v in report.items() if k != "land_biomes_percent"}, indent=2))
|
|
|
|
failures = ([f"{n} {name}" for name, n in data["ores"].items()] + border["problems"]
|
|
+ [f"unlisted biome {b} in {n} chunk sections" for b, n in unlisted.items()]
|
|
+ [f"gamerule {r} is {v}, recipe says {recipe['gamerules'][r]}" for r, v in wrong_rules.items()]
|
|
+ [f"{n} vanilla {mob} in the world" for mob, n in vanilla_mobs.items()])
|
|
if failures:
|
|
print("\nWORLD CHECK FAILED")
|
|
print("\n".join(f" - {f}" for f in failures))
|
|
sys.exit(1)
|
|
if report["missing_listed_biomes"] and not args.sample:
|
|
print(f"note: listed biomes not on this island: {', '.join(report['missing_listed_biomes'])}")
|
|
print(f"\nWORLD CHECK PASSED: {data['chunks']} chunks, no ore, no vanilla mobs or mob sources, only listed biomes"
|
|
+ ("" if args.sample else ", border fits the coast"))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|