"""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/.snbt with 16-hex-digit string ids; titles, subtitles and descriptions are not in the chapter file but in lang/.snbt under keys "..title", "quest..quest_subtitle", "quest..quest_desc" (a list of lines) and "chapter..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}