Files
cobblemon-survival-pack/tools/zone_spawns.py
T
claudeandClaude Opus 5.5 73fe71918f
CI / build-and-smoke-test (push) Failing after 7m6s
Phase 3: Alpha guardians, one per zone, back 5 hours after a defeat
- Companion mod (zone/Guardians.kt): each zone's guardian stands at the
  zone's sample point, appears when a player is within 96 blocks, stays
  within 24 blocks, is uncatchable and half again as big. Beating it in a
  battle gives every player in the battle its drops; it returns after 5
  hours of game time (Adrian: fixed spots, 5 hours). State lives in the
  world save; /cobblesurvival guardians lists it.
- tools/zone_spawns.py writes data/cobblesurvival/guardians/<zone>.json:
  the strongest walking (or swimming) Pokemon of the zone at band top + 5,
  with the drops from world/zones.json. Boss drops follow Adrian's rule:
  Icy Rocks before Sunscorch, Heat Rocks before Frostpeak.
- Goat Fur and Hoglin Hide recipes make 8 per rock, so one guardian's 3
  rocks pay for one full insulating set.
- Self-test guardian_appears_and_returns; hazard tests cleaned up; the
  zone generator reads Cobblemon's own entries from the field guide's
  hidden_spawns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 19:01:21 +01:00

302 lines
15 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.
5. Each zone with a "guardian" gets datapacks/core/data/cobblesurvival/guardians/<zone>.json: the
strongest Pokémon spawning in the zone (or "species"), uncatchable, half again as big, five levels
above the band, with the zone's boss drops and world/zones.json → guardian_respawn_hours.
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"
GUARDIANS = ROOT / "datapacks/core/data/cobblesurvival/guardians"
GUARDIAN_SCALE = 1.5 # "Alpha": half again as big as a normal one
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
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")
GUARDIANS.mkdir(parents=True, exist_ok=True)
for old in GUARDIANS.glob("*.json"):
old.unlink()
guardians = {}
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": GUARDIAN_SCALE,
"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; 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", ""]
lines_out += ["## Alpha guardians", "",
f"One per zone at its sample point, uncatchable, five levels above the band; 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]
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()