#!/usr/bin/env python3 """Sync the eleven voice sheets between writing/voices/*.md and skill_bible.json. Direction of truth, per field. This is the whole design; read it before changing anything here. PROSE writing/voices/.md -> skill_bible.json (displayName, domain, notes) MACHINE skill_bible.json -> writing/voices/.md ("Machine fields" block) A writer edits prose in the markdown and never opens the JSON. An engineer edits machine fields in the JSON and never hand-writes the markdown table. Neither direction is ambiguous, so nothing silently wins. Usage: sync_voices.py --to-md regenerate every .md from the JSON (initial extraction, and to refresh the machine block afterwards) sync_voices.py --to-json fold the markdown prose back into skill_bible.json sync_voices.py --check verify the two are in sync; exit 1 if not Wrapped prose is fine: lines inside a section are joined with single spaces, so a writer may hard-wrap a paragraph without changing what lands in the JSON. """ import argparse import json import os import re import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import voicelib ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) JSON_PATH = os.path.join(ROOT, "NightclubArcadia", "Assets", "Skills", "skill_bible.json") VOICES_DIR = os.path.join(ROOT, "writing", "voices") GEN_START = "" GEN_END = "" # markdown heading -> notes field key SECTIONS = [ ("Domain", "domain"), ("What it wants for you", "what it wants for you"), ("What it's wrong about", "what it's wrong about"), ("How it addresses you", "how it addresses you"), ("Register", "register"), ("What it never does", "what it never does"), ("Opposed to", "opposed to"), ("Allied with", "allied with"), ("Domains it must not comment on", "__forbidden"), ] def machine_block(s): allied = ", ".join(f"`{a}`" for a in s.get("alliedWith", [])) or "—" c = s.get("accentColor", {}) return "\n".join([ GEN_START, "", "| Field | Value |", "|---|---|", f"| id | `{s['id']}` |", f"| axis | {s.get('axis','—')} |", f"| opposed to | `{s.get('opposedTo','—')}` |", f"| allied with | {allied} |", f"| primary channel | {s.get('primaryChannel','—')} |", f"| secondary channel | {s.get('secondaryChannel','—')} |", f"| clue yield | {s.get('clueYield','—')} |", f"| starting rank | {s.get('startingRank','—')} |", f"| target firings/hour | {s.get('targetFiringsPerHour','—')} |", f"| accent colour | rgba({c.get('r')}, {c.get('g')}, {c.get('b')}, {c.get('a')}) |", "", GEN_END, ]) def to_md(s): d = voicelib.parse_notes(s["notes"]) f = d["fields"] L = [ "---", f"id: {s['id']}", "---", "", f"# {f['name']}", "", "> Voice sheet. **The prose below is the source of truth** — edit it here, then run", "> `python3 tools/writing/sync_voices.py --to-json`. The machine table is generated", "> from `skill_bible.json`; changing it here does nothing.", "", "## Machine fields", "", machine_block(s), "", ] for heading, key in SECTIONS: L.append(f"## {heading}") L.append("") L.append(d["forbidden"] if key == "__forbidden" else f[key]) L.append("") L.append("## Verbal tics") L.append("") for n, text in d["tics"]: L.append(f"{n}. {text}") L.append("") L.append("## Voice samples") L.append("") for kind in ("success", "failure", "passive"): L.append(f"### {kind.capitalize()}") L.append("") if kind in d["voice_gloss"]: L.append(f"*{d['voice_gloss'][kind]}*") L.append("") L.append(f"> {d['voices'][kind]}") L.append("") L.append("## Firing frequency") L.append("") L.append(f"{d['firing']} per hour (target).") L.append("") return "\n".join(L) def _join(lines): return " ".join(x.strip() for x in lines if x.strip()) def from_md(text, skill_id): """markdown -> the dict voicelib.emit_notes expects.""" body = re.sub(re.escape(GEN_START) + r".*?" + re.escape(GEN_END), "", text, flags=re.S) name_m = re.search(r"^# (.+)$", body, re.M) if not name_m: raise ValueError(f"{skill_id}: no H1 title") chunks = {} for m in re.finditer(r"^##+ (.+?)$\n(.*?)(?=^##+ |\Z)", body, re.M | re.S): chunks.setdefault(m.group(1).strip(), []).append(m.group(2)) def need(h): if h not in chunks: raise ValueError(f"{skill_id}: missing section '## {h}'") return chunks[h][0] fields = {"name": name_m.group(1).strip()} forbidden = None for heading, key in SECTIONS: val = _join(need(heading).split("\n")) if key == "__forbidden": forbidden = val else: fields[key] = val tics = [] for line in need("Verbal tics").split("\n"): m = re.match(r"^\s*(\d+)\.\s+(.*)$", line) if m: tics.append((int(m.group(1)), m.group(2).strip())) voices, gloss = {}, {} for kind in ("Success", "Failure", "Passive"): sec = need(kind) q = [l for l in sec.split("\n") if l.strip().startswith(">")] if not q: raise ValueError(f"{skill_id}: '### {kind}' has no quoted line") voices[kind.lower()] = _join([re.sub(r"^\s*>\s?", "", l) for l in q]) g = re.search(r"^\*(.+)\*$", sec.strip(), re.M) if g: gloss[kind.lower()] = g.group(1).strip() fm = re.search(r"(\d+)\s+per hour", need("Firing frequency")) if not fm: raise ValueError(f"{skill_id}: could not read firing frequency") return { "_header": "SKILL", "fields": fields, "tics": tics, "voices": voices, "voice_gloss": gloss, "firing": int(fm.group(1)), "forbidden": forbidden, } def load_json(): with open(JSON_PATH, encoding="utf-8") as fh: return json.load(fh) def md_path(skill_id): return os.path.join(VOICES_DIR, f"{skill_id}.md") def cmd_to_md(data): os.makedirs(VOICES_DIR, exist_ok=True) for s in data["skills"]: with open(md_path(s["id"]), "w", encoding="utf-8") as fh: fh.write(to_md(s)) print(f"wrote writing/voices/{s['id']}.md") def rebuilt(s): with open(md_path(s["id"]), encoding="utf-8") as fh: d = from_md(fh.read(), s["id"]) return d, voicelib.emit_notes(d) def cmd_check(data): problems = [] for s in data["skills"]: if not os.path.exists(md_path(s["id"])): problems.append(f"{s['id']}: writing/voices/{s['id']}.md is missing") continue try: d, notes = rebuilt(s) except Exception as e: problems.append(f"{s['id']}: {e}") continue if notes != s["notes"]: problems.append(f"{s['id']}: prose differs from skill_bible.json notes") if d["fields"]["name"] != s["displayName"]: problems.append(f"{s['id']}: title differs from displayName") if d["fields"]["domain"] != s["domain"]: problems.append(f"{s['id']}: domain differs from the JSON domain field") if d["firing"] != s.get("targetFiringsPerHour"): problems.append( f"{s['id']}: firing frequency {d['firing']} != " f"targetFiringsPerHour {s.get('targetFiringsPerHour')}") for p in problems: print("DRIFT:", p) if problems: print(f"\n{len(problems)} problem(s). Run --to-json (prose changed) " f"or --to-md (machine fields changed).") return 1 print(f"in sync — {len(data['skills'])} voice sheets") return 0 def cmd_to_json(data): changed = [] for s in data["skills"]: d, notes = rebuilt(s) if (notes != s["notes"] or d["fields"]["name"] != s["displayName"] or d["fields"]["domain"] != s["domain"]): changed.append(s["id"]) s["notes"] = notes s["displayName"] = d["fields"]["name"] s["domain"] = d["fields"]["domain"] s["targetFiringsPerHour"] = d["firing"] with open(JSON_PATH, "w", encoding="utf-8") as fh: json.dump(data, fh, indent=2, ensure_ascii=False) fh.write("\n") print("updated skill_bible.json" + (f" — changed: {', '.join(changed)}" if changed else " — no prose changes")) def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) g = ap.add_mutually_exclusive_group(required=True) g.add_argument("--to-md", action="store_true") g.add_argument("--to-json", action="store_true") g.add_argument("--check", action="store_true") a = ap.parse_args() data = load_json() if a.to_md: cmd_to_md(data) elif a.to_json: cmd_to_json(data) else: sys.exit(cmd_check(data)) if __name__ == "__main__": main()