Files
cobblemon-survival-pack/tools/world_report.py
T
claudeandClaude Opus 5.5 398301f0be Cobblemon Survival Pack: phases 0-2 and Phase 3 zones and hazards
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>
2026-10-29 19:01:21 +01:00

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()