Phase 3: strong flyers only spawn in high-level zones

- tools/zone_spawns.py: a species ridable through the air with top
  speed >= 60 or top stamina >= 85 never spawns in a zone whose band
  starts below Lv 30; lines that start as one are only placed in such
  zones, and the tool refuses to write pools that break the rule.
  Drampa moves to Sunscorch; Aerodactyl is fossil-only; Salamence never
  spawns wild (Bagon and Shelgon stay in the Dripstone caves).
- Ride stats stay Cobblemon's until play-tested (species_additions can
  override riding later).
- Phase 3 checklist: all steps in, approval checkpoint for Adrian.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-10-30 02:01:44 +01:00
1 parent 173203eeb5
commit 55c336f89e
12 files changed
+2115 -576

No files matched your search

+47 -13
View File
@@ -21,7 +21,8 @@ datapacks/core/data/cobblesurvival/spawn_pool_world/<zone>.json:
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
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, half again as big, five levels
above the band, with the zone's boss drops and world/zones.json → guardian_respawn_hours.
@@ -54,6 +55,13 @@ MIN_CELLS = 5 # a zone "has" a biome when at least this many grid cells of it l
# 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():
@@ -98,16 +106,23 @@ def zone_biomes(plan, layout, preview):
return shares
def base_stat_totals(jar):
"""{species id: base-stat total} from Cobblemon's species files."""
totals = {}
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 m:
stats = json.loads(z.read(name)).get("baseStats", {})
totals[f"cobblemon:{m[1]}"] = sum(stats.values())
return totals
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):
@@ -132,7 +147,7 @@ def main():
species = guide["species"]
jar = cobblemon_jar()
entries = load_entries(jar)
bst = base_stat_totals(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)
@@ -161,6 +176,10 @@ def main():
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()}
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()}
@@ -191,7 +210,7 @@ def main():
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]]
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
@@ -206,14 +225,21 @@ def main():
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
# 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)
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]]
high = zones[zid]["levels"][0] >= HIGH_ZONE_LEVEL
usable = [(e, b, m, lv) for e, b, m, lv in lines[root] if b & biomes_of[zid] and (high or m not in strong_flyers)]
if not usable:
continue
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.
@@ -238,6 +264,11 @@ def main():
names = sorted({species[m]["name"] for _, m, _, _ in fitting})
report[zid].append((species.get(root, {}).get("name", root), names))
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()
@@ -292,6 +323,9 @@ def main():
REPORT.write_text("\n".join(lines_out) + "\n")
unplaced = [r for r in lines if r not in home]
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)}")