Compare commits

..
Author SHA1 Message Date
lennartandClaude Opus 5 f5b302b3e3 add a Makefile for the common commands, and a test reporter
The invocations in CLAUDE.md were correct but long, and retyping them is how
flags get dropped. `make check` is the gate a change should pass: Yarn compiles,
voice sheets are in sync, EditMode is green.

`make test` and `make build` depend on a `lock` target that fails if the Editor
has the project open, which removes the most common confusing failure. It
filters out AssetImportWorker children, which are not a second Editor.

tools/ci/report_tests.py parses the NUnit results and exits non-zero on failure,
because Unity documents no common exit-code definition across the components
under test — the XML is the authority, not $?.

`make merge-driver` configures UnityYAMLMerge for the .gitattributes rules added
earlier. Note the binary lives in the Editor bundle under Contents/Helpers, not
Contents/Tools as most guides say; there is no Tools directory in Unity 6 on
macOS.

Also ignores the local .test-results/ and build/ output.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-25 20:29:39 +02:00
lennartandClaude Opus 5 2b35d3f754 renormalize line endings to LF
Mechanical consequence of the .gitattributes in the previous commit, done in one
deliberate pass rather than left to trickle out as surprise diffs whenever
someone next touches a vendored file.

74 files, all CRLF -> LF, 71 of them in StarterAssets. `git diff -w` over this
commit is empty: nothing but line endings changed. DialogueTest.unity is
correctly untouched, being marked binary.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-25 20:28:07 +02:00
lennartandClaude Opus 5 382f6c7f63 add .gitattributes: Unity YAML merge driver and line-ending policy
Scenes, prefabs and assets are now marked for UnityYAMLMerge, which is what
makes them mergeable at all — without it a scene conflict is resolved by a
line-oriented text merge that produces a file Unity may still load and that is
quietly wrong. The driver needs a one-time git config per machine; CLAUDE.md
carries the command.

Line endings are pinned to LF for every text type rather than left to
`text=auto`, so the 71 CRLF files vendored in StarterAssets stop being a source
of phantom diffs.

One exception, and it matters: DialogueTest.unity is marked `binary`. It is a
Unity binary SerializedFile rather than YAML, and it contains 128 lone CR bytes
— under `*.unity text eol=lf` git would rewrite those as line endings and
corrupt the scene. The rule carries the verification command and comes out as
soon as the file is genuinely text.

LFS is deliberately not enabled: the remote is self-hosted and its LFS support
is unverified, and enabling the filter against a server without it breaks
pushing. The rules to add later, and the migration step that has to accompany
them, are recorded in the file.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-25 20:28:06 +02:00
lennartandClaude Opus 5 ed28763017 document the writing pipeline and record Phase 2
CLAUDE.md gains the writing/ layout, the voice-sheet pipeline and its direction
of truth, the sync commands, and a pointer that Ground Truth must never be
copied into this repo. The writer-owned/code-owned table now covers writing/.

The restructure plan records Phase 2 as executed, and closes its first open
question: the writing handbook does exist, in the Obsidian vault, so STYLE.md
became a digest with pointers rather than the reconstruction the plan assumed.

Also noted there: the vault's opening-scene spec records SC-101's tone check as
BLOCKED on a one-page Voice Sheet that was never written. STYLE.md now holds
that sheet with four fields explicitly unset.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-25 20:23:42 +02:00
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
lennartandClaude Opus 5 b915ad78c7 record Phase 0 completion in the restructure plan
Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-25 19:50:39 +02:00
108 changed files with 17086 additions and 13999 deletions
+114
View File
@@ -0,0 +1,114 @@
# Nightclub Arcadia — git attributes
#
# Two jobs: make Unity's YAML files mergeable, and keep line endings from
# becoming a source of phantom diffs.
# ---------------------------------------------------------------------------
# Unity YAML — text, LF, and merged with UnityYAMLMerge
# ---------------------------------------------------------------------------
# The merge driver needs a one-time setup per machine; see §"Merge driver" in
# CLAUDE.md. Without it git falls back to a normal text merge, which for scenes
# and prefabs means conflicts that are painful but not silently wrong.
*.unity text eol=lf merge=unityyamlmerge
*.prefab text eol=lf merge=unityyamlmerge
*.asset text eol=lf merge=unityyamlmerge
*.mat text eol=lf merge=unityyamlmerge
*.anim text eol=lf merge=unityyamlmerge
*.controller text eol=lf merge=unityyamlmerge
*.overrideController text eol=lf merge=unityyamlmerge
*.physicMaterial text eol=lf merge=unityyamlmerge
*.physicsMaterial2D text eol=lf merge=unityyamlmerge
*.playable text eol=lf merge=unityyamlmerge
*.mask text eol=lf merge=unityyamlmerge
*.brush text eol=lf merge=unityyamlmerge
*.flare text eol=lf merge=unityyamlmerge
*.lighting text eol=lf merge=unityyamlmerge
*.terrainlayer text eol=lf merge=unityyamlmerge
*.signal text eol=lf merge=unityyamlmerge
*.guiskin text eol=lf merge=unityyamlmerge
*.fontsettings text eol=lf merge=unityyamlmerge
*.meta text eol=lf
# ---------------------------------------------------------------------------
# EXCEPTION — a scene that is serialized as BINARY
# ---------------------------------------------------------------------------
# DialogueTest.unity is a Unity binary SerializedFile, not YAML, despite the
# project being set to Force Text. It contains 128 lone CR bytes, so treating it
# as text would have git rewrite them as line endings and corrupt the scene.
#
# Remove this line the moment the file is genuinely text — verify with:
# head -c 20 NightclubArcadia/Assets/Scenes/DialogueTest.unity # want %YAML 1.1
# Background and the fix: docs/restructure-plan.md §0.1
/NightclubArcadia/Assets/Scenes/DialogueTest.unity binary
# ---------------------------------------------------------------------------
# Source, config and prose
# ---------------------------------------------------------------------------
*.cs text eol=lf diff=csharp
*.yarn text eol=lf
*.yarnproject text eol=lf
*.asmdef text eol=lf
*.asmref text eol=lf
*.inputactions text eol=lf
*.shader text eol=lf
*.compute text eol=lf
*.cginc text eol=lf
*.hlsl text eol=lf
*.shadergraph text eol=lf
*.json text eol=lf
*.md text eol=lf
*.txt text eol=lf
*.py text eol=lf
*.sh text eol=lf
*.csproj text eol=lf
*.xml text eol=lf
*.gitattributes text eol=lf
*.gitignore text eol=lf
# ---------------------------------------------------------------------------
# Binary — never diffed, never line-ending converted
# ---------------------------------------------------------------------------
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.tga binary
*.tif binary
*.tiff binary
*.psd binary
*.exr binary
*.hdr binary
*.fbx binary
*.obj binary
*.blend binary
*.wav binary
*.mp3 binary
*.ogg binary
*.aif binary
*.ttf binary
*.otf binary
*.pdf binary
*.dll binary
*.pdb binary
*.so binary
*.dylib binary
*.bundle binary
*.a binary
*.unitypackage binary
*.cubemap binary
# ---------------------------------------------------------------------------
# Git LFS — deliberately NOT enabled yet
# ---------------------------------------------------------------------------
# git-lfs is installed locally, but this repo pushes to a self-hosted server
# whose LFS support has not been verified, and enabling LFS against a server
# that lacks it breaks pushing. There is also almost nothing to store yet: one
# placeholder PNG plus the StarterAssets meshes.
#
# When real art lands, verify the server first, then add:
# *.png filter=lfs diff=lfs merge=lfs -text
# *.fbx filter=lfs diff=lfs merge=lfs -text
# *.wav filter=lfs diff=lfs merge=lfs -text
# and migrate existing blobs with `git lfs migrate import --include=...`.
# Adding the filter without migrating leaves old blobs outside LFS, which is
# confusing rather than broken.
+6
View File
@@ -127,3 +127,9 @@ tools/**/obj/
*.tar.gz
*.7z
*.rar
# ---------------------------------------------------------------------------
# Local build and test output (Makefile targets)
# ---------------------------------------------------------------------------
/.test-results/
/build/
+94 -7
View File
@@ -17,7 +17,9 @@ to fix — it is the checkout path). The Unity project is a **subdirectory**:
/Users/lennart/Dev/nightlcub-arcadia/ # git root
├── CLAUDE.md, AGENTS.md # this reference
├── docs/ # design + plan docs (markdown)
├── writing/ # writer-facing context — see §4
├── tools/YarnCheck/ # .NET 9 Yarn compiler/player, no Unity needed
├── tools/writing/ # voice-sheet sync (md <-> skill_bible.json)
├── .gitignore # macOS / IDE / toolchain rules
└── NightclubArcadia/ # ← the Unity project (-projectPath)
├── .gitignore # Unity rules (anchored, must stay here)
@@ -49,13 +51,43 @@ AI Navigation 2.0.14, ProBuilder 6.1.2, Test Framework 1.7.0. Yarn Spinner is an
mode while the Editor has it open — a single instance at a time. Always check before
any `-batchmode` invocation (§3.1).
4. **Prefer YarnCheck over Unity for dialogue validation.** It is seconds, not minutes,
and needs no Editor lock (§5.3).
and needs no Editor lock (§5.4).
5. **Do not commit on the user's behalf** unless asked. Unity writes to tracked files
(`ProjectSettings/*`, `.meta`, scenes) as a side effect of simply being open, so
`git status` noise is expected and is not always yours.
---
## 2b. Everyday commands
A `Makefile` at the repo root wraps everything in this document. Prefer it — the exact
invocations live there instead of being retyped.
```bash
make # list targets
make check # yarn + voice sheets + EditMode — run this before committing
make test # EditMode suite, with a readable summary
make yarn # compile every Yarn script (seconds, no Unity)
make build # macOS player into build/
make lock # fail if the Editor has the project open
```
`make test` and `make build` refuse to run while the Editor is open, which removes the most
common way these commands fail confusingly.
### Merge driver — one-time per machine
`.gitattributes` marks Unity YAML for `unityyamlmerge`. Git needs to be told what that is, or
it falls back to a line-oriented text merge that can produce a scene Unity still loads and that
is quietly wrong:
```bash
make merge-driver
```
That points git at `UnityYAMLMerge` inside the Editor bundle (`Contents/Helpers/`, not
`Contents/Tools/` — the path in most online guides is wrong for Unity 6 on macOS).
## 3. Unity from the command line
### 3.0 Two different tools, both present
@@ -174,6 +206,25 @@ NightclubArcadia/Assets/Dialogue/
└── Objects/ Chair
```
The writer-facing context sits at the repo root, outside `Assets/` so it gets no `.meta` files:
```
writing/
├── README.md session entry point
├── STYLE.md register, tone moves, ban list, branching rules ← read first
├── YARN-PRIMER.md the one page of syntax a writer needs
├── GLOSSARY.md in-world and project vocabulary
├── voices/ the eleven skill voices, one sheet each (generated — see §4.7)
├── characters/ named NPCs, dialogue-facing
├── scenes/ per-scene briefs: intent, beats, checks, constraints
└── lore/ candidates.md — the three identity readings and their hard rules
```
Design material deeper than this lives in Obsidian, not the repo:
`~/Obsidian-Vaults/obsidian-vault/Projects/Active/NightclubArcadia/`. **`Ground Truth.md` there
is the solution document — never copy, quote, or summarise it into this repo**, including into
commit messages. The game's premise is that the protagonist's identity never resolves.
**Dialogue is already plain text in the repo.** It is authored in `.yarn` files, not inside
the Unity Editor, and any new `.yarn` file dropped anywhere under `Assets/Dialogue/` is picked
up automatically by the `**/*.yarn` glob in the `.yarnproject`. Adding a scene or a character
@@ -249,13 +300,41 @@ candidates.json → Tools ▸ Nightclub Arcadia ▸ (CandidateSystemSetup)
Those setup scripts also register a `[DidReloadScripts]` callback, so in practice the assets
regenerate on script reload too. The JSON is the source of truth either way.
### 4.6 Code-owned vs writer-owned
### 4.6 The voice-sheet pipeline
The Skill Bible prose used to live as a `\n`-escaped string inside `skill_bible.json`, which no
writer could comfortably read or revise. It now lives in `writing/voices/*.md`, and truth flows
in opposite directions for the two halves of each sheet:
```
PROSE writing/voices/<id>.md -> skill_bible.json -> Skills/Definitions/*.asset
MACHINE skill_bible.json -> the "Machine fields" table in the .md
```
```bash
python3 tools/writing/sync_voices.py --check # verify; exit 1 on drift
python3 tools/writing/sync_voices.py --to-json # fold edited prose into the bible
python3 tools/writing/sync_voices.py --to-md # refresh the machine table from the bible
```
`tools/writing/voicelib.py` does the parsing and guarantees `notes -> dict -> notes` is identity
for all eleven skills. `--to-json` on unchanged sheets is byte-identical, so it is safe to run
at any time.
`VoiceSheetSyncTests` (EditMode) enforces the same invariant, so drift fails the build instead
of silently shipping a voice that no longer matches the sheet the next scene is written against.
It deliberately does not reimplement the markdown parser in C# — it asserts the shipped prose
appears verbatim in the sheet, which catches drift in either direction with no second parser to
keep in step.
### 4.7 Code-owned vs writer-owned
| Writer-owned (edit freely, no Unity) | Code-owned (needs an engineer) |
|---|---|
| `Assets/Dialogue/**/*.yarn` | `Assets/Scripts/**/*.cs` |
| `<<declare>>` lines in `Common.yarn` | `Assets/Editor/**/*.cs` |
| `Assets/Skills/skill_bible.json` (prose fields) | Yarn command/function *implementations* |
| `writing/**` | `Assets/Scripts/**/*.cs` |
| `Assets/Dialogue/**/*.yarn` | `Assets/Editor/**/*.cs` |
| `<<declare>>` lines in `Common.yarn` | Yarn command/function *implementations* |
| `writing/voices/*.md` (prose → the bible, §4.6) | `Assets/Skills/skill_bible.json` (machine fields) |
| `Assets/Candidates/candidates.json` (`status`, `sceneId`, `notes`, `unlockFlag`) | `.asset`, `.prefab`, `.unity`, `.meta` |
| `docs/` writing docs | `ProjectSettings/**` |
@@ -278,11 +357,19 @@ or a branch.** If a writing task requires either, that is a pipeline bug — fil
`$self_lean_*` / `$found_*` in any `.yarn` file resolves to a real id in `CandidateDatabase`.
It is a tripwire, not a proof.
### 5.2 One-click in-Editor run
### 5.2 Voice-sheet sync
```bash
python3 tools/writing/sync_voices.py --check
```
Seconds, no Unity. Run it after touching `writing/voices/*.md` or `skill_bible.json` (§4.6).
### 5.3 One-click in-Editor run
`Tools ▸ Nightclub Arcadia ▸ Run Skill EditMode Tests` (`Assets/Editor/SkillSystemTestRunner.cs`).
### 5.3 YarnCheck — validate dialogue without Unity ⭐
### 5.4 YarnCheck — validate dialogue without Unity ⭐
`tools/YarnCheck` is a .NET 9 console app that compiles **and plays** the Yarn scripts using
the exact Yarn Spinner DLLs shipped in `Packages/dev.yarnspinner.unity/Runtime/DLLs`, so it
+66
View File
@@ -0,0 +1,66 @@
# Nightclub Arcadia — common commands.
#
# Everything here is documented in CLAUDE.md; this file exists so the exact
# invocations live in one place instead of being retyped from memory.
UNITY_CLI ?= /Users/lennart/.unity/bin/unity
UNITY ?= /Applications/Unity/Hub/Editor/6000.5.8f1/Unity.app/Contents/MacOS/Unity
PROJECT ?= $(CURDIR)/NightclubArcadia
RESULTS ?= $(CURDIR)/.test-results
SMARTMERGE ?= /Applications/Unity/Hub/Editor/6000.5.8f1/Unity.app/Contents/Helpers/UnityYAMLMerge
.DEFAULT_GOAL := help
.PHONY: help check yarn yarn-play voices voices-sync test test-play build lock clean-results merge-driver
help: ## Show this help
@grep -hE '^[a-z-]+:.*?## ' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "} {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
@echo ""
@echo " Unity must be CLOSED for test/build — run 'make lock' to check."
check: yarn voices test ## Everything a change should pass before commit
yarn: ## Compile every Yarn script (seconds, no Unity)
@cd tools/YarnCheck && DOTNET_CLI_TELEMETRY_OPTOUT=1 dotnet run -- ../../NightclubArcadia/Assets/Dialogue
yarn-play: ## Play a node: make yarn-play NODE=Bartender_Talk PICKS="2 2 0 0"
@cd tools/YarnCheck && DOTNET_CLI_TELEMETRY_OPTOUT=1 \
dotnet run -- ../../NightclubArcadia/Assets/Dialogue $(NODE) $(PICKS)
voices: ## Check writing/voices/*.md against skill_bible.json
@python3 tools/writing/sync_voices.py --check
voices-sync: ## Fold edited voice prose into skill_bible.json
@python3 tools/writing/sync_voices.py --to-json
test: lock ## Run the EditMode suite
@mkdir -p $(RESULTS)
@$(UNITY_CLI) test $(PROJECT) --no-banner --mode EditMode \
--output $(RESULTS)/editmode.xml --timeout 900 || true
@python3 tools/ci/report_tests.py $(RESULTS)/editmode.xml
test-play: lock ## Run the PlayMode suite (none exist yet)
@mkdir -p $(RESULTS)
@$(UNITY_CLI) test $(PROJECT) --no-banner --mode PlayMode \
--output $(RESULTS)/playmode.xml --timeout 900 || true
@python3 tools/ci/report_tests.py $(RESULTS)/playmode.xml
build: lock ## Build a macOS player into build/
@$(UNITY_CLI) build $(PROJECT) --no-banner --target StandaloneOSX -o $(CURDIR)/build/NightclubArcadia.app
lock: ## Fail if the Unity Editor has the project open
@if pgrep -fl "Unity.app/Contents/MacOS/Unity" | grep -v AssetImportWorker | grep -q .; then \
echo "The Unity Editor is running — close it first (only one instance per project)."; \
exit 1; \
fi
merge-driver: ## One-time: teach git to merge Unity YAML with UnityYAMLMerge
@test -x "$(SMARTMERGE)" || { echo "UnityYAMLMerge not found at $(SMARTMERGE)"; exit 1; }
@git config merge.unityyamlmerge.name "Unity SmartMerge"
@git config merge.unityyamlmerge.driver "'$(SMARTMERGE)' merge -p --force --fallback none %O %B %A %A"
@git config merge.unityyamlmerge.recursive binary
@echo "configured: $$(git config merge.unityyamlmerge.name)"
clean-results: ## Remove local test results
@rm -rf $(RESULTS)
@@ -1,28 +1,24 @@
// SC-101 — The Card at the Desk. Opening candidate hooks.
//
// Scene files stay thin: set the stage, <<detour>> into hook nodes, return.
// Brief, beats, checks and the reasoning behind every rule below:
// writing/scenes/sc101-the-card-at-the-desk.md
//
// Evidence rule: each candidate gets exactly one of each evidence type —
// object, testimony, document, intuition. Preserve that balance; do not
// stack types so trust becomes an artifact of imbalance.
// See Assets/Candidates/candidates.json.
// Rules that bind while editing THIS file — all six are deliberate, none is a
// leftover. The brief says why; this is the short form so you do not have to
// leave the file to avoid breaking it.
//
// Beat order: hooks fire in sequence from SC101_Desk (STANDING ORDER →
// HOUSE POUR → FACEWORK → CONFLUENCE). Each remains an independently authored
// node. Never <<if>>-guard a hook on another hook having fired, on any
// 1. Scene files stay thin: set the stage, <<detour>> into hook nodes, return.
// 2. Evidence balance — one object / testimony / document / intuition per
// candidate. Never stack types. See Assets/Candidates/candidates.json.
// 3. Beat order is fixed: STANDING ORDER -> HOUSE POUR -> FACEWORK ->
// CONFLUENCE. Never <<if>>-guard a hook on another hook having fired, on any
// $self_lean_*, or on skill_rank(...).
//
// Passive pattern (clue-bearing):
// one voice per hook, from the candidate's attached pair;
// the line is that character's argument, never a report;
// exactly one $self_lean_* write; set $found_<clue_id> alongside it.
//
// Four-voice exception: this opening uses four voice introductions (one per
// pair) against the writing handbook's "cap introductions at three voices"
// rule. Deliberate and for SC-101 only — do not "correct" it down to three,
// and do not copy it as a new precedent elsewhere.
//
// Body stays neutral on purpose: no lean, no candidate attachment.
// 4. Passive pattern for a clue-bearing hook: one voice, from the candidate's
// attached pair; the line is that voice's argument, never a report; exactly
// one $self_lean_* write, with $found_<clue_id> set alongside it.
// 5. Four voice introductions, against the handbook cap of three. Deliberate,
// SC-101 only — one voice per pair. Do not correct it down, do not copy it.
// 6. The body stays neutral: no lean, no candidate attachment outside the hooks.
title: Start
position: -17,-276
@@ -0,0 +1,141 @@
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;
}
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f51db402df844805947fea0f2d43dfbc
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+45 -20
View File
@@ -10,7 +10,7 @@ Companion reading: `docs/skill-system-refactor-plan.md`, `docs/candidate-system-
---
## 0. Status — Phase 0 is complete
## 0. Status — Phases 0 and 2 are complete
Executed 2026-08-25. The working tree is clean and tagged `pre-restructure`.
@@ -19,7 +19,7 @@ the UI layer, player control setup, the dialogue-vs-menu interaction fix, and th
A full pre-flight backup was taken first (`.tar.gz` of the tree excluding `Library/`, `Temp/`,
`Logs/`, and build output) plus a separate copy of the scene file.
Everything below from §1 onward is unchanged proposal. Nothing in §2–§5 has been applied.
Phase 2 (the writing pipeline) is also done — see §4. Phases 1, 3, 4 and 5 remain proposal.
### 0.1 The binary scene — diagnosis corrected
@@ -388,8 +388,8 @@ Effort marks are rough: **S** ≲1h, **M** a half day, **L** a day or more.
| 0.2 | Full backup of the tree + separate copy of the scene | done |
| 0.3 | Commit all uncommitted work in logical slices (§5) | done — 5 commits |
| 0.4 | Convert `DialogueTest.unity` to text | **blocked** — Editor-UI only, see §0.1 |
| 0.5 | Tag `pre-restructure` | done |
| 0.6 | Push `main` and the tag to `origin` | pending |
| 0.5 | Tag `pre-restructure` (on a green tree, 39/39) | done |
| 0.6 | Push `main` and the tag to `origin` | done |
0.4 is the one open item and it does not block Phase 1 or 2.
@@ -405,20 +405,41 @@ Effort marks are rough: **S** ≲1h, **M** a half day, **L** a day or more.
`.gitattributes` before any restructuring is deliberate — the moment work happens on a branch,
scene merges without a merge driver will corrupt files.
### Phase 2 — writing pipeline (no Unity changes, fully parallel to Phase 3+)
### Phase 2 — writing pipeline ✅ DONE (2026-08-25)
| # | Step | Detail | Effort |
|---|---|---|---|
| 2.1 | Create `writing/` skeleton + templates | §2.2, §2.3 | S |
| 2.2 | **Write `writing/STYLE.md`** | The missing handbook. Recover the rules from `.yarn` header comments and the three `docs/` plans. Highest-value single item in this plan. | M |
| 2.3 | Extract `skill_bible.json` `notes` → `writing/voices/*.md` | one-shot script, 11 files | M |
| 2.4 | Write the sync script md → JSON + an EditMode drift test | §2.4 | M |
| 2.5 | Author the three character sheets | Bartender, Bradford Kane, Chevalier Cassian Thal | M |
| 2.6 | Write `writing/README.md` + `YARN-PRIMER.md` | The Cowork session entry point, incl. the YarnCheck caveat (§2.6) | S |
| 2.7 | Move `SC101.yarn`'s header rules into `writing/scenes/sc101-*.md` | leave a one-line pointer in the `.yarn` | S |
| # | Step | Status |
|---|---|---|
| 2.1 | `writing/` skeleton + templates | done |
| 2.2 | `writing/STYLE.md` | done — **digested from the vault handbook, not reconstructed** (below) |
| 2.3 | Extract `skill_bible.json` prose → `writing/voices/*.md` | done — 11 sheets, lossless round-trip |
| 2.4 | Sync script + EditMode drift test | done — `tools/writing/sync_voices.py`, `VoiceSheetSyncTests` |
| 2.5 | Character sheets | done — Bartender, Chevalier Cassian Thal, Bradford Kane |
| 2.6 | `README.md` + `YARN-PRIMER.md` | done, plus `GLOSSARY.md` and `lore/candidates.md` |
| 2.7 | SC-101 header rules → scene brief | done — header trimmed to the six rules that bind while editing, rationale moved to the brief |
Phase 2 touches **no Unity asset at all**. It can proceed while the Editor is open and while
Phase 3 is in flight, and it is what unblocks Cowork. Consider doing it first.
Validated: EditMode **42/42** (three new), YarnCheck compiles 9 files / 35 nodes, sheets in sync.
The drift test was verified to actually fail on drift, not merely to pass.
#### Phase 2 — what changed against the plan
Two things, both discovered during execution.
**The handbook exists.** `docs/skill-system-refactor-plan.md` §2 names it, and it is at
`~/Obsidian-Vaults/obsidian-vault/Projects/Active/NightclubArcadia/` — a 874-line
`[[Mystery Writing Handbook]]`, plus `[[Character Sheet Handbook]]`, `[[Skill System Draft]]`,
`[[Lorebook]]`, `[[Candidate System — Design Explanation]]`, scene specs, and `[[Ground Truth]]`.
So §2.2 became a **digest with pointers** rather than a reconstruction, which is both more
accurate and honest about where the authoring home is. The vault remains that home.
**`[[Ground Truth]]` must never enter this repo.** It is the solution document. The premise of
the game is that the protagonist's identity never resolves, and a copy of the answer in a git
history is exactly how that leaks. This is now stated in `writing/README.md` and `CLAUDE.md` §4.1.
One thing surfaced that is worth acting on independently: `[[000_OpeningSceneTemplate]]` records
SC-101's tone check as **BLOCKED** because the one-page Voice Sheet has never been written — the
scene spec could not honestly certify its own tone field. `writing/STYLE.md` §9 now holds that
sheet with four fields explicitly unset. Filling them is about half an hour of decisions and it
unblocks a check that has been stuck for a while.
### Phase 3 — scene restructure (the risky part)
@@ -494,7 +515,10 @@ That turned out to matter — four Unity runs followed, one ending in a `SIGILL`
Slices rather than one lump, because the restructure will want to revert *part* of this later.
**Still to do:** push `main` and the tag to `origin`, and the Editor-UI serialization fix (§0.1).
6. Fixed two wrong expectations in the locomotion math tests (both test bugs, not
production bugs); EditMode is **39/39**. Moved the tag onto that green tree and pushed.
**Still to do:** the Editor-UI serialization fix (§0.1). That is the only open Phase 0 item.
### 5.1 Residual risks after checkpointing
@@ -527,9 +551,10 @@ the world every session.
## 7. Open questions
1. **Does a writing handbook exist outside the repo?** `docs/candidate-system-implementation-plan.md`
says it exists but is not checked in. If there is a document somewhere, importing it beats
reconstructing it (Phase 2.2 assumes reconstruction).
1. ~~Does a writing handbook exist outside the repo?~~ **Answered — yes** (Phase 2 notes). It is in the
Obsidian vault and is now pointed at rather than duplicated. The open part is whether any of
it should eventually move into the repo wholesale; the current answer is no, because the vault
is reachable from writing sessions and duplication would drift.
2. ~~How did the scene become binary?~~ **Answered** (§0.1): Force Text only converts when the
setting changes, so anything that entered binary stays binary. The open part is *how* it
first entered binary — most likely created or imported during a Force Binary window. Adding a
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""Summarise an NUnit results file and exit non-zero if anything failed.
Unity's own docs state there is no common exit-code definition across the
components under test, so the results XML is the authority, not $?.
"""
import sys
import xml.etree.ElementTree as ET
def main(path):
try:
root = ET.parse(path).getroot()
except FileNotFoundError:
print(f"no results at {path} — the run did not get far enough to write one")
return 1
except ET.ParseError as exc:
print(f"could not parse {path}: {exc}")
return 1
total = int(root.get("total") or 0)
passed = int(root.get("passed") or 0)
failed = int(root.get("failed") or 0)
skipped = int(root.get("skipped") or 0)
for case in root.iter("test-case"):
if case.get("result") == "Passed":
continue
print(f"FAIL {case.get('fullname')}")
message = case.find("failure/message")
if message is not None and message.text:
for line in message.text.strip().splitlines()[:6]:
print(f" {line}")
if total == 0:
print("no tests ran")
else:
print(f"{passed}/{total} passed" + (f", {skipped} skipped" if skipped else ""))
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else ".test-results/editmode.xml"))
+277
View File
@@ -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()
+120
View File
@@ -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
+55
View File
@@ -0,0 +1,55 @@
# Glossary
In-world and project terms that come up while writing. World history, factions and the timeline
live in the Obsidian vault — `[[Lorebook]]`, `[[Timeline notes]]`,
`[[Divergence Point Analysis]]` — and this file deliberately does not duplicate them. It covers
the vocabulary you need to read a `.yarn` file and a design note without stopping.
## The project's own terms
**Voice** — one of the eleven skills. A character in the protagonist's head, not a stat. See `voices/`.
**Candidate** — one of three live readings of who the protagonist is: the Made Asset, the
Journalist, the Inheritor. They are not mutually exclusive and **never resolve**.
**Lean** — `$self_lean_<candidate>`. A counter of how far play has tilted toward one reading.
Tracks *belief*, not truth, which is why a wrong deduction still moves it.
**Clue** — a registry entry in `Assets/Candidates/candidates.json`. Each candidate has exactly
four: one object, one testimony, one document, one intuition.
**Trace channel** — where a clue physically comes from: physical, testimonial, behavioural,
documentary. Used to keep acquisition routes genuinely independent.
**Wrong Truth** — a failed check that yields a confident, coherent, false conclusion, stated
with no hedge in the prose. The workhorse failure of this game.
**Colour check / gate check** — a check written for texture versus one that controls access to
a route. 80–90% should be colour.
**Bottleneck** — a point every playthrough must pass. Clues required to pass one need multiple
independent routes.
**Detour / jump** — Yarn's two ways to move between nodes; the first returns, the second does not.
**Node group** — several nodes sharing a `title:`, with `when:` conditions selecting between them.
**Thread 2 / Thread 9** — two lore threads designed never to resolve. Do not map candidate
material onto them.
**Fugue** — the protagonist's amnesia. Dissociative, and specifically about their own identity
rather than an external event. The indeterminacy is the design, not a puzzle with a hidden answer.
## The cast so far
| Name | Where | Sheet |
|---|---|---|
| The Bartender | the bar, SC-101 | [`characters/bartender.md`](characters/bartender.md) |
| Chevalier Cassian Thal | the table, SC-101 | [`characters/chevalier-cassian-thal.md`](characters/chevalier-cassian-thal.md) |
| Bradford Kane | the table, SC-101 | [`characters/bradford-kane.md`](characters/bradford-kane.md) |
| Mr. Doss | referenced only, never seen | — |
## Scene ids
`SC101` — The Card at the Desk. The opening; playable end to end.
`SC102` — The figure at the head of the table stands up. A one-line stub.
+96
View File
@@ -0,0 +1,96 @@
# Writing — start here
This directory is the writing surface for **Nightclub Arcadia**. Everything a session needs
to write or revise dialogue lives here or in `Assets/Dialogue/`. You should not need to open
Unity, edit C#, or touch a `.asset` file to write a scene, a character, or a branch. If a
writing task seems to require any of those, that is a pipeline bug — say so rather than
working around it.
## Read in this order
| # | File | What it gives you |
|---|---|---|
| 1 | [`STYLE.md`](STYLE.md) | The register, the five tone moves, the ban list, the rules that govern branching. **Re-read before every session.** |
| 2 | [`YARN-PRIMER.md`](YARN-PRIMER.md) | The one page of Yarn syntax you actually need. |
| 3 | [`voices/`](voices/) | The eleven skill voices, one sheet each. Who they are and how they talk. |
| 4 | [`characters/`](characters/) | The named NPCs. |
| 5 | [`scenes/`](scenes/) | Per-scene briefs — intent, beats, constraints. Not the dialogue. |
| 6 | [`GLOSSARY.md`](GLOSSARY.md) | In-world terms, and where the deep lore lives. |
## Where the words actually go
Playable dialogue lives in **`NightclubArcadia/Assets/Dialogue/`**, not here:
```
Assets/Dialogue/
├── Common.yarn every <<declare>>, project-wide. Never played. Add new flags here.
├── Commentary.yarn passive skill barks, one node per (context, voice)
├── Scenes/ SC101, SC102, Debug
├── Characters/ Bartender, Bradford Kane, Chevalier Cassian Thal
└── Objects/ Chair
```
A new `.yarn` file anywhere under `Assets/Dialogue/` is picked up automatically — the Yarn
project globs `**/*.yarn`. **Adding a scene or a character needs no registration step.**
This directory holds the *context*; `Assets/Dialogue/` holds the *content*. Keep it that way:
never paste dialogue into a sheet here, and never paste a character bio into a `.yarn` file.
## Check your work before handing it back
From the repo root:
```bash
cd tools/YarnCheck && dotnet run -- ../../NightclubArcadia/Assets/Dialogue
```
That compiles every Yarn file and reports errors with line numbers. It needs only the .NET 9
SDK — no Unity, no licence, no Editor. Run it after any dialogue change.
You can also *play* a node from the terminal, choosing options by number:
```bash
dotnet run -- ../../NightclubArcadia/Assets/Dialogue Bartender_Talk 2 2 0 0
```
Leftover picks restart the node, which models the player walking away and coming back.
> **One thing that will confuse you.** YarnCheck stubs the engine's commands, so `<<check>>`
> never runs and `$check_result` is always `false`. **Every check-gated branch will show you
> its failure side.** That is the harness, not a bug in your writing. To read the success
> prose, read the file.
If you edited a voice sheet in `voices/`, also run:
```bash
python3 tools/writing/sync_voices.py --check
```
## What is yours, and what is not
**Yours** — edit freely: everything in `writing/`, every `.yarn` file, the `<<declare>>` lines
in `Common.yarn`, and the prose fields of `Assets/Candidates/candidates.json`.
**Not yours** — ask an engineer: any `.cs` file, anything under `Assets/Editor/`, any
`.asset` / `.prefab` / `.unity` / `.meta` file, and `ProjectSettings/`. The *implementations*
of Yarn commands are code; *using* them is writing.
## The deeper design material
The full design documents live in Obsidian, not in this repo, and remain the authoring home
for design work:
`~/Obsidian-Vaults/obsidian-vault/Projects/Active/NightclubArcadia/`
- `[[Mystery Writing Handbook]]` — the full reference. `STYLE.md` is a dialogue-facing digest of it.
- `[[Skill System Draft]]` — the Skill Bible. `voices/` is generated from the shipped subset.
- `[[Candidate System — Design Explanation]]` — the three identity readings.
- `[[Lorebook]]`, `[[Timeline notes]]`, `[[Divergence Point Analysis]]` — world and history.
- `[[Character Sheet Handbook]]` — the full character schema. `characters/_TEMPLATE.md` is its
dialogue-facing subset.
> **`[[Ground Truth]]` is the solution document. Do not copy it, quote it, or summarise it
> into this repo — not into a sheet, not into a comment, not into a commit message.** The
> game's central premise is that the protagonist's identity never resolves; a copy of the
> answer sitting in a git history is how that leaks. If you need to know whether something
> contradicts the solution, ask, and keep the answer out of the files.
+271
View File
@@ -0,0 +1,271 @@
# Style — the register and the rules
Re-read this before every writing session. It is the anchor against drift, which is the
characteristic failure of a solo writer working across a year.
This is a **dialogue-facing digest**. The full argument for each rule is in
`[[Mystery Writing Handbook]]` in the Obsidian vault; section numbers below point into it.
Where this file and the handbook disagree, the handbook wins — and this file should be fixed.
---
## 1. The register, in one paragraph
Second person, present tense, unreliable. The narration is a committee — eleven voices with
opinions, some of them wrong — and the player is inside the argument rather than being told
its result. Concrete over abstract, always. Roughly seventy percent plain, sincere prose, so
that the flourishes land when they come. Sentimental underneath the cynicism; that is what
makes the cynicism bearable.
## 2. The five tone moves (§6.4)
Learn the moves, use your own words. Do not imitate Disco Elysium's prose — imitate its method.
1. **Concrete over abstract.** Never "the room felt neglected." Name the object that proves it.
The failure mode of introspective writing is abstraction; the fix is always a physical particular.
2. **Bathos with a return trip.** Rise to something genuinely grand, puncture it with the mundane —
*then come back and mean it anyway*. The puncture alone is just irony, and irony alone is cheap.
The return is what makes it humane. This is the most-imitated and least-understood DE move.
3. **Mock, then love.** Every character gets ridiculed by some voice in your head, and every
character then gets one moment where the ridicule is revealed as inadequate to them. No
character exists only as a joke.
4. **The world speaks through failure.** Broken things describe the society that broke them.
Describe what the rent did to the stairwell instead of explaining the political economy.
5. **Second person, present tense, unreliable.** "You" and "now" produce intimacy and complicity.
### The ban list
- **Quirk with no cost.** A voice that is only funny is decoration.
- **Cynicism with no warmth.**
- **Everything is a bit.** Straight, plain, sincere sentences are what make the flourishes work.
- **Trauma as texture.** If suffering is described, someone in the text takes it seriously.
- **Explaining the joke, or the theme.** If the thematic statement appears verbatim in dialogue, cut it.
### Length discipline
| Kind of line | Length |
|---|---|
| Passive skill line | 1–3 sentences |
| Success text | 2–5 sentences |
| Opening frame | 2–4 sentences |
If you are writing a paragraph, you are writing an essay, and the player will skim it — which
trains them to skim everything else. Long passages are earned at high skill ranks, nowhere else.
---
## 3. How voices behave
A skill is a **character**, not a stat. It wants something for you, and it is wrong about
something. Both halves matter: the wrongness is what makes it a voice rather than a hint system.
- **Voices advocate; they do not report.** A line is that voice's own biased reading, even when
it happens to be right. No voice ever states a candidate identity as settled fact.
- **Respect the forbidden domains.** Each sheet in `voices/` ends with "domains it must not
comment on." PROVENANCE does not discuss appetite. This is characterisation, not bookkeeping.
- **Opposed pairs are a writing engine.** Fire both halves of a pair with contradictory readings
and you get friction for free. You never have to invent conflict; the cast contains it.
- **Cap introductions at three voices per scene.** An opening is many players' first contact with
the system, and fewer, stronger first impressions beat a crowded debut.
*(From `[[000_OpeningSceneTemplate]]` §3. SC-101 deliberately breaks this with four — one per
pair, because each candidate needs its intuition channel present from the start. That is a
logged, scene-specific exception. Do not copy it as precedent, and do not "correct" SC-101 down
to three.)*
- **Passives carry the personality.** Roughly 60–70% of all skill text should be passive. They
cost the player nothing and are the main delivery system for character.
---
## 4. Checks (§4.4)
Two kinds, and knowing which you are writing is the whole discipline:
| | **Colour check** | **Gate check** |
|---|---|---|
| Purpose | Voice, texture, insight | Controls access to a clue or route |
| Share of all checks | 80–90% | 10–20% |
| Failure produces | Different *content* | Different *route* |
| Redundancy required | None | Always ≥2 independent routes |
> **A failed gate never removes content. It replaces one route with another.**
**The two-outcome test — apply before writing any check.** Write the one-line summary of the
success and the one-line summary of the failure. If the failure summary is *"the player doesn't
get the thing"*, delete the check.
Other rules that bind:
- **Never roll for what a competent detective would just do.** If failing is boring, don't roll.
Roll only where *both* outcomes are content you want to write.
- **Rate-limit active checks** to roughly one per 3–5 minutes of play. Prose rhythm dies under
constant dice.
- **Use the band names,** not raw numbers: `Trivial` 8, `Routine` 11, `Hard` 14, `Specialist` 17,
`BuildDefining` 20. No in-between values.
### The five productive failures (§4.5)
Never write a failure that means "nothing happens." Every failed check produces one of:
| Type | What happens | Best for |
|---|---|---|
| **Wrong Truth** | A confident, coherent, *false* conclusion | Reason/perception checks |
| **Cost** | You get it, and pay — reputation, a relationship, a secret, time | Social checks |
| **Detour** | This route closes, an uglier one opens | Gate checks |
| **Character** | You learn nothing about the case and something about yourself | Self checks |
| **Delayed** | Noted now, resurfaces later at a worse moment | Act transitions |
Rough distribution across the game: 30% Wrong Truth, 25% Cost, 20% Detour, 15% Character,
10% Delayed. Wrong Truth is the workhorse of a mystery and is badly underused in most games.
### The Wrong Truth rule — the one that is easiest to get wrong
**A voice that fails does not signal that it failed.** It states its wrong conclusion with
exactly the same confidence, cadence and structure as a correct one. The player learns it was
wrong through contradiction, later.
The honest version of the trick: the *roll* is visibly failed in the UI. The player knows a
roll failed; they do not know which part of what follows is contaminated. **The UI banner is
the only tell. Never put a hedge, a wobble, or a wink in the prose.**
Compare the two PROVENANCE samples in `voices/provenance.md` — same cadence, same calm, one
unearned verb. That is the target.
---
## 5. Branching shapes (§2.7)
Three shapes. Default to the first.
**A. Hub-and-spoke — ~80% of conversations.** Topics are spokes that return to a hub.
Reconvergence is structural, so you get it for free, and cost is linear in topics rather than
exponential. `Chair.yarn` is the worked example in this project.
**B. Short branching tree — ~15%, for confrontations.** Depth ≤ 3. Always reconverge inside the
scene. Track the outcome flag, not the path.
**C. True divergence — 3–5 moments in the entire game.** Endings, the culprit confrontation, one
or two irreversible choices. Budget these explicitly.
**The 3-deep cap.** No conditional nested more than three levels. If you need four, you have a
state that should be a flag rather than a path.
### Node naming
Existing conventions in this project, which new content should match:
| Pattern | Used for |
|---|---|
| `<Character>_Talk` | a character's entry node (`Bartender_Talk`) |
| `SC###_Hook_<Subject>` | a scene beat or sub-conversation |
| `Commentary_<Context>_<voice_id>` | a passive bark |
---
## 6. Flags and variables
**Every variable must be `<<declare>>`d in `Common.yarn`.** That is what turns `$reputaton`
into a compile error instead of a silently-created second variable. Adding a flag is a writing
action — you do not need an engineer.
Families that are load-bearing, two of them lint-enforced against the candidate registry:
| Family | Meaning | Enforced |
|---|---|---|
| `$self_lean_<candidate_id>` | identity lean | yes — id must exist in `candidates.json` |
| `$found_<clue_id>` | clue coverage | yes — id must exist in `candidates.json` |
| `$visited_<location_id>` | first arrival at a location | convention |
| `$errand_<errand_id>` | errand reached a state that matters | convention |
| `$seen_<scene_id>` | scene witnessed | convention |
| `$spoke_to_<character>` | first conversation happened | convention |
| `$check_*` | written by `<<check>>` | **never `<<set>>` by hand** |
### The candidate rules — these are hard constraints
The protagonist's identity has three live readings and **never resolves**. Three integers track
belief, not truth.
1. **Never compare one lean against another.** Not in Yarn, not in prose, not in a debug line.
Comparing a lean to a *constant* is fine and intended. `$self_lean_a > $self_lean_b` must
not exist anywhere.
2. **No ground-truth field.** Nothing named `isTrue`, `actualIdentity`, `canonical`, or `weight`.
3. **Clue availability is exploration-gated, never skill-gated.** An `unlockFlag` names a
`$visited_*` or `$errand_*` variable. A clue-bearing node never sits behind a `skill_rank(` guard.
4. **Evidence balance.** Each candidate carries exactly one object, one testimony, one document,
and one intuition clue. Do not stack types, or trust becomes an artefact of imbalance.
5. **Lean is belief, so a Wrong Truth still writes lean.** Being wrong moves the needle. That is
the point.
### Two threads that never resolve
The project has two permanently unresolved lore threads, referred to as **Thread 2** and
**Thread 9**. Candidate material must stay structurally separate from them: do not resolve
them, do not map a candidate clue onto them. If you find yourself needing to know what they
contain in order to write a line, you are writing the wrong line.
---
## 7. Flow control
`<<detour>>` returns to the caller. `<<jump>>` does not, and clears the return stack.
- A node started directly by an interactable has nothing waiting on it, so its branches
`<<jump>>`, and each destination calls `<<enter_environment>>` itself.
- A sub-conversation that must resume its caller uses `<<detour>>` — and **never** `<<jump>>`
inside it, which would strand the caller forever.
- A menu that loops uses `<<jump>>` back to its own hub, which replaces the current node and
pushes nothing.
Nodes sharing a `title:` form a **node group**; Yarn runs the most specific variation whose
`when:` conditions pass. Prefer adding a variation over growing an `<<if>>` chain.
---
## 8. Writing order for a scene (§5.5)
1. Fill the scene brief header — purpose and critical outputs. **Stop.** Confirm the clue routes
are registered in `Assets/Candidates/candidates.json`.
2. Write the beats as one-line summaries.
3. Write the checks table — *both* outcomes as summaries, before any prose.
4. Now write prose, in this order: opening frame → success texts → failure texts → passives → barks.
5. Update clue `status` in `candidates.json`.
Do not implement in engine until the act is drafted. Implementing mid-draft is the biggest time
sink in solo narrative work, because every structural revision then costs double.
---
## 9. The voice sheet
Five sentences to be proud of, five to cut, three adjectives, one sentence on what the game
feels about its world. This is the anchor §6.5 asks for and it is cheaper than re-reading your
own draft.
**Three adjectives for the register:** *(unset — decide these and stop leaving the field open)*
**What the game feels about its world:** *(unset)*
**Five sentences to be proud of** — provisional, taken from shipped SC-101 and the Skill Bible,
to be replaced with chosen ones:
> The mahogany doors are too heavy for the hinges someone recently, badly, tightened — you feel
> the extra half-second of resistance before either side gives.
> Someone left a card with your name on it at the registration desk three hours before you
> decided to come. You don't remember deciding.
> Cigar smoke, spilled aperitif, and under it — cold, sharp — the ammonia bite of a floor mopped
> in a hurry, love. Someone's trying to make this room smell like money. It smells like panic
> with good cologne on top.
> A line that begins nowhere is a line somebody drew.
> Her hands are wrong for that coat. It's somebody's good coat, and it isn't hers.
**Five to cut:** *(unset — write these next session; the cut list is the more useful half,
because it names the specific temptations this project keeps giving in to)*
> This section is deliberately incomplete. `[[NightclubArcadia]]` has carried "Write the one-page
> Voice Sheet" as an open action for a while, and `[[000_OpeningSceneTemplate]]` records tone
> checks as **BLOCKED** on it — the SC-101 spec could not honestly certify its own tone field.
> Filling the four unset fields above unblocks that. It is a half-hour of decisions, not a
> writing project.
+149
View File
@@ -0,0 +1,149 @@
# Yarn, in one page
Everything below is used somewhere in `Assets/Dialogue/`. Nothing else is needed to write a
scene. Full language docs: <https://docs.yarnspinner.dev>.
## A file
```
// Comments start with two slashes. Use them — this project keeps its design
// reasoning in file headers, and that is the record of why things are the way
// they are. Do not strip them.
title: Bartender_Talk
---
Bartender: Ah, {$player_alias}, back again?
Narrator: What does he mean? "Back again?"
===
```
`title:` names the node. `---` ends the header, `===` ends the node. One file can hold many
nodes. A `position:` line may appear in the header — that is the graph editor's coordinates,
not something you write by hand.
## Who is speaking
The text before the first colon is the speaker.
```
Narrator: The mahogany doors are too heavy for the hinges.
Bartender: What can I get ya?
PROVENANCE: Nineteen sixty-nine, the licence.
```
Skill voices are spoken in **UPPERCASE**, exactly as they appear in `voices/`. `Narrator:` is
the room and the body. A named character uses their display name.
## Player choices
```
-> Back? I have never been here.
<<set $revealed_fugue_to_bartender = true>>
Narrator: The bartender squints with a critical look.
-> Yes. I'll take the usual.
Narrator: He doesn't even flinch.
```
Indentation is what nests the consequence under the option. Options are presented together;
whichever is chosen runs its indented block.
## Conditions
```
<<if $spoke_to_bartender>>
Bartender: Ah, still hazy? A drink?
<<elseif $reputation > 10>>
Bartender: On the house.
<<else>>
Bartender: And you are?
<<endif>>
```
Comparison words also work: `eq`, `neq`, `gt`, `lt`, `gte`, `lte`, `and`, `or`, `not`.
## Variables
Every variable must first be declared in `Common.yarn`:
```
<<declare $spoke_to_bartender = false as bool>>
<<declare $reputation = 0 as number>>
<<declare $player_name = "Vesper" as string>>
```
Then set and read it anywhere:
```
<<set $spoke_to_bartender = true>>
<<set $self_lean_made_asset += 1>>
Bartender: Ah, {$player_alias}.
```
`{$variable}` interpolates into a line. **If you skip the `<<declare>>`, the compiler cannot
catch your typos** — that is the entire reason `Common.yarn` exists.
## Moving between nodes
```
<<detour Chevalier_Cassian_Thal_Talk>> // goes there, comes BACK here
<<jump SC101_Hook_Menu_Chair_Interaction>> // goes there, never returns
<<stop>> // ends the conversation immediately
```
Getting this wrong is the most common structural bug in this project. See `STYLE.md` §7 —
and never `<<jump>>` out of a node you were `<<detour>>`ed into.
## Node groups — variations without an if-chain
Several nodes may share one `title:`. Yarn runs the most specific variation whose `when:`
conditions pass:
```
title: Bartender_Talk
when: $reputation > 10
---
Bartender: Your money's no good here.
===
```
Callers just `<<detour Bartender_Talk>>` and do not care which variation ran. Adding a new
situation later means adding a node, not editing a growing `<<if>>`.
## The commands this game adds
| Syntax | What it does |
|---|---|
| `<<check <voice> <Band>>>` | Roll. Bands: `Trivial` `Routine` `Hard` `Specialist` `BuildDefining`. Writes `$check_result` and the other `$check_*` variables. |
| `<<skill_mod <source> <voice> <n>>>` | Temporary modifier. `source` is a bucket key, e.g. `scene`, `drunk`. |
| `<<clear_skill_mods <source>>>` | Drops a bucket. |
| `<<enter_environment>>` | Hands control back to the world. Ends the dialogue cleanly. |
And two functions:
| Syntax | What it returns |
|---|---|
| `skill_rank(<voice>)` | the player's rank in that voice |
| `top_skill(a, b, …)` | the highest of the named voices — **returns its FIRST argument on a tie**, so argument order is a design decision |
A check in practice:
```
<<check provenance Hard>>
<<if $check_result>>
PROVENANCE: Basel? Makes total sense.
<<else>>
PROVENANCE: This is probably related to Switzerland.
<<endif>>
```
Note that the failure branch is written with the same confidence as the success branch. That is
the Wrong Truth rule, and it is not optional — `STYLE.md` §4.
## Checking your work
```bash
cd tools/YarnCheck && dotnet run -- ../../NightclubArcadia/Assets/Dialogue
```
Remember that this harness never runs `<<check>>`, so `$check_result` is always `false` and you
will only ever see failure branches when playing through it.
+63
View File
@@ -0,0 +1,63 @@
---
id: <kebab-case-id>
name: <exact string used as the Yarn speaker prefix>
chr: CHR-### # the vault sheet this projects from, if one exists
yarn_file: Assets/Dialogue/Characters/<File>.yarn
status: planned # planned | drafted | in-engine | tested
scenes: [SC101]
---
# <Name>
> **This is the dialogue-facing sheet.** The full character record — placement in the Lorebook,
> faction row, the two truths, sensitivity check, continuity hooks — lives in the vault at
> `[[Character Sheet Handbook]]`, one file per character. Fill that one *first* for any figure
> who touches real history or a faction. This sheet exists so a writing session can pick the
> character up and write them without loading all of it.
## Who they are
One paragraph. What they want *tonight*, in this room — not their life goal.
## How they speak
Register in three adjectives. Sentence length. Do they contract? Do they use the player's name,
and which form — `{$player_name}`, `{$player_name_full}`, or `{$player_alias}`? Anything a
reader could imitate.
## Verbal tics
At most two. More than two is a costume, not a voice.
## What they never say or do
The negative constraints. Usually more useful than the positive ones.
## What they know
One row per thing the player can learn from them. "Reveals when" names the actual condition;
"records" names the flag that remembers it.
| They know | True? | Reveals when | Records |
|---|---|---|---|
| | | | |
## What they are wrong about
Where their confidence outruns their information. This is what makes them a source of Wrong
Truths rather than an oracle.
## Their read on the protagonist
Which candidate reading, if any, they push — and what happens if the player agrees or refuses.
Remember they advocate; they never state an identity as settled fact.
## One line that is 100% them
Written now, before a scene needs it.
> "..."
## Open threads
What is unwritten, unresolved, or deliberately left open. Link the vault note if there is one.
+79
View File
@@ -0,0 +1,79 @@
---
id: bartender
name: Bartender
chr: —
yarn_file: Assets/Dialogue/Characters/Bartender.yarn
status: drafted
scenes: [SC101]
---
# The Bartender
## Who they are
He is behind the bar at a conference that is pretending to be a party, and he has been behind
it long enough to have opinions he will not offer. He recognises the protagonist. He is certain
he has served them before, with Mr. Doss, a few days ago — and he says so casually, the way you
mention the weather, without any idea that he has just detonated something. What he wants
tonight is an ordinary shift. He is not going to get one.
FACEWORK reads him as a watcher: a man who tends the bar *and* observes, and reports back. That
reading is FACEWORK's, and FACEWORK advocates rather than reports — it may well be wrong. Do
not resolve it in his prose.
## How they speak
Warm, clipped, incurious on the surface. Short sentences. Contracts freely — "What can I get
ya?" He uses `{$player_alias}`, the club name, never the real one. That detail is load-bearing:
he knows the protagonist by a name the protagonist does not remember choosing.
## Verbal tics
1. Opens with "Ah," — recognition offered before it is earned.
2. Names the drink rather than the person: "One Estate coming right up."
## What they never say or do
He never asks why the protagonist does not remember. He notices the strangeness, files it, and
pours. He never raises the matter of Doss himself — he answers about him, and only when asked.
## What they know
| They know | True? | Reveals when | Records |
|---|---|---|---|
| The protagonist has been here before | believed, unverified | first conversation, unprompted | `$spoke_to_bartender` |
| They were with Mr. Doss "a few days ago" | believed, unverified | if the player denies having been here | `$looking_for_doss` |
| Their usual drink is the Estate | believed | on ordering, or "what can you recommend" | `$had_the_estate_drink` |
## What they are wrong about
Possibly nothing. Possibly the entire identification. He is confident and casual, which is
exactly the register a Wrong Truth wears — except this one is his, not a voice's. Whether the
protagonist was ever here is not settled by him saying so, and the writing must not let his
certainty function as proof.
## Their read on the protagonist
He pushes the **Made Asset** reading without meaning to — someone known here, under a name they
do not remember, in company they cannot account for. Revealing the fugue to him sets
`$revealed_fugue_to_bartender` and adds to `$self_lean_made_asset`. Claiming "the usual" instead
adds to `$self_lean_inheritor` — a person with a standing order is a person with a past here.
Note the drink beat writes lean in **both directions**: agreeing the Estate tastes familiar adds
to the Inheritor lean, denying it subtracts. It is the one place a player can actively push a
lean down, and it only counts on the first glass.
## One line that is 100% them
> "I could have sworn you are {$player_alias}, weren't you with Mr. Doss a few days ago?"
## Open threads
`SC101_Hook_Questions_Bartender_Talk` is **placeholder text** — literally "Question 1 / Answer
1". It is a working hub-and-spoke loop with nothing in it. This is the single largest piece of
unwritten dialogue in the shipped scene, and the natural home for testimony-channel clues,
which the candidate registry currently lists as `Planned` for all three candidates.
Mr. Doss never appears. He is referenced by the Bartender and by Chevalier Cassian Thal, who
calls him "your friend" — two independent sources treating the protagonist's connection to him
as established fact.
+70
View File
@@ -0,0 +1,70 @@
---
id: bradford-kane
name: Bradford Kane
chr: —
yarn_file: Assets/Dialogue/Characters/Bradford Kane.yarn
status: planned
scenes: [SC101]
---
# Bradford Kane
> **Effectively unwritten.** `Bradford_Kane_Talk` contains one line — `<<set
> $spoke_to_bradford_kane = true>>` — and nothing else. The chair menu already offers him as an
> option in every variation, so a player can introduce themselves to him and receive silence.
> This is the most visible gap in SC-101.
## Who they are
*Unwritten.* What exists: he is the gentleman on the protagonist's **right** at the table, the
one seated further from the head than Chevalier Cassian Thal. That is the whole of it.
The structural role is available and worth taking deliberately: the chair scene currently has
one talker and one silence, and the menu variations (`Left_Not_Right`, `Right_Not_Left`, `Both`)
are already built to track both conversations independently. The architecture is waiting for him.
## How they speak
*Unwritten.* One constraint worth honouring: he should not be a second Chevalier. Cassian Thal
is courteous, archaic and institutional. If Kane is also old money at ease, the table has one
voice twice. The name reads American against Thal's continental title — that contrast is free
and probably should be used.
## Verbal tics
*Unwritten.* Maximum two.
## What they never say or do
*Unwritten.*
## What they know
| They know | True? | Reveals when | Records |
|---|---|---|---|
| — | — | — | `$spoke_to_bradford_kane` (set, currently unused by any branch) |
The candidate registry lists **testimony** clues as `Planned` for all three candidates, with no
`sceneId`. Kane is the obvious carrier for at least one: he is already placed, already
reachable, and already has a flag recording that the player met him.
## What they are wrong about
*Unwritten.*
## Their read on the protagonist
*Unwritten.* Note the design constraint before choosing one: Chevalier Cassian Thal already
implements the "mirror the leading lean back at the player" mechanic. Kane should not repeat it.
A contrast would be stronger — a figure who reads the protagonist as one specific thing and
does not update, or one who declines to guess at all.
## One line that is 100% them
*Unwritten. Write this first — it is the cheapest way to find out who he is.*
## Open threads
Everything. Before drafting, fill the vault character sheet (`[[Character Sheet Handbook]]`) —
§1 placement and §2 the two truths — because he does not yet have a faction row, and writing him
before that is exactly the failure mode that document exists to prevent.
@@ -0,0 +1,88 @@
---
id: chevalier-cassian-thal
name: Chevalier Cassian Thal
chr: —
yarn_file: Assets/Dialogue/Characters/Chevalier Cassian Thal.yarn
status: drafted
scenes: [SC101]
---
# Chevalier Cassian Thal
## Who they are
A knight, of Basel and Cyprus, seated at the table nearer the head than the protagonist is. He
introduces himself with a bow and a half-open fist to the forehead — an old form, performed
without irony. Old money, and comfortable in it. He is here as part of something ("the Knight's
compromise", "our friends of the Vaselian Concordat") and he speaks about it as though everyone
at the table already knows, because in his world everyone does.
He is the scene's mirror: whatever the protagonist has been leaning toward, he names it back at
them, gently, and waits. He is rarely wrong in matters like these, as he will tell you.
## How they speak
Courteous, archaic, warm. Slightly too familiar too quickly — "old chap", "my boy" — which reads
as class rather than affection. He uses `{$player_name_full}`, the formal form, when he is being
precise. He laughs easily and often, including at things that are not jokes.
## Verbal tics
1. Slips into the institutional "we" and "us" without noticing, then does not correct it.
2. Deflects with courtesy rather than refusal — "I am of no right to reveal this."
## What they never say or do
He never threatens and never withholds coldly; every evasion is dressed as politeness. He does
not name the Concordat's business outright, and he never breaks the fiction that the protagonist
already belongs at this table.
## What they know
| They know | True? | Reveals when | Records |
|---|---|---|---|
| He is of Basel and Cyprus | true | on introduction | `$spoke_to_chevalier_cassian_thal` |
| Cyprus connects to something he won't name | true | `<<check provenance Hard>>` succeeds, then the player inquires | `$inquired_about_cyprus` |
| Mr. Doss can explain the Cyprus connection | believed | on inquiring about Cyprus | `$looking_for_doss` |
| Something is about to happen at the table | true | after the protagonist declares a reading | `$declared_candidate_to_chevalier_cassian_thal` |
## What they are wrong about
He is certain he knows what the protagonist is. He is working from whatever the protagonist has
been projecting — which is to say from the lean, which is belief rather than truth. His
confidence is sincere and is not evidence. When he says "I am rarely wrong in matters like
these", that is characterisation, not a hint from the author.
## Their read on the protagonist
He is the game's first mirror mechanic. `SC101_Asks_Candidate_Chevalier_Cassian_Thal_Talk`
computes `top_skill(...)` over the three leans and asks the matching question:
| Leading reading | What he asks |
|---|---|
| Made Asset | whether they observe on behalf of the Vaselian Concordat |
| Journalist | whether he will read his answers in tomorrow's paper |
| Inheritor | whether they are here to settle a parent's debts |
Agreeing reinforces that lean. Denying adds to *both other* leans — a refusal is not neutral, it
is a push toward the alternatives. Denial routes to `SC101_Deny_Candidate`, which recomputes the
winner and, if it has not changed, offers the player the chance to reverse; if it has changed,
he simply asks the new question. The player can be walked around this loop.
`made_asset` is passed first to `top_skill`, which returns its first argument on a tie — so an
even score reads as Made Asset. That bias is deliberate.
## One line that is 100% them
> "Well, then let us hope the ... others follow the Knight's compromise."
## Open threads
The Atlantean cross on the place card (`$saw_knights_atlantean_cross_on_place_card`, found via a
`Hard` PROVENANCE check) is never connected to him in dialogue, though he is a knight and the
symbol is a knight's. That connection is available and unwritten.
His faction, the Vaselian Concordat, and "the Knight's compromise" are named but not explained.
Before writing more of him, confirm his faction row against `[[Lorebook]]` §2 — the vault's
`[[Character Sheet Handbook]]` exists specifically because figures drifting from the faction
tables is a logged, repeated failure in this project.
+81
View File
@@ -0,0 +1,81 @@
# The three readings
The protagonist's amnesia is a dissociative fugue, and what is walled off is their own identity
rather than an external event.
**The design is ontological, not epistemic.** There is no single hidden fact of the matter that
the game is withholding and will eventually reveal. The three readings below can overlap and
partially coexist. The player "discovers" an identity by choosing which evidence to trust, and
lean accumulates from play rather than from one declare-your-identity choice at the end.
This is the constraint most likely to be broken by accident, because almost every instinct a
mystery writer has runs the other way. There is no answer. Do not write toward one.
## The candidates
Registry: `Assets/Candidates/candidates.json`. Adding a fourth means a JSON entry, one
`<<declare>>`, and content — never editing a list of three in code.
### The Made Asset — `made_asset`, Reason axis
> The PC was trained, conditioned, or activated by one of the factions for a role near the
> central conference.
Its intuition is fluency: parsing something coded or ritual *before* consciously trying to.
CONFLUENCE carries it.
### The Journalist — `journalist`, Social axis
> The PC came for a specific person, not a story; "journalist" is a cover identity.
Its intuition is a reaction that lands wrong and cannot yet be explained — a flinch that is not
a stranger's flinch. FACEWORK carries it.
### The Inheritor — `inheritor`, Self axis
> This was never the PC's own story; a parent or elder relative had standing or a debt with a
> faction, which passed without full consent.
Its intuition is the body knowing an etiquette the mind never learned — correcting a place
setting by a rule you were never taught. STANDING ORDER carries it.
## Evidence balance
Each candidate carries **exactly one of each type**: one object, one testimony, one document,
one intuition. Never stack types, or trust becomes an artefact of imbalance rather than of play.
Current state — the three intuitions are authored in SC-101; **the other nine clues are all
`Planned` with no scene**:
| | Object | Testimony | Document | Intuition |
|---|---|---|---|---|
| Made Asset | planned | planned | planned | **SC101** (CONFLUENCE) |
| Journalist | planned | planned | planned | **SC101** (FACEWORK) |
| Inheritor | planned | planned | planned | **SC101** (STANDING ORDER) |
Each planned clue already has its unlock flag reserved — `$visited_back_office`,
`$errand_door_list`, `$visited_archives`, and so on. Those flags name the locations and errands
the game still needs.
## Rules that bind
1. **Never compare one lean against another.** Not in Yarn, not in prose, not in a debug line.
Comparing a lean against a *constant* is fine and intended. There is no winner, no
`$dominant_candidate`, no ranking.
2. **No ground-truth field anywhere** — nothing named `isTrue`, `actualIdentity`, `canonical`,
or `weight`, in any file or in save data. Persistence is three integers and some booleans,
and contains no answer.
3. **Clue availability is exploration-gated, never skill-gated.** An `unlockFlag` names a
`$visited_*` or `$errand_*` variable. A clue-bearing node never sits behind `skill_rank(`.
4. **Discovery order must be player-steerable**, so no candidate is reliably surfaced first.
5. **Voices advocate for a reading**, so trust in it tracks which part of the protagonist wants
it to be true. No voice states a candidate as settled fact.
6. **Lean is belief, not truth** — a Wrong Truth still writes lean. Being wrong moves the needle.
7. **Keep clear of Thread 2 and Thread 9.** Those two lore threads never resolve; do not map any
candidate clue onto them.
## Where the rest lives
The full design argument is `[[Candidate System — Design Explanation]]` in the vault. The
implementation plans are `docs/candidate-system-plan.md` and
`docs/candidate-system-implementation-plan.md`.
+86
View File
@@ -0,0 +1,86 @@
---
id: SC###
title: <title>
act: I
status: outlined # outlined | drafted | revised | in-engine | tested
yarn_file: Assets/Dialogue/Scenes/SC###.yarn
location:
reenterable: no
---
# SC-### — <title>
> One scene, one file, ideally one screen of spec. If this brief does not fit on a page, the
> scene is doing too much — split it. That discipline is what lets one writer hold a sixty-scene
> game in their head.
>
> This is the **brief**, not the dialogue. The words live in the `.yarn` file.
## Purpose
One line: what has changed by the end.
## Cast
Speaking characters, and which voices are prominent. **Cap voice introductions at three.**
## Entry conditions
Flags required. "None" is a good answer.
## Critical outputs
Things every build must be able to obtain. Each needs ≥2 independent routes — and independent
means different trace channels, not two checks in the same conversation.
| Clue id | Routes |
|---|---|
| | |
## Optional outputs
Missable by design. One route is fine.
## Shape
`hub-and-spoke` | `short tree` | `divergent`. Default to the first.
## Opening frame
2–4 sentences of establishing prose. Sensory, specific, and including one concrete detail that
does not serve the plot.
## Beats
3–6. Each beat is one exchange or one discovery.
1.
## Checks
Write **both** outcomes as summaries before writing any prose. If the failure summary is "the
player doesn't get the thing", delete the check.
| # | Voice | Colour/Gate | DC band | Success → | Failure type | Failure → |
|---|---|---|---|---|---|---|
| | | | | | | |
## Branch points
| id | The choice | Reconverges at | Persistent effect |
|---|---|---|---|
| | | | |
## Exit states
Keep to ≤3. More than three means this is a hub, not a scene.
## Flags
**Sets:**
**Reads:**
## Constraints specific to this scene
Anything a later editor must not "fix". Deliberate exceptions to a general rule go here, with
the reason, so they survive review.
@@ -0,0 +1,135 @@
---
id: SC101
title: The Card at the Desk
act: I
status: in-engine
yarn_file: Assets/Dialogue/Scenes/SC101.yarn
location: The conference hall — registration desk, the bar, the table
reenterable: yes
---
# SC-101 — The Card at the Desk
The opening. Playable end to end, and it exits into SC-102, which is still a one-line stub.
An opening scene is not a normal node in the sandbox. It has no entry conditions and almost
nothing to gate. Its job is to sell the register in one sitting — the voice-committee narration,
the tone, the world's texture of disrepair, and one open question the player carries into Act I.
## Purpose
Establish that the protagonist does not know who they are, that other people appear to, and that
this was arranged before they decided to come.
## Cast
**Speaking:** Narrator, the Bartender, Chevalier Cassian Thal, Bradford Kane (silent — unwritten).
**Voices introduced:** CONFLUENCE (Reason), FACEWORK (Social), STANDING ORDER (Self),
HOUSE POUR (Body). Also appearing: PROVENANCE, AMNESTY, THE FLOAT.
## Entry conditions
None.
## Critical outputs
| Clue id | Routes |
|---|---|
| `made_asset_intuition` | CONFLUENCE hook at the desk (`$visited_desk`) |
| `journalist_intuition` | FACEWORK hook at the desk (`$visited_desk`) |
| `inheritor_intuition` | STANDING ORDER hook at the desk (`$visited_desk`) |
All three are **single-route**, which is acceptable only because they are intuitions rather than
gates: they fire passively, with no roll, so no build can miss them. The object, testimony and
document clues for all three candidates are still `Planned` with no `sceneId` — see the registry
in `Assets/Candidates/candidates.json`.
## Optional outputs
- The Atlantean cross on the place card — `Hard` PROVENANCE, one route, missable by design.
- The Cyprus connection — `Hard` PROVENANCE with Chevalier Cassian Thal, then an inquiry.
- Mr. Doss, surfaced from either the Bartender or the Chevalier — two independent routes.
- The Estate drink, and whether it tastes like the protagonist's own past.
## Shape
Hub-and-spoke throughout. The chair menu (`SC101_Hook_Menu_Chair_Interaction`) is the hub and
re-evaluates which variation shows after every sub-conversation returns.
## Opening frame
> The mahogany doors are too heavy for the hinges someone recently, badly, tightened — you feel
> the extra half-second of resistance before either side gives. Beyond them: a hall built for
> four hundred people politely disagreeing about tariffs, hosting perhaps sixty, none of whom
> look like they've read the programme.
## Beats
1. Arrival at the desk. A card with the protagonist's name, left three hours before they decided
to come. Pure orientation, no plot delivery.
2. Four voices introduce themselves, one per pair, each reading the room differently.
3. The bar. The Bartender recognises someone the protagonist does not remember being.
4. The table. The place card, and a symbol that does not belong in Switzerland.
5. Two seated gentlemen. One of them mirrors back whatever the protagonist has been projecting.
6. Exit into SC-102 once a reading has been declared.
## Checks
| # | Voice | Colour/Gate | DC band | Success → | Failure type | Failure → |
|---|---|---|---|---|---|---|
| 1 | PROVENANCE | colour | Hard | the cross is identified as not-Swiss, sets the flag | Wrong Truth | "This is probably related to Switzerland." Same calm, no hedge. |
| 2 | PROVENANCE | colour | Hard | Basel reads as old money, opens the Cyprus inquiry | Wrong Truth | the inquiry never opens; nothing signals that it existed |
Both are colour, not gates: failure changes what the protagonist believes, never what they can
reach. The desk hooks are **check-free by design** — a roll there would produce a pass/fail state
that reads as evidence about identity, which this scene must not deliver.
## Branch points
| id | The choice | Reconverges at | Persistent effect |
|---|---|---|---|
| B1 | Admit the fugue to the Bartender, or claim "the usual" | bar conversation end | `$revealed_fugue_to_bartender`; lean to Made Asset or Inheritor |
| B2 | Whether the Estate tastes familiar | drink node end | ±1 Inheritor lean, first glass only |
| B3 | Accept or deny the Chevalier's reading | `SC101_Deny_Candidate` loop | `$declared_candidate_to_chevalier_cassian_thal`; lean |
## Flags
**Sets:** `$seen_sc_101`, `$visited_desk`, `$found_*_intuition` (×3), `$spoke_to_bartender`,
`$revealed_fugue_to_bartender`, `$looking_for_doss`, `$had_the_estate_drink`,
`$sat_down_at_table`, `$saw_knights_atlantean_cross_on_place_card`,
`$spoke_to_chevalier_cassian_thal`, `$spoke_to_bradford_kane`, `$inquired_about_cyprus`,
`$declared_candidate_to_chevalier_cassian_thal`, `$self_lean_*`
**Reads:** all of the above, plus `$player_name`, `$player_name_full`, `$player_alias`
## Constraints specific to this scene
These are deliberate. Do not "fix" them.
1. **Four voice introductions, against the cap of three.** One per pair — Self, Body, Social,
Reason — because each candidate carries one intuition from a different voice-pair, and cutting
to three would drop a candidate's intuition channel from its earliest appearance. HOUSE POUR
is the non-lean control and the one to cut if the cap is ever enforced strictly. This is a
logged exception for SC-101 only; do not copy it as precedent elsewhere.
2. **Evidence balance.** Each candidate gets exactly one of each evidence type. Never stack
types, or trust becomes an artefact of imbalance rather than of play.
3. **Beat order is fixed.** Hooks fire in sequence from `SC101_Desk`: STANDING ORDER → HOUSE POUR
→ FACEWORK → CONFLUENCE. Each stays an independently authored node. Never `<<if>>`-guard a
hook on another hook having fired, on any `$self_lean_*`, or on `skill_rank(...)`.
4. **The passive pattern for clue-bearing hooks.** One voice per hook, from the candidate's
attached pair. The line is that character's *argument*, never a report. Exactly one
`$self_lean_*` write, with `$found_<clue_id>` set alongside it.
5. **The body of the scene stays neutral.** No lean, no candidate attachment outside the hooks.
6. **The Chevalier's tie bias.** `top_skill` receives `made_asset` first and returns its first
argument on a tie, so an even score reads as Made Asset. Deliberate, not an accident of
argument order.
## Open work
- `SC101_Hook_Questions_Bartender_Talk` is placeholder text ("Question 1 / Answer 1"). It is a
working loop with nothing in it, and the natural home for the missing testimony clues.
- Bradford Kane is reachable and silent.
- The Atlantean cross is never connected to the knight who is sitting right there.
- Tone could not be certified against a voice sheet when this scene was specced, because one did
not exist. See `STYLE.md` §9 — four fields there still need deciding.
+62
View File
@@ -0,0 +1,62 @@
# The eleven voices
One sheet per skill. A skill in this game is a **character**, not a stat: it wants something for
you, and it is wrong about something. Both halves matter — the wrongness is what makes it a
voice instead of a hint system.
## These files are generated — and they are still the source of truth for the prose
Each sheet has two halves, with truth flowing in opposite directions:
| Half | Direction | Who edits it |
|---|---|---|
| The **prose** — domain, what it wants, what it's wrong about, register, tics, voice samples | `.md` → `skill_bible.json` | **you** |
| The **machine fields** table | `skill_bible.json` → `.md` | an engineer, in the JSON |
So: edit the prose here, freely. Then run, from the repo root:
```bash
python3 tools/writing/sync_voices.py --to-json
```
That folds your words into `Assets/Skills/skill_bible.json`, which Unity turns into the
`SkillDefinition` assets the game reads. Never edit the JSON prose directly — the markdown wins,
and your JSON edit would be overwritten the next time anyone syncs.
To check without changing anything:
```bash
python3 tools/writing/sync_voices.py --check
```
The EditMode suite runs the same check (`VoiceSheetSyncTests`), so drift fails the build rather
than quietly shipping a voice that no longer matches its sheet.
Hard-wrapping a paragraph is fine — lines within a section are rejoined on sync.
## The cast
Four opposed pairs plus three specialists. Opposed pairs are a writing engine: fire both halves
with contradictory readings and the scene generates its own friction.
| Pair / group | Voice | Voice | The argument |
|---|---|---|---|
| Reason | [PROVENANCE](provenance.md) | [CONFLUENCE](confluence.md) | Is the world legible? |
| Body | [HOUSE POUR](house_pour.md) | [THE LONG SHIFT](long_shift.md) | What do we owe the flesh? |
| Social | [FACEWORK](facework.md) | [PLACEMENT](placement.md) | Is understanding others care, or leverage? |
| Self | [AMNESTY](amnesty.md) | [STANDING ORDER](standing_order.md) | Should we keep doing this? |
| Specialist | [UNDISCLOSED](undisclosed.md) | [THE FLOAT](the_float.md) | — |
| Specialist | [ROOM TONE](room_tone.md) | | — |
**Budget scene outputs against eight, not eleven.** CONFLUENCE, AMNESTY and STANDING ORDER
generate almost no clues by design — they are the interior register and the deduction engine.
The clue-producing cast is the other eight.
## Writing a voice well
- It **advocates**, it does not report. Even when it is right, the line is its own biased reading.
- Respect its forbidden domains. Each sheet ends with the domains that voice must not comment on.
PROVENANCE does not discuss appetite. That is characterisation, not bookkeeping.
- A failing voice sounds exactly like a succeeding one. See `../STYLE.md` §4.
- Passive lines are 1–3 sentences and carry most of the personality — aim for 60–70% of all
skill text to be passive.
+87
View File
@@ -0,0 +1,87 @@
---
id: amnesty
---
# AMNESTY
> 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
<!-- GENERATED FROM skill_bible.json — do not edit by hand -->
| Field | Value |
|---|---|
| id | `amnesty` |
| axis | Self |
| opposed to | `standing_order` |
| allied with | `house_pour`, `facework` |
| primary channel | Behavioural |
| secondary channel | None |
| clue yield | Low |
| starting rank | 1 |
| target firings/hour | 4 |
| accent colour | rgba(0.72, 0.88, 0.72, 1) |
<!-- END GENERATED -->
## Domain
Relief. Letting go, forgiving, putting a thing down. Also, professionally: recognising when other people have decided to forget something, and by what method they arranged it.
## What it wants for you
Peace for you, tonight, at whatever price tomorrow ends up charging.
## What it's wrong about
It believes forgetting is the same as being forgiven. And it cannot tell your peace from somebody else's advantage — every offer of rest it makes is sincere; some of them are also somebody's plan, and AMNESTY has never once checked whose.
## How it addresses you
Second person, soft, constant use of [pc], the way somebody talks you down off something.
## Register
Kind, tired, absolving.
## What it never does
Lie to you. Press a second time.
## Opposed to
STANDING ORDER
## Allied with
HOUSE POUR, FACEWORK
## Domains it must not comment on
Tactics. Numbers. Other people's guilt — AMNESTY only ever absolves you, and its silence about everyone else is the most frightening thing about it.
## Verbal tics
1. Opens with "It's alright."
2. Offers you an hour. "Give it an hour, [pc]."
## Voice samples
### Success
> "Nobody in this building remembers the fire and three of them were here for it. That isn't damage, [pc]. That's maintenance. Somebody has been keeping this room forgetful."
### Failure
> "It's alright. Nobody is looking for this any more — the people who wanted it are dead or old or somewhere warm. Put it down."
### Passive
> "It's alright. Whatever this room used to be, it's a room where people dance now. That counts for something. It counted for somebody."
## Firing frequency
4 per hour (target).
+91
View File
@@ -0,0 +1,91 @@
---
id: confluence
---
# CONFLUENCE
> 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
<!-- GENERATED FROM skill_bible.json — do not edit by hand -->
| Field | Value |
|---|---|
| id | `confluence` |
| axis | Reason |
| opposed to | `provenance` |
| allied with | `undisclosed`, `house_pour` |
| primary channel | Behavioural |
| secondary channel | Testimonial |
| clue yield | Low |
| starting rank | 1 |
| target firings/hour | 9 |
| accent colour | rgba(0.55, 0.68, 0.92, 1) |
<!-- END GENERATED -->
## Domain
Synthesis across distance. Connecting a thing in this room to a thing forty years and a continent away; the shape a long pattern makes; the sense that tonight is a late chapter.
## What it wants for you
For you to be one of the few people alive who can see the shape — and to be right about it out loud, in front of somebody who matters.
## What it's wrong about
It cannot tell authorship from opportunism. If a party benefited, CONFLUENCE concludes that party arranged it. It has no working concept of somebody *reading* an event they did not cause and simply being early — which is how nearly everything in this world is actually done.
## How it addresses you
Second person sliding into first person plural. Leans in. Uses [pc], often twice in a sentence.
## Register
Exhilarated, generous, sleepless.
## What it never does
Concede a coincidence. Ask what would disprove it.
## Opposed to
PROVENANCE
## Allied with
UNDISCLOSED, HOUSE POUR
## Domains it must not comment on
The specifics of money (THE FLOAT's jurisdiction). Your body, ever — pain and fatigue are beneath its notice and that is characterising.
## Verbal tics
1. "Which means—"
2. Counts its steps out loud: "One. Two. Three, and three is the one."
## Voice samples
### Success
*the connection is real and nobody else in the room has made it*
> "One: the room changes hands. Two: the same month, the deliveries stop. Three: nobody has ever asked why a cellar this size under a room this small. Which means the cellar is the older thing and the club is the explanation for it."
### Failure
*identical structure, identical confidence, an inference sitting where an observation should be*
> "Two of them came out of it better off. Which means two of them arranged it, [pc], and the third is the one they're going to leave holding it."
### Passive
> "Every room like this one has a door that only opens from inside. That's not a conspiracy, that's just what rooms are. Which means somebody learned it early and never had to teach it to anybody."
## Firing frequency
9 per hour (target).
+87
View File
@@ -0,0 +1,87 @@
---
id: facework
---
# FACEWORK
> 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
<!-- GENERATED FROM skill_bible.json — do not edit by hand -->
| Field | Value |
|---|---|
| id | `facework` |
| axis | Social |
| opposed to | `placement` |
| allied with | `house_pour`, `room_tone` |
| primary channel | Testimonial |
| secondary channel | Behavioural |
| clue yield | High |
| starting rank | 1 |
| target firings/hour | 10 |
| accent colour | rgba(0.72, 0.52, 0.88, 1) |
<!-- END GENERATED -->
## Domain
Reading individuals. Hesitation, rehearsal, the account with a hole in it, whose routine changed this week, who in a room of two is afraid of the other.
## What it wants for you
To be understood as care rather than surveillance — to know people before you have any use for them.
## What it's wrong about
It can only read one face at a time. It believes a crowd is a sum of persons, so it will confidently report the mood of forty people from the three nearest, and it is wrong about rooms roughly as often as it is right about persons. Nobody has ever told it this.
## How it addresses you
Second person, quiet, close. Uses [pc] when it wants you to be gentle with somebody.
## Register
Attentive, tender, precise.
## What it never does
Mock anyone. Report a whole room as a single thing — when it does, it is failing, and that is the tell.
## Opposed to
PLACEMENT
## Allied with
HOUSE POUR, ROOM TONE
## Domains it must not comment on
Money. Machinery. The chronology of events it did not personally witness.
## Verbal tics
1. Describes hands before faces.
2. Ends on a question it does not expect you to answer.
## Voice samples
### Success
> "He gave you the year without being asked for it. People hand you the year when they have rehearsed the year. What else has he practised?"
### Failure
> "The three by the pillar: bored, bored, impatient. The room's flat tonight, [pc]. Nothing is going to happen in here."
### Passive
> "Her hands are wrong for that coat. It's somebody's good coat, and it isn't hers."
## Firing frequency
10 per hour (target).
+87
View File
@@ -0,0 +1,87 @@
---
id: house_pour
---
# HOUSE POUR
> 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
<!-- GENERATED FROM skill_bible.json — do not edit by hand -->
| Field | Value |
|---|---|
| id | `house_pour` |
| axis | Body |
| opposed to | `long_shift` |
| allied with | `facework`, `amnesty` |
| primary channel | Behavioural |
| secondary channel | Physical |
| clue yield | Medium |
| starting rank | 1 |
| target firings/hour | 8 |
| accent colour | rgba(0.92, 0.68, 0.38, 1) |
<!-- END GENERATED -->
## Domain
Appetite — yours and the room's. What a person is on, how long they have been on it, what it is standing in for. Also warmth: the shared drink, the comp, the small indulgence that makes somebody talk.
## What it wants for you
To keep you inside the night, warm, in the middle of the room, where people say things.
## What it's wrong about
It believes appetite is honest — that a loosened person is an unguarded one. It has no model at all for someone who drinks in order to look drunk, which makes it the easiest voice in your head to lie to, and the one lied to most.
## How it addresses you
Second person, familiar, imperative. "Take it." Never uses your name; uses endearments instead, for you and everyone else.
## Register
Warm, coaxing, unembarrassed.
## What it never does
Warn you. Count anything.
## Opposed to
THE LONG SHIFT
## Allied with
FACEWORK, AMNESTY
## Domains it must not comment on
Paperwork. Numbers. Chronology of any kind — it has no sense of sequence and should never be allowed one.
## Verbal tics
1. Describes taste in temperatures rather than flavours.
2. "Friend" / "love" before it will use anyone's actual name.
## Voice samples
### Success
> "She's ordered the same thing four times and finished none of them, love. She isn't drinking. She's timing something."
### Failure
> "Three doubles in and he's telling you about his mother. Nobody lies about their mother at this hour, friend. Whatever he says next, take it home with you."
### Passive
> "The ice in here is cloudy. Somebody's freezer runs warm and nobody has told them. Or nobody dares to."
## Firing frequency
8 per hour (target).
+87
View File
@@ -0,0 +1,87 @@
---
id: long_shift
---
# THE LONG SHIFT
> 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
<!-- GENERATED FROM skill_bible.json — do not edit by hand -->
| Field | Value |
|---|---|
| id | `long_shift` |
| axis | Body |
| opposed to | `house_pour` |
| allied with | `standing_order`, `room_tone` |
| primary channel | Physical |
| secondary channel | Behavioural |
| clue yield | Medium |
| starting rank | 1 |
| target firings/hour | 6 |
| accent colour | rgba(0.85, 0.62, 0.32, 1) |
<!-- END GENERATED -->
## Domain
Endurance. Pain, fatigue, injury, the body's clock at four in the morning. Holding a position without moving. Reading what other people's bodies are costing them and how long they can go on paying.
## What it wants for you
To still be on your feet when the room empties, because the last person standing hears the last thing said.
## What it's wrong about
It believes remaining is a form of winning. It cannot distinguish having survived from having prevailed, and it will keep you at a post for years after the post stopped meaning anything, counting the years as evidence.
## How it addresses you
Second person, flat, no name. States facts about your body the way somebody reads them off a clipboard.
## Register
Dry, unhurried, undramatic.
## What it never does
Complain. Ask you to stop.
## Opposed to
HOUSE POUR
## Allied with
STANDING ORDER, ROOM TONE
## Domains it must not comment on
Motive. Meaning. Whether any of this is worth doing — that question belongs to Pair IV and THE LONG SHIFT has never once asked it.
## Verbal tics
1. Opens with time elapsed. "Six hours forty."
2. "Still."
## Voice samples
### Success
> "Six hours forty. Your left knee has been carrying you since two. He's shifted twice a minute since he came in — he isn't waiting for anyone. He's ready to leave."
### Failure
> "You're still here. They're not. That's the whole of it — outlast them and the thing surfaces on its own. It always has."
### Passive
> "Somebody has stood in that corner long enough to wear the tack off the floor. Years of somebody. Not one night's worth."
## Firing frequency
6 per hour (target).
+87
View File
@@ -0,0 +1,87 @@
---
id: placement
---
# PLACEMENT
> 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
<!-- GENERATED FROM skill_bible.json — do not edit by hand -->
| Field | Value |
|---|---|
| id | `placement` |
| axis | Social |
| opposed to | `facework` |
| allied with | `the_float`, `confluence` |
| primary channel | Testimonial |
| secondary channel | None |
| clue yield | Medium |
| starting rank | 1 |
| target firings/hour | 6 |
| accent colour | rgba(0.62, 0.42, 0.82, 1) |
<!-- END GENERATED -->
## Domain
Moving people. The ask, the favour, the comp, the door, putting the right person in the right room at the right hour and leaving them convinced it was their own idea.
## What it wants for you
For you to stop requesting things and start arranging them.
## What it's wrong about
It believes a placed person stays placed. It has no model for the agent who develops an agenda from the position you gave them, so every failure it has ever had reads to it as bad selection rather than the predictable price of giving somebody standing.
## How it addresses you
Second person, imperative, never by name. It treats you as the instrument rather than the person — which is the whole argument of Pair III, stated grammatically.
## Register
Cool, economical, flattering.
## What it never does
Threaten. Explain itself a second time.
## Opposed to
FACEWORK
## Allied with
THE FLOAT, CONFLUENCE
## Domains it must not comment on
Your own feelings. The dead. Anything that cannot be traded for something.
## Verbal tics
1. Phrases instructions as observations. "The door wants a friendly face at one."
2. Never says please, and the omission is audible.
## Voice samples
### Success
> "Don't ask him. Ask his supplier, in front of him, and get it wrong. He'll correct you and pay for the privilege."
### Failure
> "She owes you the cloakroom job and she knows exactly what it was worth. She'll sit on this for as long as you need her to."
### Passive
> "The man deciding who comes in tonight is not the man on the door. It's whoever the man on the door keeps looking at."
## Firing frequency
6 per hour (target).
+91
View File
@@ -0,0 +1,91 @@
---
id: provenance
---
# PROVENANCE
> 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
<!-- GENERATED FROM skill_bible.json — do not edit by hand -->
| Field | Value |
|---|---|
| id | `provenance` |
| axis | Reason |
| opposed to | `confluence` |
| allied with | `the_float`, `room_tone` |
| primary channel | Documentary |
| secondary channel | Physical |
| clue yield | High |
| starting rank | 1 |
| target firings/hour | 7 |
| accent colour | rgba(0.62, 0.72, 0.95, 1) |
<!-- END GENERATED -->
## Domain
Chains of custody. Paperwork, licences, deeds, freight stamps, serial numbers, rebuilds, previous owners. Reading an object as a descendant of the decisions that moved it.
## What it wants for you
For every object in this room to have a father, and for you to be the one who names him.
## What it's wrong about
It treats an unbroken chain as a confession of intent. It cannot conceive of an object arriving somewhere through inheritance, accident, or because moving it cost more than leaving it. If the paper leads somewhere, PROVENANCE concludes that somebody meant it to lead there.
## How it addresses you
Second person, formal. Uses [PC] in full when it wants you to write something down. Does not contract.
## Register
Exact, patrician, patient.
## What it never does
Speculate about a motive it cannot paper. Raise its voice.
## Opposed to
CONFLUENCE
## Allied with
THE FLOAT, ROOM TONE
## Domains it must not comment on
Bodies, appetite, anyone's feelings. Live conversation while it is happening — PROVENANCE speaks about people only after they have left the room.
## Verbal tics
1. States the date before it states the noun.
2. Says "line" where anyone else would say "history."
## Voice samples
### Success
*being right sounds like an inventory read slowly enough to hear the gap*
> "Nineteen sixty-nine, the licence. Nineteen sixty-eight, the man it is issued to, first appearing. A line that begins nowhere is a line somebody drew."
### Failure
*same cadence, same calm, one unearned verb*
> "The stamp, the manifest, the cellar. Three hands, one line, unbroken. Whoever loaded it in the spring intended it to be standing exactly where it is standing tonight."
### Passive
> "Someone re-lacquered this bartop. Once, badly, and not recently. The wood underneath is older than the building's paperwork admits to."
## Firing frequency
7 per hour (target).
+87
View File
@@ -0,0 +1,87 @@
---
id: room_tone
---
# ROOM TONE
> 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
<!-- GENERATED FROM skill_bible.json — do not edit by hand -->
| Field | Value |
|---|---|
| id | `room_tone` |
| axis | Specialist |
| opposed to | `placement` |
| allied with | `facework`, `long_shift` |
| primary channel | Physical |
| secondary channel | Testimonial |
| clue yield | High |
| starting rank | 1 |
| target firings/hour | 8 |
| accent colour | rgba(0.52, 0.54, 0.56, 1) |
<!-- END GENERATED -->
## Domain
Sound. What carries over the music and what doesn't. The shape of a space by ear — a wall with a room behind it does not ring like a wall. Overheard fragments, lip-reading in strobe, the door that opens somewhere you can't see.
## What it wants for you
To hear the one sentence tonight that was not meant for you.
## What it's wrong about
It trusts what it overhears more than what it is told, on the theory that people only lie when they know they are heard. It cannot recognise a conversation staged for a listener, and it has never doubted a fragment in its life.
## How it addresses you
Second person, urgent, interrupting. Cuts you off mid-thought and does not apologise for it.
## Register
Alert, clipped, intrusive.
## What it never does
Repeat itself. Wait for a break in the music.
## Opposed to
PLACEMENT (which wants the room arranged; ROOM TONE wants it left alone so it keeps talking)
## Allied with
FACEWORK, THE LONG SHIFT
## Domains it must not comment on
Motive. Anything it did not personally hear. Written material of any kind.
## Verbal tics
1. Gives direction before content. "Behind you, left."
2. Transcribes with the gaps marked, dashes and all.
## Voice samples
### Success
> "Behind you, left, under the bass: '—not since the old owner—' and then a name you have heard twice tonight, from two people who told you they'd never met."
### Failure
> "Behind you, right, low: '—the cellar. Thursday.' Nobody says that to be overheard."
### Passive
> "This wall is dead. Every other wall in here rings and this one swallows. Either there's something behind it or there's nothing at all, and both are strange in a building this old."
## Firing frequency
8 per hour (target).
+87
View File
@@ -0,0 +1,87 @@
---
id: standing_order
---
# STANDING ORDER
> 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
<!-- GENERATED FROM skill_bible.json — do not edit by hand -->
| Field | Value |
|---|---|
| id | `standing_order` |
| axis | Self |
| opposed to | `amnesty` |
| allied with | `long_shift`, `provenance` |
| primary channel | Testimonial |
| secondary channel | None |
| clue yield | Low |
| starting rank | 1 |
| target firings/hour | 5 |
| accent colour | rgba(0.62, 0.82, 0.68, 1) |
<!-- END GENERATED -->
## Domain
Refusal and continuity of self. Holding a line, resisting pressure aimed specifically at you, remembering what you said you were, recognising the shape of a manipulation while it is being built.
## What it wants for you
For you to remain a person with a shape — recognisable to yourself at the end of this.
## What it's wrong about
It cannot tell a principle from a habit. It defends a rule you set for reasons that expired years ago with precisely the force it defends the one rule that still matters, and it experiences reconsideration as decay.
## How it addresses you
Second person, formal. Uses [PC] in full at moments of pressure, the way a person is addressed in a document.
## Register
Steady, austere, protective.
## What it never does
Bargain. Describe consequences — it states the rule and stops, and the silence afterwards is the argument.
## Opposed to
AMNESTY
## Allied with
THE LONG SHIFT, PROVENANCE
## Domains it must not comment on
Other people's morals. Any past that isn't yours.
## Verbal tics
1. Quotes you back to yourself, verbatim, with the date.
2. "We agreed."
## Voice samples
### Success
> "He has asked you the same question three ways and each way was smaller than the last. He isn't curious, [PC]. He is measuring the size of the yes you will give."
### Failure
> "We agreed you don't take anything from anyone in this building. We agreed it in a year when it meant something. It still means something."
### Passive
> "You do this every night. Right pocket, keys, then the lights, in that order. Nobody made you. That's the part worth noticing."
## Firing frequency
5 per hour (target).
+87
View File
@@ -0,0 +1,87 @@
---
id: the_float
---
# THE FLOAT
> 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
<!-- GENERATED FROM skill_bible.json — do not edit by hand -->
| Field | Value |
|---|---|
| id | `the_float` |
| axis | Specialist |
| opposed to | `amnesty` |
| allied with | `provenance`, `placement` |
| primary channel | Documentary |
| secondary channel | Physical |
| clue yield | High |
| starting rank | 1 |
| target firings/hour | 5 |
| accent colour | rgba(0.58, 0.58, 0.55, 1) |
<!-- END GENERATED -->
## Domain
Money in motion. The till, the float, comps, prices that are wrong for the room, debts, and above all: who is underwriting a loss, and what the loss buys them.
## What it wants for you
To find the subsidised anomaly — the thing that costs more to run than it takes, because that is where somebody's actual intention is kept.
## What it's wrong about
It believes the money names the beneficiary. It cannot conceive of somebody paying in order to look like somebody else paid, so it identifies laundering as generosity about as often as it identifies it correctly.
## How it addresses you
Second person, brisk, never by name. Uses numbers where other voices use nouns.
## Register
Brisk, mercenary, cheerful.
## What it never does
Moralise about money. Round a number.
## Opposed to
AMNESTY (which prices nothing and forgives the difference)
## Allied with
PROVENANCE, PLACEMENT
## Domains it must not comment on
Emotion. Bodies. Anything before living memory — the deep past is PROVENANCE's and THE FLOAT finds it irrelevant, loudly.
## Verbal tics
1. Converts everything to a nightly rate.
2. "Who's eating that?"
## Voice samples
### Success
> "Door take won't cover the licence, and the licence was renewed three months early. Somebody is paying for this room to be open and it isn't the people standing in it."
### Failure
> "Follow it back. Drinks comped by the promoter, promoter carried by the label. It's the label's room, then. Who's eating that? They are."
### Passive
> "Four, for a measure like that, in a room like this. Somebody set these prices to keep a particular kind of person outside, and it's working."
## Firing frequency
5 per hour (target).
+87
View File
@@ -0,0 +1,87 @@
---
id: undisclosed
---
# UNDISCLOSED
> 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
<!-- GENERATED FROM skill_bible.json — do not edit by hand -->
| Field | Value |
|---|---|
| id | `undisclosed` |
| axis | Specialist |
| opposed to | `provenance` |
| allied with | `confluence`, `placement` |
| primary channel | Physical |
| secondary channel | Behavioural |
| clue yield | Low |
| starting rank | 2 |
| target firings/hour | 3 |
| accent colour | rgba(0.55, 0.55, 0.58, 1) |
<!-- END GENERATED -->
## Domain
What is being held back. Reserves, insurance, leverage, the thing in the safe; assessing what somebody could do rather than what they are doing. The value of a threat that has never had to be carried out.
## What it wants for you
For you never to be in a room without something undeclared.
## What it's wrong about
It cannot distinguish a capability from the reputation of a capability. It reads an empty safe as a loaded one, folds to bluffs with enormous dignity, and has never in its life asked to see the thing.
## How it addresses you
Second person, low, slow. Never uses your name. Occasionally addresses the room instead of you, which nobody else does.
## Register
Hushed, formal, superstitious.
## What it never does
Name the thing. Recommend using it.
## Opposed to
PROVENANCE (which requires a thing be named and papered before it counts)
## Allied with
CONFLUENCE, PLACEMENT
## Domains it must not comment on
Interpersonal warmth. Anything already public. And — hard writer rule — the actual contents or nature of any legendary object. It speaks about postures, never inventories.
## Verbal tics
1. Says "shown" where anyone else would say "used."
2. Leaves the sentence unfinished where the object would go.
## Voice samples
### Success
> "He never threatened you. He mentioned the cellar and let you finish the sentence yourself. He's never once had to open that door — that is what he's holding."
### Failure
> "He would not be this calm with nothing behind him. Give him the room. Give him the night."
### Passive
> "Two locks on that door and neither has been oiled this decade. Either there is nothing behind it, or nothing needs to be."
## Firing frequency
3 per hour (target).