Files
cobblemon-survival-pack/tools/zone_spawns.py
T
claudeandClaude Opus 5.5 4083791944 Phase 9: reproducible builds, balance from the server, spark check, play feedback
Build: every tool pinned (JDK, packwiz, Gradle plugins, Python packages), exports
normalised, tools/check_reproducible.py proves two clean builds are byte-identical.

Server-side balance: a server can load config/cobblesurvival/datapack.zip in place of
the jar's data, /reload applies it, tools/server_update.py packages it and refuses
when a client change is needed. JEI drop pages are sent by the server. Code-held
balance numbers moved to tuning/server.json.

Performance: spark in the pack (server only), tools/perf_test.py with fake players
whose chunks really are active, docs/performance.md.

Adrian's feedback: default keys R, U, J, §; drop pages link to Cobblemon Info and show
real chances; bosses only hurt in battles; battle XP halved; channels between the
islands low level; temperature ×3 with insulation doubled; hazards start at once; no
item dots on the map; a wild attack starts a battle; the starter only faints; quest
book on join with a basics page and an HMs page; regional forms obtainable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 16:26:30 +01:00

495 lines
28 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Give every zone its own Pokémon: write one spawn pool per zone from Cobblemon's own spawn data.
Cobblemon's spawn files are switched off for the island (datapacks/core/pack.mcmeta filters
data/cobblemon/spawn_pool_world/). In their place this tool writes
datapacks/core/data/cobblesurvival/spawn_pool_world/<zone>.json:
1. Every evolution line that Cobblemon lets spawn somewhere on the island gets one home
zone. Lines with the fewest possible biomes are placed first. A line's score for a zone
is how much of the zone is made of the line's biomes, times how much of the line's own
range the zone covers (square root), times how well the line's strength
(base-stat total of its strongest form, as a rank among all lines) matches the zone's
level band, divided by how full the zone already is against its "lines" target. So strong
lines (pseudo-legendaries) end up in high-level zones. Legendary, mythical, Ultra Beast
and Paradox Pokémon are left out. Lines that can spawn in nearly every biome (Cobblemon's
catch-all tags) are placed last: first into the cave zones, which take those whose first stage's
primary type fits the cave (world/zones.json → caves → "types") up to their target, the ones closest to the
cave's level first; the rest by strength into the land zones, weakest first, filling each zone
up to its target.
2. In its home zone a line keeps Cobblemon's spawn entries (bucket, weight, time, light,
presets) for the biomes the zone has, plus "zone": "<id>". Levels are clamped into the
zone's band, so the band picks the evolution stages: a stage whose levels miss the band
doesn't spawn there. If no stage fits, the closest one spawns across the whole band. Every
stage that reaches the band spawns up to its top (Adrian, 2026-10-31; Cobblemon's own ranges
stop where the next stage takes over, which left e.g. Dusclops at Lv 45–46 in the Deep Dark).
3. world/zones.json → "species" pins a line to a zone by hand: {"pikachu": "southwold"}. The long form
{"ditto": {"zone": "deep_dark", "all_entries": true}} also keeps all of the line's entries when no stage
fits the band (otherwise the closest stage brings one entry).
4. In cave zones every entry spawns in the cave's biome, and sky-light minimums, "can see sky", time
of day, excluded biomes and the "natural" ground preset are dropped (there's no sky or day underground).
5. Strong flyers (STRONG_FLYER_*) only spawn in zones whose band starts at HIGH_ZONE_LEVEL or more.
6. Each zone with a "guardian" gets datapacks/core/data/cobblesurvival/guardians/<zone>.json: the
strongest Pokémon spawning in the zone (or "species"), uncatchable, scaled to stand about GUARDIAN_HEIGHT blocks
tall (or "scale"; Adrian, 2026-11-02: way bigger), five levels above the band, with the zone's boss drops and world/zones.json → guardian_respawn_hours.
7. world/zones.json → "dimensions" are zones that are a whole other dimension (the End; Adrian, 2026-11-02: it was
barren). Every line Cobblemon spawns in their "biome_tags" spawns there too, on top of its island home, with
its stages fitted to the band as in 2. The entries go into "bucket" (Cobblemon rolls the bucket first, and its
End entries are all uncommon or rare), the heaviest of each line weighing "line_weight", Cobblemon's rare ones
"rare_factor" of that.
8. world/zones.json → "homeless" gives the lines that have no matching biome on the island a zone: rare spawns
anywhere in it, at its levels (Adrian, 2026-11-09: every implemented Pokémon must be obtainable).
Also writes docs/zone-species.md, the list per zone for review.
Needs: build/zone-layout.json (tools/zone_layout.py), build/field-guide/fieldguide.json
(tools/fieldguide/build.py; its "hidden_spawns" say which island biomes each of Cobblemon's own,
filtered-out entries can use) and the
Cobblemon jar in build/server/mods (any test-server run).
Usage: tools/zone_spawns.py
"""
import collections
import copy
import json
import re
import sys
import zipfile
from pathlib import Path
import lock
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "datapacks/core/data/cobblesurvival/spawn_pool_world"
GUARDIANS = ROOT / "datapacks/core/data/cobblesurvival/guardians"
# Adrian, 2026-11-02: guardians way bigger. Each is scaled to stand about this many blocks tall (its Cobblemon hitbox
# height times base scale), but at least GUARDIAN_SCALE_MIN and at most GUARDIAN_SCALE_MAX times its normal size.
GUARDIAN_HEIGHT = 8
GUARDIAN_SCALE_MIN = 1.5
GUARDIAN_SCALE_MAX = 12
GUARDIAN_LEVELS_ABOVE_BAND = 5
REPORT = ROOT / "docs/zone-species.md"
EXCLUDED_LABELS = {"legendary", "mythical", "ultra_beast", "paradox", "restricted"}
MIN_CELLS = 5 # a zone "has" a biome when at least this many grid cells of it lie in the zone
# A line whose entries cover this share of the island's biomes lives "anywhere" (Cobblemon's catch-all
# tags): its biomes say nothing about a home, so it only goes to a land zone, by strength. Its cave and
# water entries still spawn underground and in the rivers of that zone.
ALL_ROUNDER = 0.8
# Roadmap Phase 3: strong flyers only spawn in high-level zones. A species you can ride through the air with a top
# flying speed or stamina this high never spawns in a zone whose band starts below HIGH_ZONE_LEVEL (its weaker
# pre-evolutions still do), and a line that starts as one is placed in such a zone only; if none fits its habitat,
# it doesn't spawn in the wild.
STRONG_FLYER_SPEED = 60
STRONG_FLYER_STAMINA = 85
HIGH_ZONE_LEVEL = 30
def cobblemon_jar():
return lock.mod_jar("cobblemon")
def load_entries(jar):
"""{(pool, id): entry} for every spawn entry of Cobblemon's world spawn pools."""
entries = {}
with zipfile.ZipFile(jar) as z:
for name in z.namelist():
m = re.fullmatch(r"data/cobblemon/spawn_pool_world/(.+)\.json", name)
if not m:
continue
data = json.loads(z.read(name))
if not data.get("enabled", True) or data.get("neededInstalledMods"):
continue
pool = f"cobblemon:{m[1]}"
for entry in data.get("spawns", []):
# Same key as the field guide, which names an entry without an id after its file.
entries[(pool, entry.get("id") or pool)] = entry
return entries
def zone_biomes(plan, layout, preview):
"""{zone: {biome: share of the zone's cells}}, ignoring biomes with fewer than MIN_CELLS cells."""
names = preview["biomes"]
counts = collections.defaultdict(collections.Counter)
for zone_index, biome in zip(layout["grid"], preview["surface"]):
if zone_index >= 0:
counts[layout["zones"][zone_index]][names[biome]] += 1
shares = {}
for zid, c in counts.items():
kept = {b: n for b, n in c.items() if n >= MIN_CELLS}
total = sum(kept.values())
shares[zid] = {b: n / total for b, n in kept.items()}
for cave in plan["caves"]:
shares[cave["id"]] = {cave["biome"]: 1.0}
return shares
def natural_heights(jar):
"""{species id: how tall its entity stands at scale 1, in blocks} from Cobblemon's species files."""
heights = {}
with zipfile.ZipFile(jar) as z:
for name in z.namelist():
m = re.fullmatch(r"data/cobblemon/species/[^/]+/([^/]+)\.json", name)
if m:
data = json.loads(z.read(name))
heights[f"cobblemon:{m[1]}"] = (data.get("hitbox") or {}).get("height", 1) * data.get("baseScale", 1)
return heights
def guardian_scale(height):
return round(min(GUARDIAN_SCALE_MAX, max(GUARDIAN_SCALE_MIN, GUARDIAN_HEIGHT / height)), 2)
def species_data(jar):
"""({species id: base-stat total}, {species ids that are strong flyers}) from Cobblemon's species files."""
totals, flyers = {}, set()
with zipfile.ZipFile(jar) as z:
for name in z.namelist():
m = re.fullmatch(r"data/cobblemon/species/[^/]+/([^/]+)\.json", name)
if not m:
continue
data = json.loads(z.read(name))
totals[f"cobblemon:{m[1]}"] = sum(data.get("baseStats", {}).values())
air = ((data.get("riding") or {}).get("behaviours") or {}).get("AIR") or {}
stats = air.get("stats", {})
def top(stat):
return int(str(stats.get(stat, "0")).split("-")[-1])
if air and (top("SPEED") >= STRONG_FLYER_SPEED or top("STAMINA") >= STRONG_FLYER_STAMINA):
flyers.add(f"cobblemon:{m[1]}")
return totals, flyers
def load_before(zone, room, stretch, target):
"""How many lines a land zone may hold once all-rounders are added."""
return target[zone] - room[zone] + room[zone] * stretch
def line_root(species_id, species):
seen = set()
while species_id in species and species[species_id].get("pre") and species_id not in seen:
seen.add(species_id)
species_id = species[species_id]["pre"]
return species_id
def main():
plan = json.loads((ROOT / "world/zones.json").read_text())
layout = json.loads((ROOT / "build/zone-layout.json").read_text())
recipe = json.loads((ROOT / "world/world.json").read_text())
preview = json.loads((ROOT / f"build/server/seed-search/cobblesurvival/biome_preview/{recipe['seed']}.json").read_text())
guide = json.loads((ROOT / "build/field-guide/fieldguide.json").read_text())
species = guide["species"]
jar = cobblemon_jar()
entries = load_entries(jar)
bst, strong_flyers = species_data(jar)
zones = {z["id"]: z for z in plan["zones"]} | {c["id"]: c for c in plan["caves"]}
shares = zone_biomes(plan, layout, preview)
biomes_of = {zid: set(s) for zid, s in shares.items()}
island_biomes = set().union(*biomes_of.values())
land_zones = {z["id"] for z in plan["zones"] if "water" not in z["rule"]}
cave_zones = {c["id"] for c in plan["caves"]}
mids = {zid: sum(z["levels"]) / 2 for zid, z in zones.items()}
low, high = min(mids.values()), max(mids.values())
tier = {zid: (m - low) / (high - low) for zid, m in mids.items()}
# Island biomes of every Cobblemon entry, from the field guide (it resolves biome tags). The guide lists
# pools that datapacks/core's filter hides, Cobblemon's own, under "hidden_spawns"; older guides had
# them in "spawns".
cobblemon_spawns = guide.get("hidden_spawns") or guide["spawns"]
lines = collections.defaultdict(list) # line root -> [(entry, island biomes, species id)]
for s in cobblemon_spawns:
if s["type"] != "pokemon" or s["status"] != "available" or not s["biomes"]:
continue
entry = entries.get((s["pool"], s["id"]))
if entry is None:
continue
member = s["members"][0]["species"]
if EXCLUDED_LABELS & set(species.get(member, {}).get("labels", [])):
continue
lines[line_root(member, species)].append((entry, set(s["biomes"]), member, s["level"]))
pins = {f"cobblemon:{k}" if ":" not in k else k: v for k, v in plan.get("species", {}).items()}
pinned = {root: pin["zone"] if isinstance(pin, dict) else pin for root, pin in pins.items()}
all_entries = {root for root, pin in pins.items() if isinstance(pin, dict) and pin.get("all_entries")}
flying_lines = {root for root in lines if root in strong_flyers}
def allowed(root, zid):
return root not in flying_lines or zones[zid]["levels"][0] >= HIGH_ZONE_LEVEL
wanted = {zid: z.get("lines", 20) for zid, z in zones.items()}
scale = len(lines) / sum(wanted.values())
target = {zid: n * scale for zid, n in wanted.items()}
# Strength of a line: the base-stat total of its strongest member, as a rank from 0 (weakest) to 1.
strength = {root: max(bst.get(m, 0) for _, _, m, _ in members) for root, members in lines.items()}
ranked = sorted(strength, key=strength.get)
rank = {root: i / max(len(ranked) - 1, 1) for i, root in enumerate(ranked)}
load = collections.Counter()
home = {}
biomes_of_line = {root: set().union(*(b for _, b, _, _ in members)) for root, members in lines.items()}
all_rounders = {root for root, b in biomes_of_line.items()
if root not in pinned and len(b) >= ALL_ROUNDER * len(island_biomes)}
for root, zid in pinned.items():
if root in lines:
home[root] = zid
load[zid] += 1
# 1. Lines with a habitat, the most specific first.
for root in sorted(lines, key=lambda r: (len(biomes_of_line[r]), r)):
if root in home or root in all_rounders:
continue
line_biomes = biomes_of_line[root]
def score(zid):
habitat = sum(share for b, share in shares[zid].items() if b in line_biomes)
# How much of the line's own range this zone covers.
specific = len(line_biomes & biomes_of[zid]) / len(line_biomes)
fit = 1 - abs(rank[root] - tier[zid])
return habitat * specific ** 0.5 * fit ** 2 / (1 + load[zid] / target[zid])
candidates = [zid for zid in zones if line_biomes & biomes_of[zid] and allowed(root, zid)]
if candidates:
home[root] = max(candidates, key=score)
load[home[root]] += 1
# 2. Cave zones take the all-rounders of the types that fit them, up to their targets, the ones closest
# to the cave's level first (Adrian, 2026-10-30: the deep caves' tables were too small).
# A line's type here is its first stage's primary type (a line of many types, like Eevee's, shouldn't count as all of them).
primary_type = {root: (species.get(root, {}).get("types") or [None])[0] for root in lines}
for zid in sorted(cave_zones, key=lambda z: tier[z]):
fits = set(zones[zid].get("types", []))
while load[zid] < target[zid]:
pool = [r for r in all_rounders if r not in home and primary_type[r] in fits and allowed(r, zid)]
if not pool:
break
root = min(pool, key=lambda r: (abs(rank[r] - tier[zid]), r))
home[root] = zid
load[zid] += 1
# 3. The other all-rounders fill the land zones up to their targets, weakest lines into the lowest zones.
by_tier = sorted(land_zones, key=lambda z: tier[z])
room = {z: max(0.0, target[z] - load[z]) for z in by_tier}
total_room = sum(room.values()) or 1
stretch = max(1.0, len(all_rounders) / total_room)
zone_iter = iter(by_tier)
current = next(zone_iter)
for root in sorted((r for r in all_rounders if r not in home), key=lambda r: rank[r]):
while load[current] >= load_before(current, room, stretch, target) and current != by_tier[-1]:
current = next(zone_iter)
# A strong flyer skips ahead to the first high zone.
home[root] = current if allowed(root, current) else next((z for z in by_tier if allowed(root, z)), None)
if home[root] is None:
del home[root]
continue
load[home[root]] += 1
pools = collections.defaultdict(list)
report = collections.defaultdict(list)
cave_biome = {c["id"]: c["biome"] for c in plan["caves"]}
for root, zid in home.items():
lo, hi = zones[zid]["levels"]
high = zones[zid]["levels"][0] >= HIGH_ZONE_LEVEL
# Underground every entry gets the cave's biome below, so any of the line's entries will do.
usable = [(e, b, m, lv) for e, b, m, lv in lines[root]
if (zid in cave_zones or b & biomes_of[zid]) and (high or m not in strong_flyers)]
if not usable:
continue
# Every stage that reaches the band spawns up to its top; Cobblemon's own ranges stop where the next stage
# takes over. A caught one past its evolution level evolves on its next level-up.
fitting = [(e, m, max(lv[0], lo), hi) for e, b, m, lv in usable if lv[0] <= hi and lv[1] >= lo]
if not fitting:
# No stage reaches the band: the closest stage spawns across all of it.
e, b, m, lv = min(usable, key=lambda u: min(abs(u[3][0] - hi), abs(u[3][1] - lo)))
fitting = [(e, m, lo, hi)]
if root in all_entries:
fitting = [(ue, m, lo, hi) for ue, _, um, ulv in usable if um == m and ulv == lv]
seen = set()
for entry, member, a, b in fitting:
spawn = copy.deepcopy(entry)
spawn["id"] = f"{zid}-{entry.get('id') or member.split(':')[1]}-{len(pools[zid])}"
spawn["level"] = f"{a}-{b}"
condition = spawn.setdefault("condition", {})
condition["zone"] = zid
if zid in cave_zones:
# Many entries come from Cobblemon's surface spawns (e.g. "spooky" biomes include the deep
# dark); underground they could never see the sky.
condition.pop("minSkyLight", None)
if condition.get("canSeeSky") is True:
del condition["canSeeSky"]
# "natural" only allows natural ground; cave floors (basalt deltas: magma, smooth basalt) often aren't.
if "natural" in spawn.get("presets", []):
spawn["presets"] = [x for x in spawn["presets"] if x != "natural"]
# The zone condition places it; the cave's biome stands in for Cobblemon's surface biomes, and
# there's no day or night underground.
condition["biomes"] = [cave_biome[zid]]
condition.pop("timeRange", None)
spawn.get("anticondition", {}).pop("biomes", None)
if spawn.get("anticondition") == {}:
del spawn["anticondition"]
key = (spawn["pokemon"], spawn.get("spawnablePositionType"), spawn.get("bucket"), spawn["level"])
if key in seen:
continue
seen.add(key)
pools[zid].append(spawn)
names = sorted({species[m]["name"] for _, m, _, _ in fitting})
report[zid].append((species.get(root, {}).get("name", root), names))
# 4. Dimension zones (Adrian, 2026-11-02: the End was barren): every line Cobblemon spawns in the dimension's
# biome tags spawns there too, at the dimension's band, on top of its home on the island. Read straight from
# Cobblemon's spawn files, since the field guide only resolves the island's biomes. Cobblemon rolls the bucket
# before the Pokémon, and its End entries are all uncommon or rare: they move to the dimension's bucket, the
# heaviest entry of each line weighing line_weight (Cobblemon's rare ones rare_factor of that).
dimension_roots = set()
for dim in plan.get("dimensions", []):
did = dim["id"]
zones[did] = dim
lo, hi = dim["levels"]
tags = set(dim["biome_tags"])
found = collections.defaultdict(list)
for entry in entries.values():
if "pokemon" not in entry or not tags & set(entry.get("condition", {}).get("biomes", [])):
continue
member = f"cobblemon:{entry['pokemon'].split()[0].lower()}"
if EXCLUDED_LABELS & set(species.get(member, {}).get("labels", [])):
continue
a, _, b = str(entry["level"]).partition("-")
found[line_root(member, species)].append((entry, member, (int(a), int(b or a))))
for root, items in sorted(found.items()):
fitting = [(e, m, max(lv[0], lo), hi) for e, m, lv in items if lv[0] <= hi and lv[1] >= lo]
if not fitting:
_, m, lv = min(items, key=lambda u: min(abs(u[2][0] - hi), abs(u[2][1] - lo)))
fitting = [(e, um, lo, hi) for e, um, ulv in items if um == m and ulv == lv]
heaviest = max(e.get("weight", 1) for e, _, _, _ in fitting)
for entry, member, a, b in fitting:
spawn = copy.deepcopy(entry)
spawn["id"] = f"{did}-{entry.get('id') or member.split(':')[1]}-{len(pools[did])}"
spawn["level"] = f"{a}-{b}"
rare = entry.get("bucket") in ("rare", "ultra-rare")
spawn["weight"] = round(dim["line_weight"] * entry.get("weight", 1) / heaviest * (dim["rare_factor"] if rare else 1), 3)
spawn["bucket"] = dim["bucket"]
spawn.setdefault("condition", {})["zone"] = did
pools[did].append(spawn)
names = sorted({species[m]["name"] for _, m, _, _ in fitting if m in species})
report[did].append((species.get(root, {}).get("name", root), names))
dimension_roots.add(root)
# 5. Lines with no home on the island (Adrian, 2026-11-09: every implemented Pokémon must be obtainable). They only
# spawn in biomes the island lacks, or have no spawn data at all (Rotom), so the field guide can't place them:
# world/zones.json → "homeless" names a zone for each, and they spawn there, anywhere in the zone, in the
# "bucket" at the zone's levels. Everything tied to the missing biomes is left out of the condition.
homeless = plan.get("homeless", {})
raw_lines = collections.defaultdict(list)
for entry in entries.values():
if "pokemon" in entry:
member = f"cobblemon:{entry['pokemon'].split()[0].lower()}"
a, _, b = str(entry["level"]).partition("-")
raw_lines[line_root(member, species)].append((entry, member, (int(a), int(b or a))))
homeless_roots = set()
for zid, roots in homeless.get("zones", {}).items():
lo, hi = zones[zid]["levels"]
kinds = {"submerged", "surface"} if "water" in zones[zid].get("rule", {}) else {"grounded"}
for name in roots:
base, _, form = name.partition(" ")
root = f"cobblemon:{base}"
items = [i for i in raw_lines[root] if i[0].get("spawnablePositionType") in kinds and (not form or i[0]["pokemon"].lower() == name)]
if not items:
# No usable spawn data at all: one plain entry across the band.
items = [({"type": "pokemon", "pokemon": name, "presets": ["natural"], "spawnablePositionType": "grounded"}, root, (lo, hi))]
fitting = [(e, m, max(lv[0], lo), hi) for e, m, lv in items if lv[0] <= hi and lv[1] >= lo]
if not fitting:
_, m, lv = min(items, key=lambda u: min(abs(u[2][0] - hi), abs(u[2][1] - lo)))
fitting = [(e, um, lo, hi) for e, um, ulv in items if um == m and ulv == lv]
seen = set()
for entry, member, a, b in fitting:
key = (member, entry.get("spawnablePositionType"))
if key in seen:
continue
seen.add(key)
condition = {"zone": zid}
if "fluid" in entry.get("condition", {}):
condition["fluid"] = entry["condition"]["fluid"]
presets = entry.get("presets", [])
if zid in cave_zones:
condition["biomes"] = [cave_biome[zid]]
presets = [x for x in presets if x != "natural"]
pools[zid].append({
"id": f"{zid}-{member.split(':')[1]}-{len(pools[zid])}", "type": "pokemon", "pokemon": entry["pokemon"],
"presets": presets, "spawnablePositionType": entry["spawnablePositionType"], "bucket": homeless["bucket"],
"level": f"{a}-{b}", "weight": homeless["weight"], "condition": condition,
})
names = sorted({species[m]["name"] for _, m, _, _ in fitting if m in species})
label = species.get(root, {}).get("name", root)
report[zid].append((f"{form.split()[0].title()} {label}" if form else label, names))
if not form: # a regional form comes on top of its line, which keeps its own home
homeless_roots.add(root)
low_flyers = [(zid, s["pokemon"]) for zid, spawns in pools.items() for s in spawns
if zones[zid]["levels"][0] < HIGH_ZONE_LEVEL and f"cobblemon:{s['pokemon'].split()[0].lower()}" in strong_flyers]
if low_flyers:
sys.exit(f"strong flyers would spawn below Lv {HIGH_ZONE_LEVEL}: {low_flyers[:5]}")
OUT.mkdir(parents=True, exist_ok=True)
for old in OUT.glob("*.json"):
old.unlink()
for zid, spawns in pools.items():
(OUT / f"{zid}.json").write_text(json.dumps({
"_comment": "Generated by tools/zone_spawns.py from Cobblemon's spawn data; do not edit by hand.",
"enabled": True, "neededInstalledMods": [], "neededUninstalledMods": [],
"spawns": sorted(spawns, key=lambda s: s["id"]),
}, indent=1) + "\n")
GUARDIANS.mkdir(parents=True, exist_ok=True)
for old in GUARDIANS.glob("*.json"):
old.unlink()
guardians = {}
heights = natural_heights(jar)
for zid, zone in zones.items():
config = zone.get("guardian")
if not config:
continue
# A guardian stands its ground: on land a walking spawn, at sea a swimming one, never a fishing catch.
kinds = {"submerged", "surface"} if "water" in zone.get("rule", {}) else {"grounded"}
spawning = {s["pokemon"].split()[0].lower() for s in pools.get(zid, []) if s.get("spawnablePositionType") in kinds}
species_name = config.get("species") or max(spawning, key=lambda n: bst.get(f"cobblemon:{n}", 0))
level = zone["levels"][1] + GUARDIAN_LEVELS_ABOVE_BAND
guardians[zid] = species_name
(GUARDIANS / f"{zid}.json").write_text(json.dumps({
"_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.",
"zone": zid,
"pokemon": f"{species_name} level={level} uncatchable=yes",
"scale": config.get("scale") or guardian_scale(heights.get(f"cobblemon:{species_name}", 1)),
"drops": [{"item": item, "count": count} for item, count in config["drops"]],
"respawn_hours": plan["guardian_respawn_hours"],
}, indent=1) + "\n")
lines_out = ["# Pokémon per zone", "",
"Generated by `tools/zone_spawns.py` from Cobblemon's spawn data and `world/zones.json`; "
"do not edit by hand. Each evolution line spawns in one zone of the island; the stages listed are "
"the ones whose levels reach the zone's band. Pin a line to another zone in `world/zones.json` → "
"`species`. The End's lines spawn there as well as in their island zone.", ""]
for zid, zone in zones.items():
rows = sorted(report.get(zid, []))
lines_out += [f"## {zone['name']} (Lv {zone['levels'][0]}–{zone['levels'][1]}, {len(rows)} lines)", ""]
lines_out += [", ".join(f"{root}" + (f" ({', '.join(n)})" if n != [root] else "") for root, n in rows) or "none", ""]
lines_out += ["## Alpha guardians", "",
f"One per zone at its sample point, uncatchable, about {GUARDIAN_HEIGHT} blocks tall, five levels above the "
f"band, with full HP, Defense and Sp. Def training and the Guardian's Ward in battle; it returns "
f"{plan['guardian_respawn_hours']} hours after it is beaten. Its drops help with the next zone.", "",
"| Zone | Guardian | Level | Drops |", "| --- | --- | --- | --- |"]
for zid, species_name in guardians.items():
zone = zones[zid]
drop_text = ", ".join(f"{count}× {item.split(':')[1]}" for item, count in zone["guardian"]["drops"])
lines_out.append(f"| {zone['name']} | {species[f'cobblemon:{species_name}']['name'] if f'cobblemon:{species_name}' in species else species_name} "
f"| {zone['levels'][1] + GUARDIAN_LEVELS_ABOVE_BAND} | {drop_text} |")
REPORT.write_text("\n".join(lines_out) + "\n")
unplaced = [r for r in lines if r not in home and r not in dimension_roots and r not in homeless_roots]
grounded = sorted(species.get(r, {}).get("name", r) for r in unplaced if r in flying_lines)
if grounded:
print(f"strong flyers kept out of the wild (no high zone fits their habitat): {', '.join(grounded)}")
for zid, zone in zones.items():
print(f" {zone['name']:18s} {len(report.get(zid, [])):3d} lines {len(pools.get(zid, [])):4d} spawn entries")
print(f"{len(home)} lines placed, {len(unplaced)} with no matching zone; wrote {OUT.relative_to(ROOT)} and {REPORT.relative_to(ROOT)}")
if __name__ == "__main__":
main()