- 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>
450 lines
20 KiB
Python
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()
|