diff --git a/scripts/build-vault-mocs.py b/scripts/build-vault-mocs.py new file mode 100644 index 0000000..ed6ba72 --- /dev/null +++ b/scripts/build-vault-mocs.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python3 +"""Regenerate the Pokémon ROM-hack vault's STATIC tables, header counts, and MOCs. + +Source of truth = the live vault notes at /Pokémon ROM Hacks/Hacks/*.md +(their YAML frontmatter). This script READS those notes and rewrites only the +*static mirror* surfaces that Dataview does not maintain: + + - Index.md — intro platform/base counts, the "Directory (static)" + table (all hacks), the Platforms + Bases lists. + - Platforms/

.md — H1 count + "## Static" table. Existing: GBA/GBC/GB/NDS. + Created if missing: 3DS, PC, Patch, Unknown. + - Bases/.md — H1 count + "## Static" table. FireRed/Emerald/Crystal/ + Ruby/NDS. + +It NEVER touches Hacks/*.md, so curated type/rating/version data is preserved by +construction. Dataview code blocks, prose intros, and footers in the MOC files +are preserved verbatim — only the H1 count line and the static-table region are +replaced. + +This SUPERSEDES build-romhack-vault.py for the live vault. That script is an +archived bootstrap with a hardcoded 75-hack dataset; re-running it would regress +the catalog. Use THIS one to refresh static surfaces after notes are added. + +Run: python scripts/build-vault-mocs.py [""] +Default vault: C:\\Users\\MattC\\Documents\\Obsidian Vault +""" +import os, re, sys, glob + +DEFAULT_VAULT = r"C:\Users\MattC\Documents\Obsidian Vault" +CATALOG = "Pokémon ROM Hacks" +DASH = "—" # em-dash used for "unknown" + +# ---------------------------------------------------------------- frontmatter + +def _unquote(s): + s = s.strip() + if len(s) >= 2 and s[0] == '"' and s[-1] == '"': + return s[1:-1] + return s + +def parse_frontmatter(text): + m = re.match(r"^---\n(.*?)\n---", text, re.S) + if not m: + return {} + fm, lines, i = {}, m.group(1).split("\n"), 0 + while i < len(lines): + km = re.match(r"^([\w]+):\s*(.*)$", lines[i]) + if km: + k, v = km.group(1), km.group(2) + if v == "" and i + 1 < len(lines) and re.match(r"^\s*-\s", lines[i + 1]): + items = [] + while i + 1 < len(lines) and re.match(r"^\s*-\s", lines[i + 1]): + items.append(_unquote(lines[i + 1].strip()[2:])) + i += 1 + fm[k] = items + elif v.strip().startswith("[") and v.strip().endswith("]"): + inner = v.strip()[1:-1].strip() + fm[k] = [_unquote(x) for x in inner.split(",")] if inner else [] + else: + fm[k] = _unquote(v) + i += 1 + return fm + +def load_records(hacks_dir): + recs = [] + for f in glob.glob(os.path.join(hacks_dir, "*.md")): + fm = parse_frontmatter(open(f, encoding="utf-8").read()) + stem = os.path.splitext(os.path.basename(f))[0] + typ = fm.get("type") or [] + if isinstance(typ, str): + typ = [typ] if typ and typ != "[]" else [] + recs.append({ + "stem": stem, + "platform": fm.get("platform") or DASH, + "base": fm.get("base") or DASH, + "version": fm.get("version") or DASH, + "status": fm.get("status") or DASH, + "type": typ, + }) + return recs + +# ---------------------------------------------------------------- table cells + +def cell(v): + return str(v).replace("|", "\\|") if v not in (None, "") else DASH + +def types_cell(types): + return ", ".join(types) if types else "" + +def key(r): + return r["stem"].casefold() + +# ---------------------------------------------------------------- MOC config + +# Logical hardware order for human-facing lists. +PLATFORM_ORDER = ["GB", "GBC", "GBA", "NDS", "3DS", "PC", "Patch", DASH] +PLATFORM_LABEL = { + "GB": "Game Boy", "GBC": "Game Boy Color", "GBA": "Game Boy Advance", + "NDS": "Nintendo DS", "3DS": "Nintendo 3DS", "PC": "PC / Joiplay fan-games", + "Patch": "Patch-only", DASH: "Unknown platform", +} + +# Platform MOC files: filename -> (H1 title prefix, predicate) +def plat_pred(p): + return lambda r: r["platform"] == p + +PLATFORM_MOCS = { + "GBA": ("Game Boy Advance (GBA)", plat_pred("GBA")), + "GBC": ("Game Boy Color (GBC)", plat_pred("GBC")), + "GB": ("Game Boy (GB)", plat_pred("GB")), + "NDS": ("Nintendo DS (NDS)", plat_pred("NDS")), + "3DS": ("Nintendo 3DS (3DS)", plat_pred("3DS")), + "PC": ("PC / Joiplay Fan-games", plat_pred("PC")), + "Patch": ("Patch-only", plat_pred("Patch")), + "Unknown": ("Unknown platform", plat_pred(DASH)), +} + +# New platform MOCs that may not exist yet — prose + dataview WHERE clause. +NEW_PLATFORM_MOCS = { + "3DS": ("3DS", + "Nintendo 3DS Pokémon fan-games and hacks. A small but growing set — " + "mostly Citra/Luma-targeted projects harvested from the Discord catalog.", + 'WHERE platform = "3DS"'), + "PC": ("PC", + "RPG-Maker / Joiplay fan-games (run on PC or via Joiplay on Android). " + "These are standalone games, **not** console ROM hacks — they live under " + "`/storage1/labdata/romhacks/library/` and are **not** scanned into RomM.", + 'WHERE platform = "PC"'), + "Patch": ("Patch", + "Patch-only entries — an IPS/UPS/xdelta patch was catalogued but no " + "pre-patched ROM was placed. Apply the patch to the listed base to play.", + 'WHERE platform = "Patch"'), + "Unknown": ("Unknown", + "Entries whose target platform could not be determined from the " + "import metadata. Triage these and set `platform:` in the note's " + "frontmatter to file them under the right system.", + 'WHERE platform = "' + DASH + '"'), +} + +# Base MOC files: filename -> (predicate, has_base_column) +BASE_MOCS = { + "FireRed": (lambda r: r["base"] == "FireRed", False), + "Emerald": (lambda r: "Emerald" in r["base"], False), + "Crystal": (lambda r: r["base"] == "Crystal", False), + "Ruby": (lambda r: r["base"] == "Ruby", False), + "NDS": (lambda r: r["platform"] == "NDS", True), +} + +# ---------------------------------------------------------------- table builders + +def platform_static_table(recs): + head = "| Hack | Base | Version | Status | Type |\n|---|---|---|---|---|" + rows = [ + f"| [[{r['stem']}]] | {cell(r['base'])} | {cell(r['version'])} | " + f"{cell(r['status'])} | {types_cell(r['type'])} |" + for r in sorted(recs, key=key) + ] + return head + "\n" + "\n".join(rows) + +def base_static_table(recs, with_base): + if with_base: + head = "| Hack | Base | Version | Dev | Type |\n|---|---|---|---|---|" + rows = [ + f"| [[{r['stem']}]] | {cell(r['base'])} | {cell(r['version'])} | " + f"{cell(r['status'])} | {types_cell(r['type'])} |" + for r in sorted(recs, key=key) + ] + else: + head = "| Hack | Version | Dev | Type |\n|---|---|---|---|" + rows = [ + f"| [[{r['stem']}]] | {cell(r['version'])} | {cell(r['status'])} | " + f"{types_cell(r['type'])} |" + for r in sorted(recs, key=key) + ] + return head + "\n" + "\n".join(rows) + +# ---------------------------------------------------------------- surgical edit + +def replace_static_region(text, new_h1, static_section): + """Keep H1 (swapped), prose, the dataview block, and the [[Index ...]] footer. + Replace everything between the dataview block and the footer with static_section. + """ + lines = text.split("\n") + # find dataview fence + fopen = next((i for i, l in enumerate(lines) if l.strip().startswith("```dataview")), None) + if fopen is None: + raise ValueError("no dataview block found") + fclose = next(i for i in range(fopen + 1, len(lines)) if lines[i].strip() == "```") + # find footer: last line containing [[Index + footer_idx = max(i for i, l in enumerate(lines) if "[[Index" in l) + head = [new_h1] + lines[1:fclose + 1] + footer = lines[footer_idx:] + body = ["", static_section, ""] + return "\n".join(head + body + footer).rstrip() + "\n" + +def new_platform_file(short, prose, where, title, count, static_section): + dv = ( + "```dataview\nTABLE base, version, status, type\n" + f'FROM "{CATALOG}/Hacks"\n{where}\nSORT file.name ASC\n```' + ) + return ( + f"# {title} {DASH} {count} hacks\n\n{prose}\n\n{dv}\n\n" + f"## Static\n\n{static_section}\n\n[[Index|← back to directory]]\n" + ) + +# ---------------------------------------------------------------- Index sections + +# Preserved verbatim blocks (stable taxonomy / dataview / play-queue text). +INDEX_DATAVIEW = ( + "```dataview\n" + "TABLE platform AS Platform, base AS Base, version AS Version, status AS Status, " + "type AS Type, play_status AS Play, default(rating, \"—\") AS ★\n" + f'FROM "{CATALOG}/Hacks"\n' + "SORT platform ASC, file.name ASC\n" + "```" +) + +INDEX_BYTYPE = """## By type + +- [[Types#Vanilla+|Vanilla+]] — Official game enhanced — QoL, all species obtainable, light rebalance; same region/story. +- [[Types#QoL|QoL]] — Primarily quality-of-life conveniences over an official base. +- [[Types#Difficulty|Difficulty]] — Built to be hard — boosted AI/teams, kaizo, or competitive rulesets. +- [[Types#Expansion|Expansion]] — Built on a decomp + expansion: many generations, modern mechanics, new abilities/moves. +- [[Types#New Experience|New Experience]] — New region, story, and/or Pokédex — a fresh adventure, not the official game. +- [[Types#Demake|Demake]] — Recreates a later-generation game on older hardware. +- [[Types#Roguelite|Roguelite]] — Run-based, randomized, permadeath-flavored structure. +- [[Types#Cosmetic|Cosmetic]] — Visual/audio overhaul (e.g. Moemon sprites, tilesets, music) over an existing game.""" + +INDEX_PLAYQUEUE = """## Play Queue + +[[Play Queue]] — track what you're playing, your backlog, completed hacks, and ratings (1–5).""" + +def index_directory_table(recs): + head = ("| Hack | Platform | Base | Version | Status | Type |\n" + "|---|---|---|---|---|---|") + order = {p: i for i, p in enumerate(PLATFORM_ORDER)} + rows = [ + f"| [[{r['stem']}]] | {cell(r['platform'])} | {cell(r['base'])} | " + f"{cell(r['version'])} | {cell(r['status'])} | {types_cell(r['type'])} |" + for r in sorted(recs, key=lambda r: (order.get(r["platform"], 99), key(r))) + ] + return head + "\n" + "\n".join(rows) + +def build_index(recs): + total = len(recs) + pc = {p: sum(1 for r in recs if r["platform"] == p) for p in PLATFORM_ORDER} + count_line = " · ".join( + f"**{PLATFORM_LABEL[p]}**: {pc[p]}" for p in PLATFORM_ORDER if pc[p] + ) + base_counts = { + "FireRed": sum(1 for r in recs if r["base"] == "FireRed"), + "Emerald": sum(1 for r in recs if "Emerald" in r["base"]), + "Crystal": sum(1 for r in recs if r["base"] == "Crystal"), + "Ruby": sum(1 for r in recs if r["base"] == "Ruby"), + "NDS": sum(1 for r in recs if r["platform"] == "NDS"), + } + bases_line = " · ".join( + f"[[Bases/{b}|{b}]] ({base_counts[b]})" + for b in ["FireRed", "Emerald", "Crystal", "Ruby", "NDS"] + ) + plat_links = " · ".join( + f"[[{('Unknown' if p == DASH else p)}|{PLATFORM_LABEL[p]}]]" + for p in PLATFORM_ORDER if pc[p] + ) + callout = ( + f"> [!note] {total} hacks total — the original hand-curated 75 plus " + f"~210 auto-imported from the Discord catalog on 2026-06-08 (tag " + f"`source/discord`).\n" + "> The **Dataview** blocks are the live source of truth; the **static** " + "tables below are regenerated from the notes by " + "`scripts/build-vault-mocs.py`. Imports have `play_status: Unplayed`, " + "empty `type`, and no `rating` yet — curate as you play." + ) + return f"""# Pokémon ROM-Hack Library — Index + +A knowledge base for the Pokémon ROM-hacks in the valhalla library. One note per +hack — see [[Types]] for the tag taxonomy and the [[#Platforms]] section for +per-system views. + +{callout} + +**By platform** — {count_line} + +## Directory (Dataview) + +> Requires the Dataview plugin. If you don't use it, the static table below mirrors it. + +{INDEX_DATAVIEW} + +## Directory (static) + +{index_directory_table(recs)} + +## Platforms + +- {plat_links} + +## Bases + +Browse by base game — {bases_line} + +{INDEX_BYTYPE} + +{INDEX_PLAYQUEUE} + +--- +*Static surfaces generated by `scripts/build-vault-mocs.py` from the live notes — re-run after adding hacks.* +""" + +# ---------------------------------------------------------------- main + +def main(): + vault = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_VAULT + root = os.path.join(vault, CATALOG) + hacks = os.path.join(root, "Hacks") + if not os.path.isdir(hacks): + sys.exit(f"Hacks dir not found: {hacks}") + recs = load_records(hacks) + written = [] + + # Index.md (full rebuild — all pieces known/preserved) + idx_path = os.path.join(root, "Index.md") + open(idx_path, "w", encoding="utf-8").write(build_index(recs)) + written.append(("Index.md", len(recs))) + + # Platform MOCs + plat_dir = os.path.join(root, "Platforms") + os.makedirs(plat_dir, exist_ok=True) + for fname, (title, pred) in PLATFORM_MOCS.items(): + sel = [r for r in recs if pred(r)] + path = os.path.join(plat_dir, fname + ".md") + table = platform_static_table(sel) + if os.path.exists(path): + text = open(path, encoding="utf-8").read() + new = replace_static_region(text, f"# {title} {DASH} {len(sel)} hacks", + f"## Static\n\n{table}") + else: + short, prose, where = NEW_PLATFORM_MOCS[fname] + new = new_platform_file(short, prose, where, title, len(sel), table) + open(path, "w", encoding="utf-8").write(new) + written.append((f"Platforms/{fname}.md", len(sel))) + + # Base MOCs + base_dir = os.path.join(root, "Bases") + for fname, (pred, with_base) in BASE_MOCS.items(): + sel = [r for r in recs if pred(r)] + path = os.path.join(base_dir, fname + ".md") + table = base_static_table(sel, with_base) + text = open(path, encoding="utf-8").read() + new = replace_static_region(text, f"# {fname} {DASH} {len(sel)} Hacks", + f"## Static\n\n{table}") + open(path, "w", encoding="utf-8").write(new) + written.append((f"Bases/{fname}.md", len(sel))) + + print(f"Regenerated {len(written)} files from {len(recs)} notes:") + for name, n in written: + print(f" {name:24s} {n}") + +if __name__ == "__main__": + main()