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>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-10-29 19:01:21 +01:00
commit 398301f0be
232 files changed
+72665

No files matched your search

+387
View File
@@ -0,0 +1,387 @@
"""FTB Quests "Field Guide" chapters from fieldguide.json (reference only, nothing locks).
Format of FTB Quests 2101.1.37 (read from its source, BaseQuestFile/Chapter/
Quest/TranslationManager): quests live in config/ftbquests/quests/; each
chapter is chapters/<filename>.snbt with 16-hex-digit string ids; titles,
subtitles and descriptions are not in the chapter file but in
lang/<locale>.snbt under keys "<type>.<ID>.title", "quest.<ID>.quest_subtitle",
"quest.<ID>.quest_desc" (a list of lines) and "chapter.<ID>.chapter_subtitle".
Icons are single-item stacks {id, components}; a Pokémon icon is
cobblemon:pokemon_model with the cobblemon:pokemon_item component
{species, aspects} (Cobblemon 1.8.4 PokemonItemComponent).
Ids are derived from names (sha256), so they stay the same between runs and
Phase 8's generator can place its own chapters next to these.
"""
import hashlib
from .world import RANK
CHAPTERS = ("field_guide_wild", "field_guide_other", "field_guide_items")
BIOME_ICONS = {
"minecraft:plains": "minecraft:short_grass", "minecraft:forest": "minecraft:oak_sapling",
"minecraft:cherry_grove": "minecraft:cherry_sapling", "minecraft:mangrove_swamp": "minecraft:mangrove_propagule",
"minecraft:jungle": "minecraft:jungle_sapling", "minecraft:desert": "minecraft:cactus",
"minecraft:badlands": "minecraft:red_sand", "minecraft:windswept_hills": "minecraft:gravel",
"minecraft:jagged_peaks": "minecraft:snow_block", "minecraft:lush_caves": "minecraft:moss_block",
"minecraft:dripstone_caves": "minecraft:pointed_dripstone", "minecraft:deep_dark": "minecraft:sculk",
"minecraft:basalt_deltas": "minecraft:basalt", "minecraft:ocean": "minecraft:kelp",
"minecraft:deep_ocean": "minecraft:prismarine", "minecraft:lukewarm_ocean": "minecraft:seagrass",
"minecraft:deep_lukewarm_ocean": "minecraft:sea_pickle", "minecraft:warm_ocean": "minecraft:brain_coral",
"minecraft:river": "minecraft:water_bucket", "minecraft:beach": "minecraft:sand",
}
COLUMNS = 14
def stable_id(name):
"""64-bit id from a name. FTB parses ids with Long.parseLong(hex, 16), so the top bit must be clear."""
v = int(hashlib.sha256(("cobblesurvival-fieldguide/" + name).encode()).hexdigest()[:16], 16) & 0x7FFF_FFFF_FFFF_FFFF
return f"{max(v, 2):016X}"
# --- SNBT in FTB Library's style (tabs, newline-separated, sorted keys) -------------------------
def _key(k):
return k if all(c.isalnum() or c in "._-+" for c in k) else _str(k)
def _str(s):
out = s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\t", "\\t").replace("\r", "")
return f'"{out}"'
def snbt(value, indent=0):
tab = "\t" * indent
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, int):
return str(value)
if isinstance(value, float):
return f"{value:g}d" if "e" not in f"{value:g}" else f"{value:.6f}d"
if isinstance(value, str):
return _str(value)
if isinstance(value, list):
if not value:
return "[ ]"
return "[\n" + "\n".join(tab + "\t" + snbt(v, indent + 1) for v in value) + "\n" + tab + "]"
if isinstance(value, dict):
if not value:
return "{ }"
return "{\n" + "\n".join(f"{tab}\t{_key(k)}: {snbt(v, indent + 1)}" for k, v in sorted(value.items())) + "\n" + tab + "}"
raise TypeError(type(value))
def parse_snbt(text):
"""Read SNBT the way FTB Library 2101's SNBTParser does (used to check our own output).
Lines whose trimmed text starts with // or # are comments; entries are separated by commas
or newlines; numbers carry b/s/l/f/d suffixes; true/false are bytes.
"""
lines = [ln for ln in text.split("\n") if not ln.strip().startswith(("//", "#"))]
src, pos = "\n".join(lines), 0
simple = lambda c: c.isalnum() or c in "._-+\u221e" # noqa: E731
def ws():
nonlocal pos
while pos < len(src) and src[pos] in " \t\r\n,":
pos += 1
def quoted():
nonlocal pos
pos += 1
out = []
esc = {"n": "\n", "t": "\t", "b": "\b", "r": "\r", "f": "\f", '"': '"', "\\": "\\"}
while True:
c = src[pos]
pos += 1
if c == "\n":
raise ValueError(f"newline inside a string at {pos}")
if c == "\\":
out.append(esc.get(src[pos], ""))
pos += 1
elif c == '"':
return "".join(out)
else:
out.append(c)
def word():
nonlocal pos
start = pos
while pos < len(src) and simple(src[pos]):
pos += 1
return src[start:pos]
def value():
nonlocal pos
ws()
c = src[pos]
if c == "{":
pos += 1
out = {}
while True:
ws()
if src[pos] == "}":
pos += 1
return out
key = quoted() if src[pos] == '"' else word()
ws()
if src[pos] != ":":
raise ValueError(f"expected ':' after {key!r} at {pos}")
pos += 1
out[key] = value()
if c == "[":
pos += 1
out = []
while True:
ws()
if src[pos] == "]":
pos += 1
if len({type(v) for v in out}) > 1:
raise ValueError("mixed list")
return out
out.append(value())
if c == '"':
return quoted()
w = word()
if w in ("true", "false"):
return w == "true"
if w[-1:] in "dD" and _num(w[:-1]):
return float(w[:-1])
if _num(w):
return int(w) if w.lstrip("-").isdigit() else float(w)
return w
result = value()
ws()
if pos != len(src):
raise ValueError(f"trailing data at {pos}")
return result
def _num(s):
try:
float(s)
return True
except ValueError:
return False
# --- chapter building --------------------------------------------------------------------------------
class Builder:
def __init__(self, data):
self.data = data
self.lang = {}
self.chapters = []
def chapter(self, filename, title, subtitle, icon, order):
cid = stable_id("chapter/" + filename)
self.lang[f"chapter.{cid}.title"] = title
self.lang[f"chapter.{cid}.chapter_subtitle"] = subtitle
ch = {"id": cid, "filename": filename, "group": "", "order_index": order, "icon": icon,
"default_quest_shape": "", "default_hide_dependency_lines": False,
"quests": [], "quest_links": [], "images": []}
self.chapters.append(ch)
return ch
def quest(self, ch, name, x, y, title, icon, subtitle=None, desc=(), shape=None, size=None):
qid = stable_id(f"{ch['filename']}/{name}")
q = {"id": qid, "x": float(x), "y": float(y), "icon": icon,
"tasks": [{"id": stable_id(f"{ch['filename']}/{name}/task"), "type": "checkmark"}]}
if shape:
q["shape"] = shape
if size:
q["size"] = float(size)
self.lang[f"quest.{qid}.title"] = title
if subtitle:
self.lang[f"quest.{qid}.quest_subtitle"] = subtitle
if desc:
self.lang[f"quest.{qid}.quest_desc"] = [line for line in desc if not line.startswith("{")]
ch["quests"].append(q)
return q
def item_icon(iid):
return {"id": iid}
def pokemon_icon(sid):
return {"id": "cobblemon:pokemon_model", "components": {"cobblemon:pokemon_item": {"species": sid, "aspects": []}}}
def species_lines(data, sid, limit=10):
sp = data["species"][sid]
lines = [f"#{sp['dex']} · " + " / ".join(t.capitalize() for t in sp["types"])]
if sp["locations"]:
lines.append("Found in:")
for loc in sp["locations"][:limit]:
if loc.get("special"):
lines.append(f"- {loc['name']}: only near particular blocks (e.g. buildings)")
continue
zones = f" ({', '.join(loc['zones'])})" if loc.get("zones") else ""
lines.append(f"- {loc['name']}{zones}: Lv {loc['level'][0]}-{loc['level'][1]}, "
+ ", ".join("alpha" if b == "boss" else b for b in loc["buckets"]))
if len(sp["locations"]) > limit:
lines.append(f"- and {len(sp['locations']) - limit} more places")
best = sorted((data["spawns"][i] for i in sp["spawns"] if data["spawns"][i]["status"] != "unavailable"),
key=lambda s: -(s["weight"] or 0))[:2]
conds = {" · ".join(c["text"]) for s in best for c in s["conditions"] if c["text"] and c["biomes"]}
for c in sorted(conds)[:2]:
lines.append(f"Usually: {c}")
other = [s for s in sp["sources"] if s["status"] != "unavailable"]
if other:
lines.append("Also:" if sp["locations"] else "Not found in the wild here. Get it by:")
lines += [f"- {s['text']}" for s in other[:4]]
for ev in sp["evolutions"][:4]:
lines.append(f"Evolves into {ev['name']}" + (f" ({', '.join(ev['how'])})" if ev["how"] else ""))
if sp["drops"]:
names = [f"{_item_name(data, d['item'])} {d['chance']}" for d in sp["drops"][:5]]
lines.append("Drops: " + ", ".join(names))
return [ln for ln in lines if ln is not None]
def _item_name(data, iid):
return (data["items"].get(iid) or data["vanilla"].get(iid) or {}).get("name") or iid.split(":")[1].replace("_", " ")
def primary_location(data, sid):
"""Location where the species is easiest to meet: most common bucket, then highest weight."""
best, key = None, None
order = {"common": 0, "uncommon": 1, "rare": 2, "ultra-rare": 3, "boss": 4}
for i in data["species"][sid]["spawns"]:
s = data["spawns"][i]
if s["status"] != "available":
continue
for b in s["biomes"]:
k = (s.get("needs_blocks", False), order.get(s["bucket"], 9), -(s["weight"] or 0))
if key is None or k < key:
best, key = b, k
return best
def build(data):
b = Builder(data)
species = data["species"]
wild = b.chapter("field_guide_wild", "Field Guide: Wild Pokémon",
["Where each Pokémon lives on this island. Reference only: nothing here locks anything.",
"Generated from the pack's spawn data by tools/fieldguide."], item_icon("cobblemon:poke_ball"), 900)
by_loc = {}
for sid, sp in species.items():
if sp["wild"] == "available":
loc = primary_location(data, sid)
if loc:
by_loc.setdefault(loc, []).append(sid)
y = 0.0
for loc in data["locations"]:
if loc["kind"] == "end":
continue
mons = sorted(by_loc.get(loc["id"], []), key=lambda s: species[s]["dex"])
everyone = sorted({m["species"] for i in loc["spawns"] for m in data["spawns"][i]["members"]},
key=lambda s: species.get(s, {}).get("dex", 0))
desc = [f"{len(everyone)} Pokémon can appear here. Each one's own entry lists all its places.", ""]
desc += [", ".join(species[s]["name"] for s in everyone if s in species)]
b.quest(wild, "loc/" + loc["id"], 0, y, loc["name"], item_icon(BIOME_ICONS.get(loc["id"], "minecraft:grass_block")),
subtitle=f"{len(everyone)} Pokémon", desc=desc, shape="square", size=1.5)
for n, sid in enumerate(mons):
b.quest(wild, "mon/" + sid, 2 + (n % COLUMNS), y + (n // COLUMNS), species[sid]["name"], pokemon_icon(sid),
desc=species_lines(data, sid), shape="circle")
y += max(1, (len(mons) + COLUMNS - 1) // COLUMNS) + 1.5
other = b.chapter("field_guide_other", "Field Guide: Other Pokémon",
["Pokémon that do not spawn in the wild on this island, and how to get them anyway."],
item_icon("cobblemon:old_amber_fossil"), 901)
obtainable = sorted((s for s, sp in species.items() if sp["wild"] != "available" and sp["status"] != "unavailable"),
key=lambda s: species[s]["dex"])
for n, sid in enumerate(obtainable):
b.quest(other, "mon/" + sid, n % COLUMNS, n // COLUMNS, species[sid]["name"], pokemon_icon(sid),
desc=species_lines(data, sid), shape="circle")
none = sorted((s for s, sp in species.items() if sp["status"] == "unavailable" and sp["implemented"]),
key=lambda s: species[s]["dex"])
rows = (len(obtainable) + COLUMNS - 1) // COLUMNS + 1
chunks = [none[i:i + 40] for i in range(0, len(none), 40)]
for n, chunk in enumerate(chunks):
b.quest(other, f"none/{n}", n * 2, rows + 0.5, "Not obtainable here" + (f" ({n + 1})" if len(chunks) > 1 else ""),
item_icon("minecraft:barrier"), subtitle=f"{len(chunk)} species",
desc=["These have no wild spawn, evolution, fossil or starter route on this island:", ""]
+ [", ".join(species[s]["name"] for s in chunk)], shape="square")
items = b.chapter("field_guide_items", "Field Guide: Items",
["How to get each Cobblemon item on this island and what it is for.",
"Red text: no source on the island."], item_icon("cobblemon:water_stone"), 902)
cats = {}
for iid, it in data["items"].items():
if not it["technical"] and (it["sources"] or it["uses"]):
cats.setdefault(it["category"], []).append(iid)
y = 0.0
for cat in sorted(cats):
ids = sorted(cats[cat], key=lambda i: data["items"][i]["name"])
for n, iid in enumerate(ids):
it = data["items"][iid]
lines = list(it["tooltip"][:2])
if it["status"] == "unavailable":
lines.append("&cNo source on this island.")
elif it["status"] == "conditional":
lines.append("&6Only under a condition (e.g. the End).")
srcs = [s for s in it["sources"] if RANK[s["status"]]] or it["sources"]
if srcs:
lines += ["", "How to get it:"] + [f"- {s['text']}" + (f" ({s['chance']})" if s.get("chance") else "")
for s in srcs[:6]]
if len(srcs) > 6:
lines.append(f"- and {len(srcs) - 6} more")
if it["uses"]:
lines += ["", "Used for:"] + [f"- {u['text']}" for u in it["uses"][:6]]
if len(it["uses"]) > 6:
lines.append(f"- and {len(it['uses']) - 6} more")
b.quest(items, "item/" + iid, n % COLUMNS, y + n // COLUMNS, it["name"], item_icon(iid),
subtitle=cat, desc=lines, shape="hexagon" if it["status"] == "available" else "octagon")
y += (len(ids) + COLUMNS - 1) // COLUMNS + 1
return b
README = """# Field Guide chapters for FTB Quests (generated, not installed)
Made by `python3 tools/fieldguide/build.py` from `build/field-guide/fieldguide.json`.
Format: FTB Quests {version} for NeoForge 1.21.1, read from its source.
Contents (paths relative to the game or server folder):
config/ftbquests/quests/chapters/field_guide_wild.snbt wild Pokémon by location
config/ftbquests/quests/chapters/field_guide_other.snbt Pokémon you get another way
config/ftbquests/quests/chapters/field_guide_items.snbt Cobblemon items: how to get, what for
config/ftbquests/quests/lang/en_us.snbt titles and descriptions (only these chapters' keys)
All entries are reference quests with one checkmark task, no dependencies and no rewards, so nothing locks.
Ids are hashes of names and stay the same between runs.
## Installing (your decision)
1. Stop the game or server (FTB Quests rewrites these files when it saves).
2. Copy the three chapter files into `config/ftbquests/quests/chapters/`.
3. Titles and descriptions: if `config/ftbquests/quests/lang/en_us.snbt` does not exist yet, copy ours.
If it exists (for example after Phase 8), merge: put every line of ours inside the existing top-level
`{{ }}`. Keys never collide with other chapters' keys because they use these chapters' ids.
4. For the pack: the same files go under `pack/config/ftbquests/quests/` and are refreshed with packwiz.
5. Start the game and open the quest book. `/ftbquests reload` re-reads the files on a running server.
When regenerating, replace the three chapter files and our keys in the lang file; FTB Quests keeps
manual edits (positions, extra quests) only in its own saved copy, so regenerate before editing by hand.
"""
def write(data, out, ftb_version="2101.1.37"):
b = build(data)
base = out / "ftbquests"
qdir = base / "config/ftbquests/quests"
(qdir / "chapters").mkdir(parents=True, exist_ok=True)
(qdir / "lang").mkdir(parents=True, exist_ok=True)
for ch in b.chapters:
text = snbt(ch) + "\n"
if parse_snbt(text) != ch: # round trip through an FTB-style reader
raise SystemExit(f"SNBT round trip failed for {ch['filename']}")
(qdir / "chapters" / f"{ch['filename']}.snbt").write_text(text)
text = snbt(b.lang) + "\n"
if parse_snbt(text) != b.lang:
raise SystemExit("SNBT round trip failed for lang/en_us.snbt")
(qdir / "lang/en_us.snbt").write_text(text)
(base / "README.md").write_text(README.format(version=ftb_version))
return {ch["filename"]: len(ch["quests"]) for ch in b.chapters}