Files
cobblemon-survival-pack/tools/zone_spawns.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

264 lines
13 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: 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.
3. world/zones.json → "species" pins a line to a zone by hand: {"pikachu": "southwold"}.
4. In cave zones, sky-light minimums, "can see sky" and the "natural" ground preset are dropped.
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
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "datapacks/core/data/cobblesurvival/spawn_pool_world"
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
def cobblemon_jar():
jars = sorted((ROOT / "build/server/mods").glob("Cobblemon-*.jar"))
if not jars:
sys.exit("no Cobblemon jar in build/server/mods: run tools/build.py and tools/selftest.py (or the quick check) first")
return jars[-1]
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 base_stat_totals(jar):
"""{species id: base-stat total} from Cobblemon's species files."""
totals = {}
with zipfile.ZipFile(jar) as z:
for name in z.namelist():
m = re.fullmatch(r"data/cobblemon/species/[^/]+/([^/]+)\.json", name)
if m:
stats = json.loads(z.read(name)).get("baseStats", {})
totals[f"cobblemon:{m[1]}"] = sum(stats.values())
return totals
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 = base_stat_totals(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"]))
pinned = {f"cobblemon:{k}" if ":" not in k else k: v for k, v in plan.get("species", {}).items()}
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]]
if candidates:
home[root] = max(candidates, key=score)
load[home[root]] += 1
# 2. 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(all_rounders, key=lambda r: rank[r]):
while load[current] >= load_before(current, room, stretch, target) and current != by_tier[-1]:
current = next(zone_iter)
home[root] = current
load[current] += 1
pools = collections.defaultdict(list)
report = collections.defaultdict(list)
for root, zid in home.items():
lo, hi = zones[zid]["levels"]
usable = [(e, b, m, lv) for e, b, m, lv in lines[root] if b & biomes_of[zid]]
fitting = [(e, m, max(lv[0], lo), min(lv[1], 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)]
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"]
pools[zid].append(spawn)
names = sorted({species[m]["name"] for _, m, _, _ in fitting})
report[zid].append((species.get(root, {}).get("name", root), names))
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")
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; the stages listed are the ones "
"whose levels reach the zone's band. Pin a line to another zone in `world/zones.json` → "
"`species`.", ""]
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", ""]
REPORT.write_text("\n".join(lines_out))
unplaced = [r for r in lines if r not in home]
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()