Files
claudeandClaude Opus 5.5 d29e177f9d Phase 9: safe start island, better quest book, every useful item obtainable
- Button Isle is peaceful: wild Pokémon only fight a player who hit them first
  (tuning/server.json peaceful_zones); its apricorns and coal make Poké Balls
  (4 apricorns + coal or charcoal), and the world report checks it has enough.
- Quest book: no boilerplate; each zone page says what is particular to it, and
  every zone that needs gear or a field move to be safe has its own quest. New
  page on the bigger mods (Cold Sweat, Fight or Flight, Cobblebase, breeding,
  veins, shared loot, maps and info).
- The Mire's poison stops 10 blocks below the surface.
- Player battles never cost a Pokémon (CobbleHardcoreMon config, mixins, and the
  companion mod's own permadeath).
- tools/item_audit.py: every item with a use has a source on the island; new
  sources for the gaps it found (species additions, three recipes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 15:04:09 +01:00

450 lines
20 KiB
Python

#!/usr/bin/env python3
"""Generate the quest book (roadmap Phase 8) from docs/quests.yaml.
Writes pack/config/ftbquests/quests/ in FTB Quests' own format (data.snbt, chapter_groups.snbt, chapters/*.snbt and
every title and text in lang/en_us.snbt), and the hidden advancements the companion mod grants for the pack's own
events (datapacks/core/data/cobblesurvival/advancement/events/: zone reached, guardian beaten, machine built, End boss
beaten), which quests check with FTB Quests' "advancement" tasks.
Adrian's choices (2026-11-03): quests give no rewards; Cobblemon tasks come from Cobblemon Quests Reloaded; quests are
written in docs/quests.yaml and node positions he moves in the in-game editor are read back into its `layout` block
(--pull). Titles, texts, tasks and dependencies live only in docs/quests.yaml: edits to those in game are overwritten.
Every chapter, quest and task gets an id derived from its name in docs/quests.yaml, so the ids stay the same when the
book is regenerated and players keep their progress. Zone chapters (`zone: <id>`) start with the quests of
`zone_chapter`, filled in from world/zones.json, the zone's spawn pool and its guardian.
Usage:
tools/quests.py generate
tools/quests.py --pull DIR first copy node positions from an FTB Quests folder (e.g. a Prism instance's
minecraft/config/ftbquests/quests) into docs/quests.yaml, then generate
tools/quests.py --check fail if the generated files are out of date
"""
import argparse
import copy
import hashlib
import json
import re
import shutil
import sys
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parent.parent
SOURCE = ROOT / "docs/quests.yaml"
OUT = ROOT / "pack/config/ftbquests/quests"
DATA = ROOT / "datapacks/core/data/cobblesurvival"
EVENTS = DATA / "advancement/events"
COBBLEMON_TASK = "cobblemon_tasks:cobblemon_task"
LAYOUT_START = "layout:"
# --- SNBT, the way FTB Quests writes it: one entry per line, no commas -------------------------------------------
class Long(int):
pass
def snbt(value, indent=0):
pad = "\t" * indent
if isinstance(value, dict):
if not value:
return "{ }"
body = "\n".join(f"{pad}\t{key(k)}: {snbt(v, indent + 1)}" for k, v in sorted(value.items()))
return "{\n" + body + "\n" + pad + "}"
if isinstance(value, list):
if not value:
return "[ ]"
if all(isinstance(v, str) for v in value) and len(value) <= 4 and sum(map(len, value)) < 80:
return "[" + ", ".join(snbt(v) for v in value) + "]"
return "[\n" + "\n".join(f"{pad}\t{snbt(v, indent + 1)}" for v in value) + "\n" + pad + "]"
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, Long):
return f"{int(value)}L"
if isinstance(value, float):
return f"{value}d"
if isinstance(value, int):
return str(value)
return '"' + str(value).replace("\\", "\\\\").replace('"', '\\"') + '"'
def key(k):
return k if re.fullmatch(r"[a-z0-9_]+", k) else snbt(k)
def parse_snbt(text):
"""Reads FTB Quests' SNBT (and vanilla's, with commas) into dicts, lists, strings and numbers."""
pos = 0
def skip():
nonlocal pos
while pos < len(text) and (text[pos].isspace() or text[pos] == ","):
pos += 1
def token():
nonlocal pos
start = pos
while pos < len(text) and not text[pos].isspace() and text[pos] not in ",:{}[]":
pos += 1
return text[start:pos]
def string():
nonlocal pos
quote, pos, out = text[pos], pos + 1, []
while text[pos] != quote:
if text[pos] == "\\":
pos += 1
out.append(text[pos])
pos += 1
pos += 1
return "".join(out)
def value():
nonlocal pos
skip()
c = text[pos]
if c == "{":
pos += 1
out = {}
while True:
skip()
if text[pos] == "}":
pos += 1
return out
k = string() if text[pos] in "\"'" else token()
skip()
assert text[pos] == ":", f"expected ':' at {pos}"
pos += 1
out[k] = value()
if c == "[":
pos += 1
m = re.match(r"\s*[BIL];", text[pos:])
if m:
pos += m.end()
out = []
while True:
skip()
if text[pos] == "]":
pos += 1
return out
out.append(value())
if c in "\"'":
return string()
t = token()
if t in ("true", "false"):
return t == "true"
m = re.fullmatch(r"(-?[0-9.]+(?:[eE]-?[0-9]+)?)([bBsSlLfFdD]?)", t)
if m:
return float(m[1]) if "." in m[1] or m[2] in "fFdD" and m[2] else int(m[1])
return t
return value()
# --- Ids ----------------------------------------------------------------------------------------------------------
def object_id(name):
"""A stable FTB Quests id (16 hex digits) for a chapter, quest or task name."""
n = int(hashlib.sha1(f"cobblesurvival:{name}".encode()).hexdigest()[:16], 16) & 0x7FFF_FFFF_FFFF_FFFF
return f"{max(n, 2):016X}"
# --- The pack's data, for zone chapters and event advancements ---------------------------------------------------
def zone_data():
plan = json.loads((ROOT / "world/zones.json").read_text())
zones = {z["id"]: z for z in plan["zones"] + plan["caves"] + plan.get("dimensions", [])}
nodes = json.loads((DATA / "ore_nodes/island.json").read_text())["nodes"]
for zid, z in zones.items():
pool = DATA / f"spawn_pool_world/{zid}.json"
spawns = json.loads(pool.read_text())["spawns"] if pool.exists() else []
z["species"] = sorted({f"cobblemon:{s['pokemon'].split()[0].lower()}" for s in spawns})
# What its ore veins give when mined (world/zones.json → ores → item; veins are named <ore>_node).
z["ore_items"] = [plan["ores"][v.removesuffix("_node")]["item"] for v in sorted({n["vein"] for n in nodes if n["zone"] == zid})]
guardian = DATA / f"guardians/{zid}.json"
z["guardian_data"] = json.loads(guardian.read_text()) if guardian.exists() else None
return zones
def machine_cores():
return sorted(json.loads(p.read_text())["core"].split(":")[1] for p in (DATA / "machines").glob("*.json"))
def write_event_advancements(zones):
"""The hidden advancements the companion mod grants (progress/QuestEvents.kt)."""
shutil.rmtree(EVENTS, ignore_errors=True)
hidden = {"_comment": "Generated by tools/quests.py; granted by the companion mod (progress/QuestEvents.kt). "
"No display: no toast, not in the advancement screen; quests check it.",
"criteria": {"done": {"trigger": "minecraft:impossible"}}}
names = [f"zone/{z}" for z in zones] + [f"guardian/{z}" for z, d in zones.items() if d["guardian_data"]]
names += [f"machine/{core}" for core in machine_cores()] + ["end_boss"]
for name in names:
path = EVENTS / f"{name}.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(hidden, indent=2) + "\n")
return set(names)
# --- Expanding docs/quests.yaml ----------------------------------------------------------------------------------
def title_case(item):
return item.split(":")[1].replace("_", " ").title()
def zone_fields(zone, chapter, book):
"""What the zone template fills in. A zone chapter says what is particular about it (Adrian, 2026-11-10: no
boilerplate): `about`, the `locals` named as examples (they must spawn there), and a `guardian_note`."""
g = zone["guardian_data"]
ores = zone["ore_items"]
if not chapter.get("about"):
sys.exit(f"zone chapter {chapter['id']} has no 'about'")
for name in re.split(r", | and ", chapter.get("locals", "")):
if name and f"cobblemon:{re.sub(r'[^a-z0-9]', '', name.lower())}" not in zone["species"]:
sys.exit(f"{chapter['id']}: {name} is named as a local but doesn't spawn in {zone['name']}")
return {
"zone": zone["name"], "zone_id": zone["id"], "low": zone["levels"][0], "high": zone["levels"][1],
"about": chapter["about"].strip(), "locals": chapter.get("locals", ""), "guardian_note": chapter.get("guardian_note", ""),
"guardian": g["pokemon"].split()[0].title() if g else "",
"guardian_level": re.search(r"level=(\d+)", g["pokemon"])[1] if g else "",
"drops": ", ".join(f"{d['count']} {title_case(d['item'])}" for d in g["drops"]) if g else "",
"ores": ores, "ore_names": ", ".join(title_case(o) for o in ores),
}
def fill(value, fields):
if isinstance(value, str):
return re.sub(r"\{(\w+)\}", lambda m: str(fields[m[1]]) if m[1] in fields else m[0], value)
if isinstance(value, list):
return [fill(v, fields) for v in value]
if isinstance(value, dict):
return {k: fill(v, fields) for k, v in value.items()}
return value
def chapter_quests(chapter, book, zones):
quests = copy.deepcopy(chapter.get("quests", []))
if "zone" in chapter:
zone = zones[chapter["zone"]]
fields = zone_fields(zone, chapter, book)
wanted = {"ores": bool(fields["ores"]), "guardian": bool(zone["guardian_data"]), "species": bool(zone["species"])}
template = [q for q in book["zone_chapter"] if all(wanted[w] for w in q.get("when", []))]
quests = [fill({k: v for k, v in q.items() if k != "when"}, fields) for q in template] + quests
# One item task per ore of the zone.
for q in quests:
tasks = []
for t in q.get("tasks", []):
tasks += [{**t, "item": o, "each": None} for o in fields["ores"]] if t.get("each") == "ores" else [t]
q["tasks"] = [{k: v for k, v in t.items() if k != "each"} for t in tasks]
# One quest per Sigil: craft it, use it, catch what comes (world/summons.json; names from the JEI data).
if chapter.get("summons"):
sigils = json.loads((ROOT / "datapacks/core/data/cobblesurvival/drop_sources/pages.json").read_text())["summons"]
for s in sigils:
quests.append({
"id": s["species"].split(":")[1], "title": s["name"], "icon": "cobblesurvival:summon_sigil", "after": ["about"],
"text": f"Craft the {s['name']} Sigil and use it in {zones[s['zone']]['name']}. Catch the Lv {s['level']} {s['name']} that answers.",
"tasks": [{"cobblemon": "catch", "pokemon": [s["pokemon"]]}],
})
# "Catch Pokémon of a zone": its spawn pool's species (Cobblemon Quests' own biome filter knows no zones).
for q in quests:
for t in q.get("tasks", []):
if t.get("zone_species"):
t["pokemon"] = zones[t.pop("zone_species")]["species"]
return quests
def task_nbt(task, name, events):
tid = object_id(f"task:{name}")
if "item" in task:
nbt = {"id": tid, "type": "item", "item": {"count": 1, "id": task["item"]}}
if task.get("count", 1) > 1:
nbt["count"] = Long(task["count"])
return nbt
if "event" in task:
assert task["event"] in events, f"{name}: no event advancement {task['event']}"
return {"id": tid, "type": "advancement", "advancement": f"cobblesurvival:events/{task['event']}", "criterion": ""}
if "advancement" in task:
return {"id": tid, "type": "advancement", "advancement": task["advancement"], "criterion": ""}
if "cobblemon" in task:
species = task.get("pokemon", [])
return {
"id": tid, "type": COBBLEMON_TASK, "action": task["cobblemon"], "amount": Long(task.get("amount", 1)),
"pokemon": ",".join(s if ":" in s else f"cobblemon:{s}" for s in species),
"pokemon_type": ",".join(task.get("types", [])), "shiny": False,
"biome": "", "dimension": "", "form": "", "gender": "", "poke_ball_used": "", "region": "", "natures": "",
"time_min": Long(0), "time_max": Long(0), "min_level": 0, "max_level": 0, "dex_progress": "",
}
if task.get("checkmark"):
return {"id": tid, "type": "checkmark"}
raise ValueError(f"{name}: unknown task {task}")
def auto_layout(quests, chapter_id, grid=None):
"""Left to right by dependency depth inside the chapter, stacked within a column; with `grid` columns, what depends
on something goes into a grid to the right instead of one tall column."""
local = {q["id"] for q in quests}
depth = {}
def d(q):
if q["id"] not in depth:
deps = [p for p in q.get("after", []) if p in local]
depth[q["id"]] = 0 if not deps else 1 + max(d(next(x for x in quests if x["id"] == p)) for p in deps)
return depth[q["id"]]
columns = {}
for q in quests:
columns.setdefault(d(q), []).append(q["id"])
pos = {}
if grid:
for i, qid in enumerate(q["id"] for q in quests if depth[q["id"]] > 0):
pos[f"{chapter_id}/{qid}"] = [2.0 + (i % grid) * 1.5, (i // grid) * 1.5]
columns = {c: ids for c, ids in columns.items() if c == 0}
for col, ids in columns.items():
for row, qid in enumerate(ids):
pos[f"{chapter_id}/{qid}"] = [col * 2.0, (row - (len(ids) - 1) / 2) * 1.5]
return pos
def build(book):
zones = zone_data()
events = write_event_advancements(zones)
layout = book.get("layout") or {}
lang = {"file.0000000000000001.title": book["book"]["title"]}
chapters, seen = {}, set()
expanded = {c["id"]: chapter_quests(c, book, zones) for c in book["chapters"]}
all_quests = {f"{cid}/{q['id']}" for cid, qs in expanded.items() for q in qs}
for index, chapter in enumerate(book["chapters"]):
cid = chapter["id"]
quests = expanded[cid]
auto = auto_layout(quests, cid, chapter.get("columns"))
chapter_id = object_id(f"chapter:{cid}")
lang[f"chapter.{chapter_id}.title"] = chapter["title"]
if chapter.get("subtitle"):
lang[f"chapter.{chapter_id}.chapter_subtitle"] = [chapter["subtitle"]]
nbt_quests = []
for q in quests:
name = f"{cid}/{q['id']}"
assert name not in seen, f"quest {name} twice"
seen.add(name)
qid = object_id(f"quest:{name}")
x, y = layout.get(name) or auto[name]
deps = []
for dep in q.get("after", []):
full = dep if "/" in dep else f"{cid}/{dep}"
assert full in all_quests, f"{name}: depends on unknown quest {full}"
deps.append(object_id(f"quest:{full}"))
nbt = {"id": qid, "x": float(x), "y": float(y),
"tasks": [task_nbt(t, f"{name}/{i}", events) for i, t in enumerate(q["tasks"])]}
if deps:
nbt["dependencies"] = deps
if q.get("icon"):
nbt["icon"] = {"id": q["icon"]}
if q.get("size"):
nbt["size"] = float(q["size"])
nbt_quests.append(nbt)
lang[f"quest.{qid}.title"] = q["title"]
if q.get("subtitle"):
lang[f"quest.{qid}.quest_subtitle"] = q["subtitle"]
if q.get("text"):
lang[f"quest.{qid}.quest_desc"] = [line.rstrip() for line in q["text"].rstrip("\n").split("\n")]
for i, t in enumerate(q["tasks"]):
if t.get("title"):
lang[f"task.{object_id(f'task:{name}/{i}')}.title"] = t["title"]
chapters[cid] = {
"id": chapter_id, "filename": cid, "group": "", "order_index": index,
"icon": {"id": chapter["icon"]}, "default_quest_shape": "", "default_hide_dependency_lines": False,
"quest_links": [], "quests": nbt_quests,
}
data = {
# Adrian, 2026-11-03 / roadmap: flexible progression (tasks count before their dependencies are done) and no
# lock icons, so dependencies show as lines but never block. No rewards anywhere.
"version": 13, "progression_mode": "flexible", "show_lock_icons": False,
"default_reward_team": False, "default_consume_items": False, "default_autoclaim_rewards": "disabled",
"default_quest_shape": "circle", "default_quest_disable_jei": False, "emergency_items_cooldown": 300,
"drop_loot_crates": False, "disable_gui": False, "grid_scale": 0.5, "pause_game": False, "lock_message": "",
"detection_delay": 20, "drop_book_on_death": False, "hide_excluded_quests": False, "fallback_locale": "en_us",
"verify_on_load": False, "icon": {"id": book["book"]["icon"]},
}
return data, chapters, lang
def write(data, chapters, lang, out):
if out.exists():
shutil.rmtree(out)
(out / "chapters").mkdir(parents=True)
(out / "lang").mkdir()
(out / "data.snbt").write_text(snbt(data) + "\n")
(out / "chapter_groups.snbt").write_text(snbt({"chapter_groups": []}) + "\n")
for cid, chapter in chapters.items():
(out / f"chapters/{cid}.snbt").write_text(snbt(chapter) + "\n")
(out / "lang/en_us.snbt").write_text(snbt(lang) + "\n")
# --- Reading positions back from the game ------------------------------------------------------------------------
def pull(book, folder):
"""The book's `layout` updated from an FTB Quests folder: a quest found there is recorded where it stands, unless
that's where the generator puts it anyway; quests not found there keep their entry."""
zones = zone_data()
auto, by_id = {}, {}
for c in book["chapters"]:
quests = chapter_quests(c, book, zones)
auto |= auto_layout(quests, c["id"], c.get("columns"))
by_id |= {object_id(f"quest:{c['id']}/{q['id']}"): f"{c['id']}/{q['id']}" for q in quests}
layout = dict(book.get("layout") or {})
for path in sorted((folder / "chapters").glob("*.snbt")):
for q in parse_snbt(path.read_text()).get("quests", []):
name = by_id.get(str(q.get("id", "")).upper())
if not name:
continue
pos = [round(float(q.get("x", 0)), 2), round(float(q.get("y", 0)), 2)]
if pos == auto[name]:
layout.pop(name, None)
else:
layout[name] = pos
return layout
def write_layout(positions):
"""Replaces the `layout:` block at the end of docs/quests.yaml."""
text = SOURCE.read_text()
head = text[:text.index("\n" + LAYOUT_START) + 1] if "\n" + LAYOUT_START in text else text.rstrip("\n") + "\n\n"
lines = [LAYOUT_START] + [f" {name}: [{x}, {y}]" for name, (x, y) in sorted(positions.items())]
SOURCE.write_text(head + "\n".join(lines) + "\n")
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--pull", type=Path, metavar="DIR", help="read node positions from an FTB Quests folder first")
parser.add_argument("--check", action="store_true", help="fail if the generated files are out of date")
args = parser.parse_args()
book = yaml.safe_load(SOURCE.read_text())
if args.pull:
layout = pull(book, args.pull)
if layout != (book.get("layout") or {}):
write_layout(layout)
book = yaml.safe_load(SOURCE.read_text())
print(f"quest positions from {args.pull} written to {SOURCE.relative_to(ROOT)} ({len(layout)} moved nodes)")
else:
print(f"no quest moved in {args.pull}")
data, chapters, lang = build(book)
if args.check:
tmp = ROOT / "build/quests-check"
write(data, chapters, lang, tmp)
stale = [p.relative_to(tmp) for p in tmp.rglob("*.snbt") if not (OUT / p.relative_to(tmp)).exists()
or (OUT / p.relative_to(tmp)).read_text() != p.read_text()]
stale += [p.relative_to(OUT) for p in OUT.rglob("*.snbt") if not (tmp / p.relative_to(OUT)).exists()]
if stale:
sys.exit(f"quest files out of date ({', '.join(map(str, stale[:5]))}); run tools/quests.py")
print("quest files up to date")
return
write(data, chapters, lang, OUT)
count = sum(len(c["quests"]) for c in chapters.values())
print(f"wrote {len(chapters)} chapters, {count} quests to {OUT.relative_to(ROOT)}")
if __name__ == "__main__":
main()