Adds standardize-vault-pages pipeline, exports intentional empty generations, and refreshes the 396-hack catalog with full banner coverage, status classification, and Fakemon generation tags.
387 lines
16 KiB
Python
387 lines
16 KiB
Python
#!/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 <VAULT>/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/<P>.md — H1 count + "## Static" table. Existing: GBA/GBC/GB/NDS.
|
||
Created if missing: 3DS, PC, Patch, Unknown.
|
||
- Bases/<B>.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 ["<path to Obsidian Vault>"]
|
||
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,
|
||
"engine": fm.get("engine") 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()
|
||
|
||
|
||
def wikilink(stem: str) -> str:
|
||
"""Obsidian-safe wikilink for note stems that contain [ or ]."""
|
||
escaped = stem.replace("[", "\\[").replace("]", "\\]")
|
||
return f"[[{escaped}]]"
|
||
|
||
# ---------------------------------------------------------------- MOC config
|
||
|
||
# Logical hardware order for human-facing lists.
|
||
PLATFORM_ORDER = ["GB", "GBC", "GBA", "NDS", "3DS", "N64", "GameCube", "Switch", "PC", "Patch", DASH]
|
||
PLATFORM_LABEL = {
|
||
"GB": "Game Boy", "GBC": "Game Boy Color", "GBA": "Game Boy Advance",
|
||
"NDS": "Nintendo DS", "3DS": "Nintendo 3DS", "N64": "Nintendo 64",
|
||
"GameCube": "Nintendo GameCube",
|
||
"Switch": "Nintendo Switch",
|
||
"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")),
|
||
"N64": ("Nintendo 64 (N64)", plat_pred("N64")),
|
||
"GameCube": ("Nintendo GameCube", plat_pred("GameCube")),
|
||
"Switch": ("Nintendo Switch", plat_pred("Switch")),
|
||
"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"'),
|
||
"N64": ("N64",
|
||
"Nintendo 64 Pokémon mods and overhaul patches. These are tracked "
|
||
"separately from handheld ROM hacks because they target Nintendo 64-era base games.",
|
||
'WHERE platform = "N64"'),
|
||
"GameCube": ("GameCube",
|
||
"Nintendo GameCube Pokémon mods and overhaul patches. These are tracked "
|
||
"separately from handheld ROM hacks because they target GameCube-era base games.",
|
||
'WHERE platform = "GameCube"'),
|
||
"Switch": ("Switch",
|
||
"Nintendo Switch Pokémon mods and overhaul patches. These are tracked "
|
||
"separately from handheld ROM hacks because they target Switch-era base games.",
|
||
'WHERE platform = "Switch"'),
|
||
"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"| {wikilink(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 | Engine | Type |\n|---|---|---|---|---|---|"
|
||
rows = [
|
||
f"| {wikilink(r['stem'])} | {cell(r['base'])} | {cell(r['version'])} | "
|
||
f"{cell(r['status'])} | {cell(r['engine'])} | {types_cell(r['type'])} |"
|
||
for r in sorted(recs, key=key)
|
||
]
|
||
else:
|
||
head = "| Hack | Version | Dev | Engine | Type |\n|---|---|---|---|---|"
|
||
rows = [
|
||
f"| {wikilink(r['stem'])} | {cell(r['version'])} | {cell(r['status'])} | "
|
||
f"{cell(r['engine'])} | {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"| {wikilink(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 — 75 hand-curated, ~210 imported from the "
|
||
f"Discord catalog, and 83 added from the scrape (tag `source/discord`), "
|
||
f"enriched 2026-06-08 with developer / release / banner art and uniform "
|
||
f"Summary·Features·Story bodies.\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`. Most carry `play_status: Unplayed` 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()
|