Phase 4 play-test fixes: deep caves, fuller cave spawns, strays, shared loot

From Adrian's play-test (2026-10-30):
- The Deep Dark no longer fills the deepslate layer: world/biomes.json
  splits it by temperature (Deep Dark under cold and temperate land,
  basalt deltas under the hottest, ordinary caves between); at y -52 it
  drops from 30 % to 14 % of the land, basalt 7 %.
- Cave zones fill up to their targets with lines whose primary type fits
  the cave; top stages spawn up to the band's top (Deep Dark Lv 60, Basalt
  70); no time-of-day conditions underground.
- Wild Pokemon more than 10 levels above the band of the zone they
  wander into despawn (zone/Wanderers.kt).
- Loot is shared: all players in one scoreboard team, Lootr team_loot.
- Basalt ore nodes reach the deep basalt.
Self-tests strays_despawn and loot_is_shared; the catching rules from the
previous commit are now tested too. Template rebuilt (world check passed),
island quick check passed (36 self-tests and the restart test).
Shoulder Pokemon noted for Phase 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-10-31 02:01:41 +01:00
1 parent 6361636da7
commit d49684a2eb
40 files changed
+8212 -9412

No files matched your search

+44 -8
View File
@@ -13,14 +13,18 @@ datapacks/core/data/cobblesurvival/spawn_pool_world/<zone>.json:
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.
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.
doesn't spawn there. If no stage fits, the closest one spawns across the whole band. The
line's top stage spawns up to the top of the band (Cobblemon's own ranges stop earlier).
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.
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, half again as big, five levels
@@ -215,14 +219,28 @@ def main():
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.
# 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(all_rounders, key=lambda r: rank[r]):
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.
@@ -234,17 +252,24 @@ def main():
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
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)]
# 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
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]
reaching = [u for u in usable if u[3][0] <= hi and u[3][1] >= lo]
top = max(reaching, key=lambda u: u[3][1])[2] if reaching else None
# The top stage spawns up to the band's top; Cobblemon's own ranges often stop well below it.
fitting = [(e, m, max(lv[0], lo), hi if m == top else min(lv[1], hi)) for e, b, m, lv in reaching]
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)]
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])}"
@@ -260,6 +285,17 @@ def main():
# "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))