add a writer-facing content pipeline under writing/
Dialogue was already plain text, so the gap was never the .yarn files. It was
that the character material a writing session needs was either missing or stored
somewhere a writer could not comfortably work.
Three things were wrong. The writing handbook was not in the repo and was cited
by SC101.yarn as though it were. The best voice material — register, verbal tics,
success/failure/passive samples — was a \n-escaped string inside a JSON field.
The named NPCs had no sheets at all, existing only as their Yarn lines.
writing/ now holds the dialogue-facing layer, at the repo root so it gets no
.meta files and is not a Unity asset:
STYLE.md register, the five tone moves, the ban list, branching
shapes, the Wrong Truth rule, flag families, candidate rules
YARN-PRIMER.md the one page of syntax needed to write a scene
GLOSSARY.md in-world and project vocabulary
voices/ the eleven skill voices, one readable sheet each
characters/ Bartender, Chevalier Cassian Thal, Bradford Kane
scenes/ SC-101's brief: intent, beats, checks, constraints
lore/ the three identity readings and the rules that bind them
STYLE.md is a digest of the Mystery Writing Handbook in the Obsidian vault, with
pointers rather than copies. The vault stays the authoring home for design work,
and Ground Truth — the solution document — deliberately stays out of this repo.
The voice sheets are generated from skill_bible.json but are the source of truth
for the prose. tools/writing/sync_voices.py folds edits back; voicelib.py
guarantees notes -> dict -> notes is identity for all eleven, so --to-json on
unchanged sheets is byte-identical. VoiceSheetSyncTests fails the build on drift
in either direction, and was verified to fail on real drift rather than merely
to pass.
SC101.yarn's header is trimmed from prose rationale to the six rules that bind
while editing that file, with the reasoning moved to the scene brief.
EditMode 42/42. YarnCheck compiles 9 files / 35 nodes.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
#!/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/<id>.md -> skill_bible.json (displayName, domain, notes)
|
||||
MACHINE skill_bible.json -> writing/voices/<id>.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 = "<!-- GENERATED FROM skill_bible.json — do not edit by hand -->"
|
||||
GEN_END = "<!-- END GENERATED -->"
|
||||
|
||||
# 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()
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Parse and re-emit the Skill Bible prose that lives in skill_bible.json.
|
||||
|
||||
The `notes` field of each skill is a plain-text sheet with a fixed shape. This
|
||||
module converts between that string and a structured dict, losslessly, so the
|
||||
prose can live in writing/voices/*.md and still be folded back into the JSON.
|
||||
|
||||
Round-tripping is the whole contract: notes -> dict -> notes must be identity
|
||||
for every skill in the bible, and check_roundtrip() asserts exactly that.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
PROSE_KEYS = [
|
||||
"name",
|
||||
"domain",
|
||||
"what it wants for you",
|
||||
"what it's wrong about",
|
||||
"how it addresses you",
|
||||
"register",
|
||||
]
|
||||
# "verbal tics" is handled separately (it carries bullets)
|
||||
TAIL_KEYS = ["what it never does", "opposed to", "allied with"]
|
||||
|
||||
VOICE_RE = re.compile(r'^(SUCCESS|FAILURE|PASSIVE) VOICE(?: — (.*?))?: (.*)$')
|
||||
FIRING_RE = re.compile(r'^Firing frequency target \(per hour\): (\d+)$')
|
||||
FORBID_RE = re.compile(r'^Domains it must NOT comment on: (.*)$')
|
||||
BULLET_RE = re.compile(r'^\* \((\d+)\) (.*)$')
|
||||
|
||||
|
||||
def parse_notes(notes):
|
||||
"""notes string -> dict. Raises on anything unrecognised."""
|
||||
out = {"fields": {}, "tics": [], "voices": {}, "voice_gloss": {}}
|
||||
lines = notes.split("\n")
|
||||
i = 0
|
||||
if lines[i].strip() != "SKILL":
|
||||
raise ValueError("expected a leading SKILL line")
|
||||
out["_header"] = lines[i]
|
||||
i += 1
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
|
||||
if line.strip() == "":
|
||||
i += 1
|
||||
continue
|
||||
|
||||
m = BULLET_RE.match(line)
|
||||
if m:
|
||||
out["tics"].append((int(m.group(1)), m.group(2)))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
m = VOICE_RE.match(line)
|
||||
if m:
|
||||
kind = m.group(1).lower()
|
||||
out["voices"][kind] = m.group(3)
|
||||
if m.group(2):
|
||||
out["voice_gloss"][kind] = m.group(2)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
m = FIRING_RE.match(line)
|
||||
if m:
|
||||
out["firing"] = int(m.group(1))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
m = FORBID_RE.match(line)
|
||||
if m:
|
||||
out["forbidden"] = m.group(1)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
m = re.match(r"^([a-z][a-z '\-]*): ?(.*)$", line)
|
||||
if m and m.group(1) in PROSE_KEYS + TAIL_KEYS + ["verbal tics"]:
|
||||
out["fields"][m.group(1)] = m.group(2)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
raise ValueError(f"unrecognised line: {line!r}")
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def emit_notes(d):
|
||||
"""dict -> notes string. Inverse of parse_notes."""
|
||||
L = [d.get("_header", "SKILL")]
|
||||
for k in PROSE_KEYS:
|
||||
L.append(f"{k}: {d['fields'][k]}")
|
||||
L.append("verbal tics:")
|
||||
for n, text in d["tics"]:
|
||||
L.append(f"* ({n}) {text}")
|
||||
for k in TAIL_KEYS:
|
||||
L.append(f"{k}: {d['fields'][k]}")
|
||||
L.append("")
|
||||
for kind in ("success", "failure", "passive"):
|
||||
gloss = d["voice_gloss"].get(kind)
|
||||
label = f"{kind.upper()} VOICE"
|
||||
if gloss:
|
||||
L.append(f"{label} — {gloss}: {d['voices'][kind]}")
|
||||
else:
|
||||
L.append(f"{label}: {d['voices'][kind]}")
|
||||
L.append("")
|
||||
L.append(f"Firing frequency target (per hour): {d['firing']}")
|
||||
L.append(f"Domains it must NOT comment on: {d['forbidden']}")
|
||||
return "\n".join(L)
|
||||
|
||||
|
||||
def check_roundtrip(skills):
|
||||
"""Assert notes -> dict -> notes is identity for every skill."""
|
||||
bad = []
|
||||
for s in skills:
|
||||
try:
|
||||
again = emit_notes(parse_notes(s["notes"]))
|
||||
except Exception as e:
|
||||
bad.append((s["id"], f"parse error: {e}"))
|
||||
continue
|
||||
if again != s["notes"]:
|
||||
bad.append((s["id"], "round-trip differs"))
|
||||
return bad
|
||||
Reference in New Issue
Block a user