Files
nightclub-arcadia/NightclubArcadia/Assets/Tests/EditMode/VoiceSheetSyncTests.cs
T
lennartandClaude Opus 5 f1fadf7909 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]>
2026-08-25 20:23:42 +02:00

142 lines
5.4 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
namespace NightclubArcadia.Skills.Tests
{
/// <summary>
/// Guards the voice-sheet pipeline: writing/voices/*.md holds the prose, skill_bible.json
/// mirrors it, and the SkillDefinition assets are generated from the JSON.
///
/// A writer edits the markdown and runs tools/writing/sync_voices.py --to-json. If they
/// forget, or if someone edits the JSON prose directly, the two drift and the shipped
/// voice stops matching the sheet the next scene is written against. That is silent and
/// expensive, so it fails here instead.
///
/// This deliberately does NOT reimplement the markdown parser in C#. It asserts that the
/// prose the game ships is present verbatim in the sheet, which catches drift in either
/// direction without a second parser to keep in step with the Python one.
/// </summary>
public class VoiceSheetSyncTests
{
const string DatabasePath = "Assets/Skills/SkillDatabase.asset";
static string VoicesDir =>
Path.GetFullPath(Path.Combine(Application.dataPath, "..", "..", "writing", "voices"));
static string SheetPath(string id) => Path.Combine(VoicesDir, id + ".md");
SkillDatabase LoadDatabase()
{
var db = AssetDatabase.LoadAssetAtPath<SkillDatabase>(DatabasePath);
Assert.IsNotNull(db, $"Expected SkillDatabase at {DatabasePath}");
return db;
}
/// <summary>Files in voices/ that are prose for the writer, not a voice sheet.</summary>
static bool IsSupportingDoc(string name) =>
name.StartsWith("_") ||
string.Equals(name, "README", StringComparison.OrdinalIgnoreCase);
/// <summary>Collapse whitespace so a hard-wrapped sheet still matches a single-line source.</summary>
static string Flatten(string s) =>
Regex.Replace(s ?? string.Empty, @"\s+", " ").Trim();
[Test]
public void EverySkill_HasAVoiceSheet()
{
Assert.IsTrue(Directory.Exists(VoicesDir), $"Expected the voice sheets at {VoicesDir}");
var missing = LoadDatabase().All
.Where(s => !File.Exists(SheetPath(s.Id)))
.Select(s => s.Id)
.ToList();
Assert.IsEmpty(missing,
"No voice sheet for: " + string.Join(", ", missing) +
". Run: python3 tools/writing/sync_voices.py --to-md");
}
[Test]
public void EveryVoiceSheet_HasASkill()
{
Assert.IsTrue(Directory.Exists(VoicesDir), $"Expected the voice sheets at {VoicesDir}");
var ids = new HashSet<string>(LoadDatabase().All.Select(s => s.Id));
var orphans = Directory.GetFiles(VoicesDir, "*.md")
.Select(Path.GetFileNameWithoutExtension)
.Where(name => !IsSupportingDoc(name) && !ids.Contains(name))
.ToList();
Assert.IsEmpty(orphans,
"Voice sheets with no matching skill id: " + string.Join(", ", orphans));
}
[Test]
public void VoiceSheets_MatchTheShippedProse()
{
var problems = new List<string>();
foreach (var skill in LoadDatabase().All)
{
var path = SheetPath(skill.Id);
if (!File.Exists(path))
{
continue; // EverySkill_HasAVoiceSheet reports this
}
var sheet = Flatten(File.ReadAllText(path));
if (!sheet.Contains(Flatten("# " + skill.DisplayName)))
{
problems.Add($"{skill.Id}: sheet title does not match displayName '{skill.DisplayName}'");
}
if (!sheet.Contains(Flatten(skill.Domain)))
{
problems.Add($"{skill.Id}: the domain text is not in the sheet");
}
foreach (var line in VoiceSamples(skill.Notes))
{
if (!sheet.Contains(Flatten(line)))
{
var preview = line.Length > 60 ? line.Substring(0, 60) + "…" : line;
problems.Add($"{skill.Id}: voice sample missing from the sheet: {preview}");
}
}
}
Assert.IsEmpty(problems,
"writing/voices/*.md has drifted from skill_bible.json:\n " +
string.Join("\n ", problems) +
"\n\nProse is authored in the markdown. If you changed it there, run:\n" +
" python3 tools/writing/sync_voices.py --to-json\n" +
"If you changed the JSON directly, move the change into the sheet instead.");
}
/// <summary>The SUCCESS / FAILURE / PASSIVE lines out of a Skill Bible notes blob.</summary>
static IEnumerable<string> VoiceSamples(string notes)
{
if (string.IsNullOrEmpty(notes))
{
yield break;
}
foreach (var raw in notes.Split('\n'))
{
var match = Regex.Match(raw, @"^(?:SUCCESS|FAILURE|PASSIVE) VOICE(?: — .*?)?: (.+)$");
if (match.Success)
{
yield return match.Groups[1].Value;
}
}
}
}
}