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>
264 lines
13 KiB
Python
Executable File
264 lines
13 KiB
Python
Executable File
#!/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()
|