Phase 8: the quest book, generated from docs/quests.yaml
- tools/quests.py writes pack/config/ftbquests/quests/ in FTB Quests 2101's format (data.snbt, chapters/*.snbt, texts in lang/en_us.snbt) with ids derived from the YAML names, so progress survives regeneration. Flexible progression, no lock icons, no rewards (Adrian, 2026-11-03). tools/build.py runs it. - docs/quests.yaml: 18 chapters, 74 quests. Getting Started; one chapter per zone and the End from a zone template (reach it, catch three of its Pokemon, mine its ores, beat its guardian) plus chapter extras (Mire Waders, Eyes of Ender, Eternatus); Pokemon Machines; Base and Jobs. - Node positions: --pull reads them from an FTB Quests folder into the layout block (only moved nodes); tools/prism_instance.py pulls from the instance before each sync and rebuilds if something moved. - The pack's own events as hidden advancements (cobblesurvival:events/...), granted by QuestEvents: zone reached, guardian beaten, machine filled, End boss beaten. - Self-tests quest_book_loads and quest_events_mark_zones; the guardian and furnace tests check their events. Self-tests and island check pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9fb5e9895e
commit
c5d1cf93f4
70 files changed
+3665
-7
No files matched your search
@@ -104,6 +104,7 @@ def main():
|
||||
if not args.skip_mod:
|
||||
build_mod()
|
||||
jar = companion_jar()
|
||||
run([sys.executable, str(ROOT / "tools/quests.py")], ROOT) # the quest book (Phase 8) from docs/quests.yaml
|
||||
packwiz("refresh", cwd=PACK)
|
||||
pack = tomllib.loads((PACK / "pack.toml").read_text())
|
||||
base = f"{pack['name'].lower().replace(' ', '-')}-{pack['version']}"
|
||||
|
||||
@@ -5,6 +5,8 @@ Writes instance.cfg and mmc-pack.json (Minecraft + NeoForge from pack.toml),
|
||||
then syncs the client-side files with packwiz-installer. Run tools/build.py
|
||||
first. Run it again after changing the pack; options and saves in the
|
||||
instance are left alone. Close the instance in Prism before syncing.
|
||||
Quest nodes moved in the instance's quest editor are first copied into
|
||||
docs/quests.yaml (tools/quests.py --pull), and the pack rebuilt with them.
|
||||
|
||||
When build/world-template exists (tools/make_world.py), it is copied into the
|
||||
instance as the save "Cobblemon Survival Island" unless that save already
|
||||
@@ -16,6 +18,7 @@ Usage: tools/prism_instance.py [--name NAME] [--prism-dir DIR] [--memory MB] [--
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
@@ -78,6 +81,16 @@ def main():
|
||||
instance = prism / "instances" / args.name
|
||||
game = instance / "minecraft"
|
||||
game.mkdir(parents=True, exist_ok=True)
|
||||
# Node positions moved in the in-game quest editor go back into docs/quests.yaml first (Adrian, 2026-11-03),
|
||||
# so the sync doesn't overwrite them; the pack is rebuilt with them.
|
||||
quests = game / "config/ftbquests/quests"
|
||||
if (quests / "chapters").is_dir():
|
||||
source = ROOT / "docs/quests.yaml"
|
||||
before = source.read_text()
|
||||
subprocess.run([sys.executable, str(ROOT / "tools/quests.py"), "--pull", str(quests)], check=True)
|
||||
if source.read_text() != before:
|
||||
print("rebuilding with the moved quest nodes (commit docs/quests.yaml)")
|
||||
subprocess.run([sys.executable, str(ROOT / "tools/build.py"), "--skip-mod"], check=True)
|
||||
pack = tomllib.loads((stage / "pack.toml").read_text())
|
||||
write_cfg(instance / "instance.cfg", args.name, args.memory)
|
||||
write_components(instance / "mmc-pack.json", pack["versions"])
|
||||
|
||||
+427
@@ -0,0 +1,427 @@
|
||||
#!/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})
|
||||
z["veins"] = 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):
|
||||
g = zone["guardian_data"]
|
||||
ores = [book["ore_items"][v] for v in zone["veins"] if v in book["ore_items"]]
|
||||
return {
|
||||
"zone": zone["name"], "zone_id": zone["id"], "low": zone["levels"][0], "high": zone["levels"][1],
|
||||
"hazard_text": chapter.get("hazard_text", ""),
|
||||
"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]
|
||||
# "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):
|
||||
"""Left to right by dependency depth inside the chapter, stacked within a 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 = {}
|
||||
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_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"])
|
||||
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()
|
||||
Reference in new issue
Block a user