Compare commits

..
2 Commits
Author SHA1 Message Date
lennart 57bc10ba79 updated Candidate System with Voice System 2026-08-15 13:25:04 +02:00
lennart 22b8c7a438 integrated candidate system 2026-08-15 13:01:04 +02:00
37 changed files with 2905 additions and 2 deletions
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: db0c69a0766e477bb76a94393518cf60
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,18 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: c4dc6e246e9a462dbae29cd42b16317e, type: 3}
m_Name: CandidateDatabase
m_EditorClassIdentifier:
candidates:
- {fileID: 11400000, guid: af55e04701494a71a0b292d910db3d13, type: 2}
- {fileID: 11400000, guid: 7282093aca2e4d408d4972354ebcd634, type: 2}
- {fileID: 11400000, guid: b6184c723bab4bef8f8fbaaf462a324d, type: 2}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e2fa27d75ae14f498235dd461791765d
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 75c54c58c29b4b738e7c0f55e960540b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,49 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 1f0a63ccd8da4e5ab64dea83c64eeb6a, type: 3}
m_Name: inheritor
m_EditorClassIdentifier:
id: inheritor
displayName: The Inheritor
reading: 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.
intuitionAxis: 3
clues:
- id: inheritor_object
type: 0
sceneId:
unlockFlag: $visited_private_box
status: 0
skillId:
notes: "Object clue \u2014 Planned. Not yet authored."
- id: inheritor_testimony
type: 1
sceneId:
unlockFlag: $errand_ask_about_name
status: 0
skillId:
notes: "Testimony clue \u2014 Planned. Not yet authored."
- id: inheritor_document
type: 2
sceneId:
unlockFlag: $visited_membership_ledger
status: 0
skillId:
notes: "Document clue \u2014 Planned. Not yet authored."
- id: inheritor_intuition
type: 3
sceneId: SC101
unlockFlag: $visited_desk
status: 1
skillId: standing_order
notes: 'SC-101 Self intuition: correcting an etiquette rule never consciously
learned.'
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b6184c723bab4bef8f8fbaaf462a324d
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,49 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 1f0a63ccd8da4e5ab64dea83c64eeb6a, type: 3}
m_Name: journalist
m_EditorClassIdentifier:
id: journalist
displayName: The Journalist
reading: The PC came for a specific person, not a story; 'journalist' is a cover
identity.
intuitionAxis: 2
clues:
- id: journalist_object
type: 0
sceneId:
unlockFlag: $visited_coat_check
status: 0
skillId:
notes: "Object clue \u2014 Planned. Not yet authored."
- id: journalist_testimony
type: 1
sceneId:
unlockFlag: $errand_find_contact
status: 0
skillId:
notes: "Testimony clue \u2014 Planned. Not yet authored."
- id: journalist_document
type: 2
sceneId:
unlockFlag: $visited_press_room
status: 0
skillId:
notes: "Document clue \u2014 Planned. Not yet authored."
- id: journalist_intuition
type: 3
sceneId: SC101
unlockFlag: $visited_desk
status: 1
skillId: facework
notes: 'SC-101 Social intuition: a reaction that lands wrong and can''t yet be
explained.'
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7282093aca2e4d408d4972354ebcd634
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,49 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 1f0a63ccd8da4e5ab64dea83c64eeb6a, type: 3}
m_Name: made_asset
m_EditorClassIdentifier:
id: made_asset
displayName: The Made Asset
reading: The PC was trained, conditioned, or activated by one of the factions for
a role near the central conference.
intuitionAxis: 0
clues:
- id: made_asset_object
type: 0
sceneId:
unlockFlag: $visited_back_office
status: 0
skillId:
notes: "Object clue \u2014 Planned. Not yet authored."
- id: made_asset_testimony
type: 1
sceneId:
unlockFlag: $errand_door_list
status: 0
skillId:
notes: "Testimony clue \u2014 Planned. Not yet authored."
- id: made_asset_document
type: 2
sceneId:
unlockFlag: $visited_archives
status: 0
skillId:
notes: "Document clue \u2014 Planned. Not yet authored."
- id: made_asset_intuition
type: 3
sceneId: SC101
unlockFlag: $visited_desk
status: 1
skillId: confluence
notes: 'SC-101 Reason intuition: parsing something too fluently, before consciously
trying to.'
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: af55e04701494a71a0b292d910db3d13
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,136 @@
{
"candidates": [
{
"id": "made_asset",
"displayName": "The Made Asset",
"reading": "The PC was trained, conditioned, or activated by one of the factions for a role near the central conference.",
"intuitionAxis": "Reason",
"clues": [
{
"id": "made_asset_object",
"type": "Object",
"sceneId": "",
"unlockFlag": "$visited_back_office",
"status": "Planned",
"skillId": "",
"notes": "Object clue — Planned. Not yet authored."
},
{
"id": "made_asset_testimony",
"type": "Testimony",
"sceneId": "",
"unlockFlag": "$errand_door_list",
"status": "Planned",
"skillId": "",
"notes": "Testimony clue — Planned. Not yet authored."
},
{
"id": "made_asset_document",
"type": "Document",
"sceneId": "",
"unlockFlag": "$visited_archives",
"status": "Planned",
"skillId": "",
"notes": "Document clue — Planned. Not yet authored."
},
{
"id": "made_asset_intuition",
"type": "Intuition",
"sceneId": "SC101",
"unlockFlag": "$visited_desk",
"status": "Authored",
"skillId": "confluence",
"notes": "SC-101 Reason intuition: parsing something too fluently, before consciously trying to."
}
]
},
{
"id": "journalist",
"displayName": "The Journalist",
"reading": "The PC came for a specific person, not a story; 'journalist' is a cover identity.",
"intuitionAxis": "Social",
"clues": [
{
"id": "journalist_object",
"type": "Object",
"sceneId": "",
"unlockFlag": "$visited_coat_check",
"status": "Planned",
"skillId": "",
"notes": "Object clue — Planned. Not yet authored."
},
{
"id": "journalist_testimony",
"type": "Testimony",
"sceneId": "",
"unlockFlag": "$errand_find_contact",
"status": "Planned",
"skillId": "",
"notes": "Testimony clue — Planned. Not yet authored."
},
{
"id": "journalist_document",
"type": "Document",
"sceneId": "",
"unlockFlag": "$visited_press_room",
"status": "Planned",
"skillId": "",
"notes": "Document clue — Planned. Not yet authored."
},
{
"id": "journalist_intuition",
"type": "Intuition",
"sceneId": "SC101",
"unlockFlag": "$visited_desk",
"status": "Authored",
"skillId": "facework",
"notes": "SC-101 Social intuition: a reaction that lands wrong and can't yet be explained."
}
]
},
{
"id": "inheritor",
"displayName": "The Inheritor",
"reading": "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.",
"intuitionAxis": "Self",
"clues": [
{
"id": "inheritor_object",
"type": "Object",
"sceneId": "",
"unlockFlag": "$visited_private_box",
"status": "Planned",
"skillId": "",
"notes": "Object clue — Planned. Not yet authored."
},
{
"id": "inheritor_testimony",
"type": "Testimony",
"sceneId": "",
"unlockFlag": "$errand_ask_about_name",
"status": "Planned",
"skillId": "",
"notes": "Testimony clue — Planned. Not yet authored."
},
{
"id": "inheritor_document",
"type": "Document",
"sceneId": "",
"unlockFlag": "$visited_membership_ledger",
"status": "Planned",
"skillId": "",
"notes": "Document clue — Planned. Not yet authored."
},
{
"id": "inheritor_intuition",
"type": "Intuition",
"sceneId": "SC101",
"unlockFlag": "$visited_desk",
"status": "Authored",
"skillId": "standing_order",
"notes": "SC-101 Self intuition: correcting an etiquette rule never consciously learned."
}
]
}
]
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 8255e20527a84dbabb918a5f5ad590d7
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -5,6 +5,14 @@
// Writing rules (code does not enforce these):
// - Wrong Truth failures: no failure signal in the prose; the UI banner is the only tell.
// - Documentary routes are gated on Act and errand state, never on a voice.
//
// TODO: ambient fallback. Every voice in the Skill Bible has one context-free
// PASSIVE VOICE line, and right now those lines have nowhere to live — a voice
// with no Commentary_{context}_{id} node simply stays silent in that context.
// Proposal: when no context node matches an eligible skill, CommentarySystem
// falls back to Commentary_Ambient_{id}. Deferred from the refactor as optional;
// only worth doing if it doesn't complicate the candidate loop.
// See docs/skill-system-refactor-plan.md §5.6.
title: Commentary_Entrance_Queue_facework
---
@@ -36,4 +36,47 @@ title: Declarations
<<declare $check_modifier = 0 as number>>
<<declare $check_degree = "" as string>>
<<declare $check_skill = "" as string>>
// Candidate lean. Three permanently-live readings of who the PC is; they are
// not mutually exclusive and never resolve. Writers <<set>> these; no code
// reads them. There is deliberately NO derived "winning candidate" variable —
// see docs/candidate-system-implementation-plan.md §6.
<<declare $self_lean_made_asset = 0 as number>>
<<declare $self_lean_journalist = 0 as number>>
<<declare $self_lean_inheritor = 0 as number>>
// Scene witnessed. Convention: $seen_<scene_id>.
<<declare $seen_sc_101 = false as bool>>
// Clue coverage flags. One per registry clue id ($found_<clue_id>). Writers
// <<set>> these at the discovery beat alongside the lean write. Lean tracks
// belief (and can move on a Wrong Truth failure); $found_* tracks coverage.
// No code reads these.
<<declare $found_made_asset_object = false as bool>>
<<declare $found_made_asset_testimony = false as bool>>
<<declare $found_made_asset_document = false as bool>>
<<declare $found_made_asset_intuition = false as bool>>
<<declare $found_journalist_object = false as bool>>
<<declare $found_journalist_testimony = false as bool>>
<<declare $found_journalist_document = false as bool>>
<<declare $found_journalist_intuition = false as bool>>
<<declare $found_inheritor_object = false as bool>>
<<declare $found_inheritor_testimony = false as bool>>
<<declare $found_inheritor_document = false as bool>>
<<declare $found_inheritor_intuition = false as bool>>
// Exploration gating. Clue availability is exploration-gated, never skill-gated.
// Convention: $visited_<location_id> on first arrival; $errand_<errand_id> when
// an errand reaches the state that matters. Writers <<set>> these from scene
// content. unlockFlag fields in candidates.json may only name these families.
<<declare $visited_desk = false as bool>>
<<declare $visited_back_office = false as bool>>
<<declare $visited_archives = false as bool>>
<<declare $visited_coat_check = false as bool>>
<<declare $visited_press_room = false as bool>>
<<declare $visited_private_box = false as bool>>
<<declare $visited_membership_ledger = false as bool>>
<<declare $errand_door_list = false as bool>>
<<declare $errand_find_contact = false as bool>>
<<declare $errand_ask_about_name = false as bool>>
===
@@ -1,6 +1,12 @@
// Scratch node for verifying the skill-check plumbing. Not part of the story.
// Point DialogueRunner.startNode at "Debug_SkillCheck" to run it, then set it
// Scratch nodes for verifying mechanics in isolation. Not part of the story.
// Point DialogueRunner.startNode at the node you want to run, then set it
// back to "Start" when you're done.
//
// Candidate active-check pattern (docs/candidate-system-implementation-plan.md §5 Phase 3):
// <<check skillId band>>
// <<if $check_result>> success branch writes $found_* + $self_lean_*
// <<else>> failure is a Wrong Truth — equally confident, unmarked in prose,
// and still writes lean (belief, not truth). Banner is the only tell.
title: Debug_SkillCheck
---
@@ -20,3 +26,34 @@ Narrator: Confluence came out {$check_degree}.
<<check nosuchskill routine>>
Narrator: Survived the bad skill id.
===
// Candidate lean accumulator round-trip. Expect made_asset 2 / journalist 1 /
// inheritor 0, and the threshold branch to fire.
title: Debug_CandidateLean
---
<<set $self_lean_made_asset += 1>>
<<set $self_lean_made_asset += 1>>
<<set $self_lean_journalist += 1>>
Narrator: made_asset {$self_lean_made_asset} / journalist {$self_lean_journalist} / inheritor {$self_lean_inheritor}
<<if $self_lean_made_asset >= 2>>
Narrator: threshold branch reached.
<<endif>>
===
// Candidate active-check pattern. Set SkillRuntime.rollSeed non-zero for a
// SeededRollSource so both branches are reachable deterministically across runs.
// Success feeds made_asset; failure feeds inheritor (Wrong Truth lean — §4.4).
title: Debug_CandidateCheck
---
Narrator: Candidate check harness. Banner is the only pass/fail tell.
<<check confluence hard>>
<<if $check_result>>
CONFLUENCE: One: the card's stock matches the Concordat watermark. Two: the date is a week before the invitation went out. Three — and three is the one — somebody left this for you to find. Which means you were expected.
<<set $found_made_asset_document = true>>
<<set $self_lean_made_asset += 1>>
<<else>>
CONFLUENCE: One: the card's stock matches a wedding invitation from the thirties. Two: the date is your mother's. Three — and three is the one — you already knew the seating. Which means this was never yours to choose.
<<set $self_lean_inheritor += 1>>
<<endif>>
Narrator: lean made_asset {$self_lean_made_asset} / inheritor {$self_lean_inheritor}. Degree {$check_degree}.
===
@@ -0,0 +1,72 @@
// SC-101 — The Card at the Desk. Opening candidate hooks.
//
// Scene files stay thin: set the stage, <<detour>> into hook nodes, return.
//
// 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.
//
// Discovery order: hooks are independently reachable in any order via
// <<detour>> from the desk. Never <<if>>-guard a hook on another hook having
// fired, on any $self_lean_*, or on skill_rank(...). Never make a hook only
// reachable as fall-through from another.
//
// 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.
title: SC101_Desk
---
<<set $seen_sc_101 = true>>
<<set $visited_desk = true>>
Narrator: A desk under a single lamp. The card is face-down. Your hands already know where the corners are.
-> Made Asset hook
<<detour SC101_Hook_MadeAsset>>
<<jump SC101_Desk>>
-> Journalist hook
<<detour SC101_Hook_Journalist>>
<<jump SC101_Desk>>
-> Inheritor hook
<<detour SC101_Hook_Inheritor>>
<<jump SC101_Desk>>
-> Neutral hook
<<detour SC101_Hook_Neutral>>
<<jump SC101_Desk>>
-> Leave the desk
Narrator: You leave the card where it is. The lamp keeps the shape of your hands on the blotter.
===
title: SC101_Hook_MadeAsset
---
CONFLUENCE: You don't turn it over. You don't have to. The stock, the margin, the way the emboss catches — one, two, three, and three is the one. Which means somebody trained you for a card like this.
<<set $found_made_asset_intuition = true>>
<<set $self_lean_made_asset += 1>>
===
title: SC101_Hook_Journalist
---
FACEWORK: Your throat tightens before you decide what the card is. Not curiosity. Recognition of a person you haven't named yet. That reaction is wrong for a story. It's right for a meeting.
<<set $found_journalist_intuition = true>>
<<set $self_lean_journalist += 1>>
===
title: SC101_Hook_Inheritor
---
STANDING ORDER: You straighten the card so its top edge is parallel to the desk's. Nobody taught you that. You correct it anyway. The rule is older than you are, and it still applies.
<<set $found_inheritor_intuition = true>>
<<set $self_lean_inheritor += 1>>
===
title: SC101_Hook_Neutral
---
HOUSE POUR: The lamp is warm enough. Your mouth is dry. Neither of those is information about who you are — only about how long you've been standing here.
===
@@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: ef4adc1687e744cd0afd7041ab38c377
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 11500000, guid: 94073015eacc34c1d8fc6786e43d60ca, type: 3}
@@ -0,0 +1,218 @@
using System;
using System.Collections.Generic;
using System.IO;
using NightclubArcadia.Skills;
using UnityEditor;
using UnityEditor.Callbacks;
using UnityEngine;
/// <summary>
/// Regenerates CandidateDefinition assets + CandidateDatabase from candidates.json.
///
/// The JSON is the source of truth; .asset files are build output and hand-edits are lost
/// on script reload by EnsureCandidateAssetsOnly.
/// </summary>
public static class CandidateSystemSetup
{
const string CandidatesFolder = "Assets/Candidates";
const string DefinitionsFolder = "Assets/Candidates/Definitions";
const string JsonPath = "Assets/Candidates/candidates.json";
const string DatabasePath = "Assets/Candidates/CandidateDatabase.asset";
const string AutoSetupSessionKey = "NightclubArcadia.CandidateSystem.AutoSetupDone";
[DidReloadScripts]
static void AutoSetupAfterReload()
{
if (EditorApplication.isPlayingOrWillChangePlaymode)
{
return;
}
if (SessionState.GetBool(AutoSetupSessionKey, false))
{
return;
}
EditorApplication.delayCall += () =>
{
if (SessionState.GetBool(AutoSetupSessionKey, false))
{
return;
}
SessionState.SetBool(AutoSetupSessionKey, true);
EnsureCandidateAssetsOnly();
};
}
[MenuItem("Tools/Nightclub Arcadia/Set Up Candidate System")]
public static void SetUpCandidateSystem()
{
EnsureCandidateAssetsOnly();
}
/// <summary>
/// Creates/updates ScriptableObject assets without opening a scene.
/// Safe to run on domain reload.
/// </summary>
public static void EnsureCandidateAssetsOnly()
{
if (!TryLoadRoster(out var roster))
{
return;
}
EnsureFolder("Assets", "Candidates");
EnsureFolder("Assets/Candidates", "Definitions");
var definitions = new List<CandidateDefinition>();
var rosterIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var entry in roster)
{
rosterIds.Add(entry.id);
definitions.Add(CreateOrUpdateDefinition(entry));
}
DeleteOrphanDefinitions(rosterIds);
var database = AssetDatabase.LoadAssetAtPath<CandidateDatabase>(DatabasePath);
if (database == null)
{
database = ScriptableObject.CreateInstance<CandidateDatabase>();
AssetDatabase.CreateAsset(database, DatabasePath);
}
database.EditorSetCandidates(definitions);
EditorUtility.SetDirty(database);
AssetDatabase.SaveAssets();
Debug.Log($"[CandidateSystemSetup] Candidate assets ready at {DatabasePath}.");
}
static bool TryLoadRoster(out CandidateEntry[] roster)
{
roster = null;
var text = AssetDatabase.LoadAssetAtPath<TextAsset>(JsonPath);
if (text == null)
{
// JSON may exist on disk before Unity has imported it as a TextAsset.
if (!File.Exists(JsonPath))
{
Debug.LogError($"[CandidateSystemSetup] Missing registry at {JsonPath}.");
return false;
}
text = new TextAsset(File.ReadAllText(JsonPath));
}
CandidateRoot root;
try
{
root = JsonUtility.FromJson<CandidateRoot>(text.text);
}
catch (Exception ex)
{
Debug.LogError($"[CandidateSystemSetup] Failed to parse {JsonPath}: {ex.Message}");
return false;
}
if (root?.candidates == null || root.candidates.Length == 0)
{
Debug.LogError($"[CandidateSystemSetup] {JsonPath} has no candidates.");
return false;
}
foreach (var entry in root.candidates)
{
if (entry == null || string.IsNullOrEmpty(entry.id))
{
Debug.LogError($"[CandidateSystemSetup] {JsonPath} contains a candidate with empty id.");
return false;
}
if (!Enum.TryParse<SkillAxis>(entry.intuitionAxis, ignoreCase: true, out _))
{
Debug.LogError(
$"[CandidateSystemSetup] Candidate '{entry.id}': invalid intuitionAxis '{entry.intuitionAxis}'.");
return false;
}
if (entry.clues == null)
{
continue;
}
foreach (var clue in entry.clues)
{
if (clue == null || string.IsNullOrEmpty(clue.id))
{
Debug.LogError($"[CandidateSystemSetup] Candidate '{entry.id}' has a clue with empty id.");
return false;
}
if (!Enum.TryParse<CandidateEvidenceType>(clue.type, ignoreCase: true, out _))
{
Debug.LogError(
$"[CandidateSystemSetup] Clue '{clue.id}': invalid type '{clue.type}'.");
return false;
}
var status = string.IsNullOrEmpty(clue.status) ? "Planned" : clue.status;
if (!Enum.TryParse<CandidateClueStatus>(status, ignoreCase: true, out _))
{
Debug.LogError(
$"[CandidateSystemSetup] Clue '{clue.id}': invalid status '{clue.status}'.");
return false;
}
}
}
roster = root.candidates;
return true;
}
static CandidateDefinition CreateOrUpdateDefinition(CandidateEntry entry)
{
var path = $"{DefinitionsFolder}/{entry.id}.asset";
var asset = AssetDatabase.LoadAssetAtPath<CandidateDefinition>(path);
if (asset == null)
{
asset = ScriptableObject.CreateInstance<CandidateDefinition>();
AssetDatabase.CreateAsset(asset, path);
}
asset.EditorSet(entry);
EditorUtility.SetDirty(asset);
return asset;
}
static void DeleteOrphanDefinitions(HashSet<string> rosterIds)
{
if (!AssetDatabase.IsValidFolder(DefinitionsFolder))
{
return;
}
var guids = AssetDatabase.FindAssets("t:CandidateDefinition", new[] { DefinitionsFolder });
foreach (var guid in guids)
{
var path = AssetDatabase.GUIDToAssetPath(guid);
var fileName = Path.GetFileNameWithoutExtension(path);
if (rosterIds.Contains(fileName))
{
continue;
}
Debug.Log($"[CandidateSystemSetup] Deleting orphan definition {path}");
AssetDatabase.DeleteAsset(path);
}
}
static void EnsureFolder(string parent, string child)
{
var path = $"{parent}/{child}";
if (!AssetDatabase.IsValidFolder(path))
{
AssetDatabase.CreateFolder(parent, child);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 871be622b3a34b8fb46ce81bb87272d0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,58 @@
using System;
using UnityEngine;
namespace NightclubArcadia.Skills
{
[Serializable]
public sealed class CandidateClue
{
[SerializeField] string id;
[SerializeField] CandidateEvidenceType type;
[SerializeField] string sceneId;
[SerializeField] string unlockFlag;
[SerializeField] CandidateClueStatus status;
[SerializeField] string skillId;
[SerializeField] string notes;
public string Id => id;
public CandidateEvidenceType Type => type;
public string SceneId => sceneId;
public string UnlockFlag => unlockFlag;
public CandidateClueStatus Status => status;
public string SkillId => skillId;
public string Notes => notes;
#if UNITY_EDITOR
public void EditorSet(CandidateClueEntry entry)
{
if (entry == null)
{
throw new ArgumentNullException(nameof(entry));
}
id = entry.id ?? string.Empty;
type = ParseEnumRequired<CandidateEvidenceType>(entry.type, nameof(entry.type), entry.id);
sceneId = entry.sceneId ?? string.Empty;
unlockFlag = entry.unlockFlag ?? string.Empty;
status = ParseEnumRequired<CandidateClueStatus>(
string.IsNullOrEmpty(entry.status) ? "Planned" : entry.status,
nameof(entry.status),
entry.id);
skillId = entry.skillId ?? string.Empty;
notes = entry.notes ?? string.Empty;
}
static TEnum ParseEnumRequired<TEnum>(string value, string fieldName, string clueId)
where TEnum : struct
{
if (!Enum.TryParse(value, ignoreCase: true, out TEnum parsed))
{
throw new ArgumentException(
$"Clue '{clueId}': invalid {fieldName} '{value}' for {typeof(TEnum).Name}.");
}
return parsed;
}
#endif
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1bc4fea124704b97b8280778a2ddab67
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
namespace NightclubArcadia.Skills
{
public enum CandidateClueStatus
{
Planned,
Authored,
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ac8a5835a8fe4dfe83346eb10009656a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,238 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace NightclubArcadia.Skills
{
[CreateAssetMenu(menuName = "Nightclub Arcadia/Candidate Database", fileName = "CandidateDatabase")]
public sealed class CandidateDatabase : ScriptableObject
{
static readonly Regex IdPattern = new Regex(@"^[a-z][a-z0-9_]*$", RegexOptions.Compiled);
static readonly Regex UnlockFlagPattern =
new Regex(@"^\$(visited|errand)_[a-z][a-z0-9_]*$", RegexOptions.Compiled);
const string SkillDatabasePath = "Assets/Skills/SkillDatabase.asset";
[SerializeField] List<CandidateDefinition> candidates = new List<CandidateDefinition>();
Dictionary<string, CandidateDefinition> _lookup;
public IReadOnlyList<CandidateDefinition> All => candidates;
public bool TryGet(string candidateId, out CandidateDefinition def)
{
EnsureLookup();
if (string.IsNullOrEmpty(candidateId))
{
def = null;
return false;
}
return _lookup.TryGetValue(candidateId, out def);
}
void EnsureLookup()
{
if (_lookup != null)
{
return;
}
_lookup = new Dictionary<string, CandidateDefinition>(StringComparer.OrdinalIgnoreCase);
if (candidates == null)
{
return;
}
foreach (var candidate in candidates)
{
if (candidate == null || string.IsNullOrEmpty(candidate.Id))
{
continue;
}
_lookup[candidate.Id] = candidate;
}
}
void OnValidate()
{
_lookup = null;
if (candidates == null)
{
return;
}
var seenCandidates = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var seenClues = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var axesUsed = new HashSet<SkillAxis>();
var authoredCount = 0;
var plannedCount = 0;
SkillDatabase skillDb = null;
#if UNITY_EDITOR
skillDb = AssetDatabase.LoadAssetAtPath<SkillDatabase>(SkillDatabasePath);
#endif
foreach (var candidate in candidates)
{
if (candidate == null)
{
Debug.LogError($"[{nameof(CandidateDatabase)}] Null candidate entry in {name}.", this);
continue;
}
if (string.IsNullOrEmpty(candidate.Id))
{
Debug.LogError($"[{nameof(CandidateDatabase)}] Candidate '{candidate.name}' has an empty Id.", this);
continue;
}
if (!IdPattern.IsMatch(candidate.Id))
{
Debug.LogError(
$"[{nameof(CandidateDatabase)}] Candidate Id '{candidate.Id}' must match ^[a-z][a-z0-9_]*$.",
this);
}
if (!seenCandidates.Add(candidate.Id))
{
Debug.LogError($"[{nameof(CandidateDatabase)}] Duplicate candidate Id '{candidate.Id}'.", this);
}
if (candidate.IntuitionAxis == SkillAxis.Body)
{
Debug.LogError(
$"[{nameof(CandidateDatabase)}] Candidate '{candidate.Id}' uses intuitionAxis Body; " +
"Body is deliberately unattached to any candidate.",
this);
}
if (candidate.IntuitionAxis == SkillAxis.Specialist)
{
Debug.LogError(
$"[{nameof(CandidateDatabase)}] Candidate '{candidate.Id}' uses intuitionAxis Specialist; " +
"intuition clues attach to Reason / Social / Self only.",
this);
}
if (!axesUsed.Add(candidate.IntuitionAxis))
{
Debug.LogError(
$"[{nameof(CandidateDatabase)}] intuitionAxis {candidate.IntuitionAxis} is shared by " +
$"more than one candidate (including '{candidate.Id}').",
this);
}
var typeCounts = new Dictionary<CandidateEvidenceType, int>();
if (candidate.Clues == null)
{
Debug.LogError($"[{nameof(CandidateDatabase)}] Candidate '{candidate.Id}' has no clues.", this);
continue;
}
foreach (var clue in candidate.Clues)
{
if (clue == null)
{
Debug.LogError($"[{nameof(CandidateDatabase)}] Candidate '{candidate.Id}' has a null clue.", this);
continue;
}
if (string.IsNullOrEmpty(clue.Id) || !IdPattern.IsMatch(clue.Id))
{
Debug.LogError(
$"[{nameof(CandidateDatabase)}] Candidate '{candidate.Id}' clue Id '{clue.Id}' " +
"must match ^[a-z][a-z0-9_]*$.",
this);
}
else if (!seenClues.Add(clue.Id))
{
Debug.LogError($"[{nameof(CandidateDatabase)}] Duplicate clue Id '{clue.Id}'.", this);
}
if (!typeCounts.ContainsKey(clue.Type))
{
typeCounts[clue.Type] = 0;
}
typeCounts[clue.Type]++;
if (clue.Status == CandidateClueStatus.Authored)
{
authoredCount++;
}
else
{
plannedCount++;
}
if (!string.IsNullOrEmpty(clue.UnlockFlag) && !UnlockFlagPattern.IsMatch(clue.UnlockFlag))
{
Debug.LogError(
$"[{nameof(CandidateDatabase)}] Clue '{clue.Id}' unlockFlag '{clue.UnlockFlag}' " +
"must match ^\\$(visited|errand)_[a-z][a-z0-9_]*$ " +
"(exploration-gated, never skill-gated).",
this);
}
if (clue.Type == CandidateEvidenceType.Intuition)
{
if (string.IsNullOrEmpty(clue.SkillId))
{
Debug.LogError(
$"[{nameof(CandidateDatabase)}] Intuition clue '{clue.Id}' needs a skillId.",
this);
}
else if (skillDb != null)
{
if (!skillDb.TryGet(clue.SkillId, out var skill))
{
Debug.LogError(
$"[{nameof(CandidateDatabase)}] Intuition clue '{clue.Id}' skillId " +
$"'{clue.SkillId}' is not in SkillDatabase.",
this);
}
else if (skill.Axis != candidate.IntuitionAxis)
{
Debug.LogError(
$"[{nameof(CandidateDatabase)}] Intuition clue '{clue.Id}' skill " +
$"'{clue.SkillId}' is axis {skill.Axis}, but candidate " +
$"'{candidate.Id}' intuitionAxis is {candidate.IntuitionAxis}.",
this);
}
}
}
}
foreach (CandidateEvidenceType type in Enum.GetValues(typeof(CandidateEvidenceType)))
{
typeCounts.TryGetValue(type, out var count);
if (count != 1)
{
Debug.LogError(
$"[{nameof(CandidateDatabase)}] Candidate '{candidate.Id}' has {count} " +
$"{type} clue(s); expected exactly 1.",
this);
}
}
}
Debug.Log(
$"[{nameof(CandidateDatabase)}] clue coverage: {authoredCount} Authored / {plannedCount} Planned.",
this);
}
#if UNITY_EDITOR
public void EditorSetCandidates(List<CandidateDefinition> newCandidates)
{
candidates = newCandidates ?? new List<CandidateDefinition>();
_lookup = null;
}
#endif
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c4dc6e246e9a462dbae29cd42b16317e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,69 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace NightclubArcadia.Skills
{
// Generated from Assets/Candidates/candidates.json by CandidateSystemSetup.
// Hand-edits to .asset files are overwritten on script reload — edit the JSON.
[CreateAssetMenu(menuName = "Nightclub Arcadia/Candidate Definition", fileName = "NewCandidate")]
public sealed class CandidateDefinition : ScriptableObject
{
[SerializeField] string id;
[SerializeField] string displayName;
[TextArea(2, 6)] [SerializeField] string reading;
[SerializeField] SkillAxis intuitionAxis;
[SerializeField] List<CandidateClue> clues = new List<CandidateClue>();
public string Id => id;
public string DisplayName => displayName;
public string Reading => reading;
public SkillAxis IntuitionAxis => intuitionAxis;
public IReadOnlyList<CandidateClue> Clues => clues;
#if UNITY_EDITOR
public void EditorSet(CandidateEntry entry)
{
if (entry == null)
{
throw new ArgumentNullException(nameof(entry));
}
id = entry.id;
displayName = entry.displayName ?? string.Empty;
reading = entry.reading ?? string.Empty;
intuitionAxis = ParseEnumRequired<SkillAxis>(entry.intuitionAxis, nameof(entry.intuitionAxis), entry.id);
clues = new List<CandidateClue>();
if (entry.clues == null)
{
return;
}
foreach (var clueEntry in entry.clues)
{
if (clueEntry == null)
{
continue;
}
var clue = new CandidateClue();
clue.EditorSet(clueEntry);
clues.Add(clue);
}
}
static TEnum ParseEnumRequired<TEnum>(string value, string fieldName, string candidateId)
where TEnum : struct
{
if (!Enum.TryParse(value, ignoreCase: true, out TEnum parsed))
{
throw new ArgumentException(
$"Candidate '{candidateId}': invalid {fieldName} '{value}' for {typeof(TEnum).Name}.");
}
return parsed;
}
#endif
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1f0a63ccd8da4e5ab64dea83c64eeb6a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,37 @@
using System;
namespace NightclubArcadia.Skills
{
/// <summary>
/// JSON mirror of the candidate registry. Deserialized by CandidateSystemSetup;
/// also the EditorSet DTO for CandidateDefinition. Enum fields are strings in JSON.
/// Schema holds no weight, truth, or ranking fields — by design.
/// </summary>
[Serializable]
public sealed class CandidateRoot
{
public CandidateEntry[] candidates;
}
[Serializable]
public sealed class CandidateEntry
{
public string id;
public string displayName;
public string reading;
public string intuitionAxis;
public CandidateClueEntry[] clues;
}
[Serializable]
public sealed class CandidateClueEntry
{
public string id;
public string type;
public string sceneId;
public string unlockFlag;
public string status;
public string skillId;
public string notes;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 01cb3b5f0ea14f69bd63314fd779f695
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,17 @@
namespace NightclubArcadia.Skills
{
/// <summary>
/// The four evidence kinds a candidate may carry. Exactly one of each per candidate.
///
/// Kept separate from <see cref="TraceChannel"/> — the vocabularies nearly align
/// (Document↔Documentary, Object↔Physical, Testimony↔Testimonial, Intuition↔Behavioural)
/// but collapsing them would quietly redefine one of them.
/// </summary>
public enum CandidateEvidenceType
{
Object,
Testimony,
Document,
Intuition,
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8538e846bf0d40498e12502099477435
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,180 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using NUnit.Framework;
using NightclubArcadia.Skills;
using UnityEditor;
using UnityEngine;
namespace NightclubArcadia.Skills.Tests
{
public class CandidateRegistryTests
{
const string DatabasePath = "Assets/Candidates/CandidateDatabase.asset";
const string SkillDatabasePath = "Assets/Skills/SkillDatabase.asset";
const string CommonYarnPath = "Assets/Dialogue/Common.yarn";
static readonly Regex IdPattern = new Regex(@"^[a-z][a-z0-9_]*$");
static readonly Regex UnlockFlagPattern =
new Regex(@"^\$(visited|errand)_[a-z][a-z0-9_]*$");
CandidateDatabase LoadDatabase()
{
// Assets are checked in (generated by CandidateSystemSetup). Same pattern as
// SkillRosterTests — the test assembly cannot reference the Editor setup type.
var db = AssetDatabase.LoadAssetAtPath<CandidateDatabase>(DatabasePath);
Assert.IsNotNull(db, $"Expected CandidateDatabase at {DatabasePath}");
return db;
}
SkillDatabase LoadSkillDatabase()
{
var db = AssetDatabase.LoadAssetAtPath<SkillDatabase>(SkillDatabasePath);
Assert.IsNotNull(db, $"Expected SkillDatabase at {SkillDatabasePath}");
return db;
}
[Test]
public void Registry_HasSymmetricClues_UniqueNonBodyIntuitionAxes_AndValidIds()
{
var db = LoadDatabase();
var skills = LoadSkillDatabase();
Assert.GreaterOrEqual(db.All.Count, 1, "Registry must contain at least one candidate.");
var seenCandidates = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var seenClues = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var axesUsed = new HashSet<SkillAxis>();
foreach (var candidate in db.All)
{
Assert.IsNotNull(candidate);
Assert.IsTrue(IdPattern.IsMatch(candidate.Id), $"Bad candidate id '{candidate.Id}'");
Assert.IsTrue(seenCandidates.Add(candidate.Id), $"Duplicate candidate id '{candidate.Id}'");
Assert.AreNotEqual(
SkillAxis.Body,
candidate.IntuitionAxis,
$"Candidate '{candidate.Id}' must not use Body as intuitionAxis.");
Assert.AreNotEqual(
SkillAxis.Specialist,
candidate.IntuitionAxis,
$"Candidate '{candidate.Id}' must not use Specialist as intuitionAxis.");
Assert.IsTrue(
axesUsed.Add(candidate.IntuitionAxis),
$"intuitionAxis {candidate.IntuitionAxis} shared by more than one candidate.");
Assert.IsNotNull(candidate.Clues);
var typeCounts = new Dictionary<CandidateEvidenceType, int>();
foreach (var clue in candidate.Clues)
{
Assert.IsNotNull(clue);
Assert.IsTrue(IdPattern.IsMatch(clue.Id), $"Bad clue id '{clue.Id}'");
Assert.IsTrue(seenClues.Add(clue.Id), $"Duplicate clue id '{clue.Id}'");
if (!typeCounts.ContainsKey(clue.Type))
{
typeCounts[clue.Type] = 0;
}
typeCounts[clue.Type]++;
if (clue.Type == CandidateEvidenceType.Intuition)
{
Assert.IsFalse(string.IsNullOrEmpty(clue.SkillId),
$"Intuition clue '{clue.Id}' needs skillId.");
Assert.IsTrue(
skills.TryGet(clue.SkillId, out var skill),
$"Intuition clue '{clue.Id}' skillId '{clue.SkillId}' missing from SkillDatabase.");
Assert.AreEqual(
candidate.IntuitionAxis,
skill.Axis,
$"Intuition clue '{clue.Id}' skill axis mismatch.");
}
}
foreach (CandidateEvidenceType type in Enum.GetValues(typeof(CandidateEvidenceType)))
{
typeCounts.TryGetValue(type, out var count);
Assert.AreEqual(
1,
count,
$"Candidate '{candidate.Id}' must have exactly one {type} clue.");
}
}
}
[Test]
public void Registry_UnlockFlags_AreExplorationGated_AndDeclared()
{
var db = LoadDatabase();
var commonYarn = File.ReadAllText(CommonYarnPath);
foreach (var candidate in db.All)
{
foreach (var clue in candidate.Clues)
{
if (string.IsNullOrEmpty(clue.UnlockFlag))
{
continue;
}
Assert.IsTrue(
UnlockFlagPattern.IsMatch(clue.UnlockFlag),
$"Clue '{clue.Id}' unlockFlag '{clue.UnlockFlag}' must match " +
@"^\$(visited|errand)_[a-z][a-z0-9_]*$.");
Assert.IsTrue(
commonYarn.Contains($"<<declare {clue.UnlockFlag}"),
$"Clue '{clue.Id}' unlockFlag '{clue.UnlockFlag}' is not declared in Common.yarn.");
}
}
}
[Test]
public void Registry_NoCandidateUnlockFlagSetIsStrictSupersetOfAnother()
{
// Proxy, not a proof: if candidate A's unlock flags are a strict superset of B's,
// A is reliably discoverable only after B. Genuine order coverage is a playtest question.
var db = LoadDatabase();
var flagSets = new List<(string id, HashSet<string> flags)>();
foreach (var candidate in db.All)
{
var flags = new HashSet<string>(StringComparer.Ordinal);
foreach (var clue in candidate.Clues)
{
if (!string.IsNullOrEmpty(clue.UnlockFlag))
{
flags.Add(clue.UnlockFlag);
}
}
flagSets.Add((candidate.Id, flags));
}
for (var i = 0; i < flagSets.Count; i++)
{
for (var j = 0; j < flagSets.Count; j++)
{
if (i == j)
{
continue;
}
var a = flagSets[i];
var b = flagSets[j];
if (a.flags.Count <= b.flags.Count)
{
continue;
}
Assert.IsFalse(
b.flags.IsSubsetOf(a.flags) && !a.flags.SetEquals(b.flags),
$"Candidate '{a.id}' unlock-flag set is a strict superset of '{b.id}' — " +
"that makes discovery order reliably stacked.");
}
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a525c84fb92f402d863adb1ae811ffd0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,174 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using NUnit.Framework;
using NightclubArcadia.Skills;
using UnityEditor;
namespace NightclubArcadia.Skills.Tests
{
/// <summary>
/// Source lint over Assets/Dialogue/**/*.yarn. This is a tripwire, not a proof —
/// it catches the easy-and-wrong wirings, not every possible one.
/// </summary>
public class CandidateYarnLintTests
{
const string DialogueRoot = "Assets/Dialogue";
const string DatabasePath = "Assets/Candidates/CandidateDatabase.asset";
static readonly Regex LeanWrite = new Regex(
@"\$self_lean_([a-z][a-z0-9_]*)",
RegexOptions.Compiled);
static readonly Regex FoundWrite = new Regex(
@"\$found_([a-z][a-z0-9_]*)",
RegexOptions.Compiled);
static readonly Regex LeanVsLean = new Regex(
@"\$self_lean_[a-z][a-z0-9_]*\s*(>=|<=|==|!=|>|<)\s*\$self_lean_[a-z][a-z0-9_]*",
RegexOptions.Compiled);
static readonly Regex NodeSplit = new Regex(
@"^title:\s*(.+)\s*$",
RegexOptions.Compiled | RegexOptions.Multiline);
CandidateDatabase LoadDatabase()
{
// Assets are checked in (generated by CandidateSystemSetup). Same pattern as
// SkillRosterTests — the test assembly cannot reference the Editor setup type.
var db = AssetDatabase.LoadAssetAtPath<CandidateDatabase>(DatabasePath);
Assert.IsNotNull(db, $"Expected CandidateDatabase at {DatabasePath}");
return db;
}
static IEnumerable<string> AllYarnFiles()
{
return Directory.GetFiles(DialogueRoot, "*.yarn", SearchOption.AllDirectories);
}
static string Read(string path) => File.ReadAllText(path);
[Test]
public void Lint_LeanAndFoundWrites_MatchRegistryIds()
{
var db = LoadDatabase();
var candidateIds = new HashSet<string>(
db.All.Select(c => c.Id),
StringComparer.OrdinalIgnoreCase);
var clueIds = new HashSet<string>(
db.All.SelectMany(c => c.Clues).Select(c => c.Id),
StringComparer.OrdinalIgnoreCase);
foreach (var path in AllYarnFiles())
{
var text = Read(path);
foreach (Match match in LeanWrite.Matches(text))
{
var id = match.Groups[1].Value;
Assert.IsTrue(
candidateIds.Contains(id),
$"{path}: $self_lean_{id} has no matching candidate id in the registry.");
}
foreach (Match match in FoundWrite.Matches(text))
{
var id = match.Groups[1].Value;
Assert.IsTrue(
clueIds.Contains(id),
$"{path}: $found_{id} has no matching clue id in the registry.");
}
}
}
[Test]
public void Lint_AuthoredClues_HaveFoundWrites()
{
var db = LoadDatabase();
var allYarn = string.Join("\n", AllYarnFiles().Select(Read));
foreach (var candidate in db.All)
{
foreach (var clue in candidate.Clues)
{
if (clue.Status != CandidateClueStatus.Authored)
{
continue;
}
var needle = $"<<set $found_{clue.Id} = true>>";
Assert.IsTrue(
allYarn.Contains(needle),
$"Authored clue '{clue.Id}' has no `{needle}` anywhere under {DialogueRoot}.");
}
}
}
[Test]
public void Lint_NoLeanVsLeanComparison()
{
foreach (var path in AllYarnFiles())
{
var text = Read(path);
Assert.IsFalse(
LeanVsLean.IsMatch(text),
$"{path}: comparing two $self_lean_* values against each other is forbidden " +
"(no derived winner). Comparing one lean against a constant is fine.");
}
}
[Test]
public void Lint_ClueAndLeanWrites_NotSkillGated()
{
foreach (var path in AllYarnFiles())
{
var text = Read(path);
var matches = NodeSplit.Matches(text);
for (var i = 0; i < matches.Count; i++)
{
var start = matches[i].Index;
var end = i + 1 < matches.Count ? matches[i + 1].Index : text.Length;
var nodeBody = text.Substring(start, end - start);
var title = matches[i].Groups[1].Value.Trim();
var writesLeanOrFound =
nodeBody.Contains("$self_lean_") || nodeBody.Contains("$found_");
if (!writesLeanOrFound)
{
continue;
}
Assert.IsFalse(
nodeBody.Contains("skill_rank("),
$"{path} node '{title}': a node that writes $self_lean_* / $found_* " +
"must not sit behind a skill_rank(...) guard.");
}
}
}
[Test]
public void Lint_CommentaryNodes_NeverWriteLeanOrFound()
{
foreach (var path in AllYarnFiles())
{
var text = Read(path);
var matches = NodeSplit.Matches(text);
for (var i = 0; i < matches.Count; i++)
{
var title = matches[i].Groups[1].Value.Trim();
if (!title.StartsWith("Commentary_", StringComparison.Ordinal))
{
continue;
}
var start = matches[i].Index;
var end = i + 1 < matches.Count ? matches[i + 1].Index : text.Length;
var nodeBody = text.Substring(start, end - start);
Assert.IsFalse(
nodeBody.Contains("$self_lean_") || nodeBody.Contains("$found_"),
$"{path} node '{title}': Commentary_* nodes must not write lean or found flags " +
"(ambient advocacy only; clue lines are scene-authored).");
}
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4b60d38cd1ee4eccb73aa11b23b94695
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,864 @@
# Candidate System — Implementation Plan
**Audience:** the developer or agent implementing this. Assume no prior context on this project and no
access to any design document. Everything needed is in this file. Nothing here says "see the design
doc," because you do not have one.
**Repo root:** `/Users/lennart/Dev/nightlcub-arcadia`
**Unity project:** `NightclubArcadia/` — Unity `6000.5.8f1`, URP, Yarn Spinner 3.2.8 (vendored).
**Unity editor binary:** `/Applications/Unity/Hub/Editor/6000.5.8f1/Unity.app/Contents/MacOS/Unity`
**Status:** implemented (Phases 0–5). A predecessor plan,
[`docs/candidate-system-plan.md`](./candidate-system-plan.md), covered the first slice of this
feature, and its Steps 1, 2 and 4 landed in commit `22b8c7a`. That document has been marked
superseded and points here. Its §1 design summary and §8 traps remain accurate. §1 below was the
survey at plan-writing time; the code now also contains the Phase 1–5 deliverables
(`Assets/Candidates/`, lint/registry tests, Common.yarn flags, SC-101 prose, `Debug_CandidateCheck`).
---
## 0. How to read this
- §1 is a survey of the codebase as it stands, with paths. Read it first; most of the machinery this
feature needs already exists and must be extended, not re-invented.
- §2 restates the whole design — the Voice-System it rides on, and the Candidate-System itself.
It is decided intent, not a proposal. Do not redesign it.
- §3–§4 fix names and architecture decisions.
- §5 is the phased build. Each phase is independently buildable and testable.
- §6 turns the design's guardrails into enforceable implementation constraints.
- §7 lists decisions that are **genuinely open**. Do not resolve them; raise them with the lead
developer. Where a placeholder was needed to keep a phase buildable, it is marked *provisional*.
- §8 lists traps that have already bitten this codebase.
---
## 1. Survey — what exists in the repo today
### 1.1 Dialogue and variable storage
| Thing | Path | Notes |
|---|---|---|
| Yarn Spinner 3.2.8 | `NightclubArcadia/Packages/dev.yarnspinner.unity/` | Vendored as an embedded package; **not** listed in `Packages/manifest.json`. |
| Yarn project | `Assets/Dialogue/NightclubArcadia.yarnproject` | Compiles `**/*.yarn` under `Assets/Dialogue/`. **New `.yarn` files are picked up automatically** — no importer change needed to add a scene. |
| Working scene | `Assets/Scenes/DialogueTest.unity` | Contains the `Dialogue System` objects: `DialogueRunner`, Line Presenter, Options Presenter, Line Advancer, Canvas, and a stock **`InMemoryVariableStorage`**. `autoStart: 1`, `startNode: "Start"`. |
| Template leftover | `Assets/Scenes/SampleScene.unity` | The only scene in `EditorBuildSettings`. `DialogueTest` is opened by hand. |
Existing `.yarn` files, all under `Assets/Dialogue/`:
| File | Contents |
|---|---|
| `Common.yarn` | The `Declarations` node. Never played. **Every variable in the project is declared here.** |
| `Scenes/Entrance.yarn` | `Start`, `Entrance_Inside`, `Entrance_Outside`. Scene files stay thin: set the stage, `<<detour>>` into character nodes, branch on the outcome. |
| `Scenes/SC101.yarn` | The opening candidate scene. Structure and flag writes are in; prose is `TODO`. See §1.4. |
| `Scenes/Debug.yarn` | `Debug_SkillCheck`, `Debug_CandidateLean`. Not story content; exists to exercise mechanics in isolation. **This is the precedent for verifying each phase below.** |
| `Characters/Bouncer.yarn` | Four nodes all titled `Bouncer_Talk` — a Yarn 3 node group with `when:` saliency variations. |
| `Commentary.yarn` | Passive skill barks. Node names follow `Commentary_{contextId}_{skillId}`. |
Every one of these opens with a `//` header comment explaining the file's role and its writing
conventions. **Match that.**
**Persistence.** There is no custom variable-storage subclass, no save system, and no game-state
singleton beyond `SkillRuntime`. There does not need to be:
- `VariableStorageBehaviour` (`Packages/dev.yarnspinner.unity/Runtime/Storage/VariableStorageBehaviour.cs`)
exposes `SetValue(string, float/bool/string)`, `TryGetValue<T>`, `GetAllVariables()`,
`SetAllVariables(...)`, `Contains(...)`, `AddChangeListener(...)`.
- `DialogueRunner.SaveStateToPersistentStorage(fileName)` / `LoadStateFromPersistentStorage(fileName)`
(`Runtime/DialogueRunner/DialogueRunner.Utility.cs`) serialise the whole variable storage to JSON
under `Application.persistentDataPath`. **This is the persistence mechanism. Do not build another.**
No call site exists yet (§7.6).
- The yarnproject importer's generated-variables feature (`generateVariablesSourceFile`) is **off**.
Leave it off.
### 1.2 The skill-voice system (the Voice-System, in code)
Assembly `NightclubArcadia.Skills` — `Assets/Scripts/Skills/NightclubArcadia.Skills.asmdef`,
references `YarnSpinner.Unity` and `Unity.TextMeshPro`.
| Piece | Path | Relevance here |
|---|---|---|
| `SkillAxis` enum | `Scripts/Skills/Data/SkillAxis.cs` | `{ Reason, Body, Social, Self, Specialist }`. **This is the design's "voice pair" concept.** Do not duplicate it. |
| `TraceChannel` enum | `Scripts/Skills/Data/TraceChannel.cs` | `{ None, Documentary, Behavioural, Testimonial, Physical }`. |
| `ClueYield` enum | `Scripts/Skills/Data/ClueYield.cs` | `{ Low, Medium, High }` — a per-voice authoring hint, not a clue object. |
| `DcBand` enum | `Scripts/Skills/Data/DcBand.cs` | `Trivial=8, Routine=11, Hard=14, Specialist=17, BuildDefining=20`. `DcBands.TryParse` accepts band names or raw ints (raw ints log a warning). |
| `SkillCheckDegree` | `Scripts/Skills/Data/SkillCheckDegree.cs` | `{ CriticalFailure, Failure, Success, CriticalSuccess }`. |
| `SkillDefinition` | `Scripts/Skills/Data/SkillDefinition.cs` | ScriptableObject per voice. **Generated** from JSON; hand-edits to `.asset` files are overwritten on script reload. |
| `SkillDatabase` | `Scripts/Skills/Data/SkillDatabase.cs` | Roster ScriptableObject. Its `OnValidate()` runs a full audit: id regex `^[a-z][a-z0-9_]*$`, duplicates, `opposedTo` symmetry, per-axis counts. **This is the pattern to copy in Phase 1.** |
| `skill_bible.json` | `Assets/Skills/skill_bible.json` | Writer-facing source of truth for the roster. 11 voices. |
| `SkillSystemSetup` | `Assets/Editor/SkillSystemSetup.cs` | `[DidReloadScripts]` generator: JSON → `.asset` files → database → scene wiring. |
| `PlayerSkills` | `Scripts/Skills/Runtime/PlayerSkills.cs` | Plain C#. Ranks only, seeded from each definition's `startingRank`. **In-memory; not persisted.** |
| `SkillCheckSystem` | `Scripts/Skills/Runtime/SkillCheckSystem.cs` | Plain C#. `Resolve(skillId, dc, situationalMod)` = d20 + rank + situational + state; nat 1 crit-fail, nat 20 crit-success, else `total >= dc`. |
| `SkillStateModifiers` | `Scripts/Skills/Runtime/SkillStateModifiers.cs` | Nested dict `sourceKey → (skillId → delta)`; `"*"` wildcard supported. |
| `SkillRuntime` | `Scripts/Skills/Runtime/SkillRuntime.cs` | Composition root + singleton the static Yarn commands route through. |
| `CommentarySystem` | `Scripts/Skills/Commentary/CommentarySystem.cs` | Fires **one** passive bark per request from `Commentary_{contextId}_{skillId}`, weighted by rank, gated by per-skill budget, recency, and a global floor. See §8.2. |
| `YarnSkillCommands` | `Scripts/Skills/Yarn/YarnSkillCommands.cs` | The entire Yarn↔C# surface: `<<check>>`, `<<skill_mod>>`, `<<clear_skill_mods>>`, `skill_rank()`. |
| Tests | `Assets/Tests/EditMode/` | `SkillRosterTests`, `SkillCheckSystemTests`, `CommentaryPickerTests`, `DcBandTests`. `SkillRosterTests` loads the real `SkillDatabase.asset` via `AssetDatabase` and asserts roster invariants — **the pattern to copy in Phase 1.** |
The roster in `skill_bible.json` matches the design cast exactly:
| id | display | axis | opposedTo | primary / secondary channel |
|---|---|---|---|---|
| `provenance` | PROVENANCE | Reason | `confluence` | Documentary / Physical |
| `confluence` | CONFLUENCE | Reason | `provenance` | Behavioural / Testimonial |
| `house_pour` | HOUSE POUR | Body | `long_shift` | Behavioural / Physical |
| `long_shift` | THE LONG SHIFT | Body | `house_pour` | Physical / Behavioural |
| `facework` | FACEWORK | Social | `placement` | Testimonial / Behavioural |
| `placement` | PLACEMENT | Social | `facework` | Testimonial / None |
| `amnesty` | AMNESTY | Self | `standing_order` | Behavioural / None |
| `standing_order` | STANDING ORDER | Self | `amnesty` | Testimonial / None |
| `undisclosed` | UNDISCLOSED | Specialist | `provenance` | Physical / Behavioural |
| `the_float` | THE FLOAT | Specialist | `amnesty` | Documentary / Physical |
| `room_tone` | ROOM TONE | Specialist | `placement` | Physical / Testimonial |
**Nothing needs to be added to the roster for this feature.**
### 1.3 Candidate state that already exists
`Assets/Dialogue/Common.yarn` already declares, with a comment block stating the no-winner guardrail:
```yarn
<<declare $self_lean_made_asset = 0 as number>>
<<declare $self_lean_journalist = 0 as number>>
<<declare $self_lean_inheritor = 0 as number>>
<<declare $seen_sc_101 = false as bool>>
```
`Assets/Dialogue/Scenes/Debug.yarn` has `Debug_CandidateLean`, which increments the accumulators and
branches on a constant threshold — the round-trip proof.
### 1.4 SC-101 as it stands
`Assets/Dialogue/Scenes/SC101.yarn` exists with a house-style header and five nodes: an entry node
`SC101_Desk` that sets `$seen_sc_101` and offers four `<<detour>>` routes, plus four hook nodes —
`SC101_Hook_MadeAsset` (`CONFLUENCE:`), `SC101_Hook_Journalist` (`FACEWORK:`),
`SC101_Hook_Inheritor` (`STANDING ORDER:`) and `SC101_Hook_Neutral` (`HOUSE POUR:`, deliberately
carrying no lean write). Every line of prose is a `TODO` placeholder.
### 1.5 What does **not** exist
State these plainly, because they are the starting conditions and two of them are dependencies:
- **No clue, evidence, or deduction objects of any kind.** No registry, no ids, no discovery flags.
`docs/skill-system-refactor-plan.md` §6 explicitly lists this as out of scope for that pass:
*"That system does not exist in this repo and is out of scope. Do not invent a schema for it."*
This plan is where it gets invented, deliberately and minimally (Phase 1).
- **No errand, quest, Act, location, or scene-progression system.** Same doc, same section, lists
"Act / errand state, or any gating for documentary routes" as an explicit non-goal. The
Candidate-System *requires* exploration gating (§2.6), so Phase 4 proposes the minimal new
structure — see §7.2 for who owns the larger system.
- **No skill-rank progression.** `PlayerSkills.SetRank` has no caller anywhere outside its own file
and the tests. Ranks come from `startingRank` and never change, and are not persisted. The design
premise "a player who has invested in a voice pair hears more advocacy" therefore has no mechanism
behind it yet. This does not block anything here — see §7.5.
- **No save/load call site.** The Yarn API in §1.1 is present but never called.
- **No `CLAUDE.md`, no project `README.md`.**
- **No representation of the two permanently unresolved lore threads** referred to internally as
"Thread 2" and "Thread 9". A repo-wide grep for thread identifiers returns nothing. See §6.8 —
they are off-limits for this feature regardless.
- **No NPC content beyond `Bouncer.yarn`.** In particular, the NPC named in
`docs/candidate-system-plan.md` §7.6 as a possible Inheritor link does not exist in code. See §7.3.
### 1.6 Working tree
`git status` is clean at the time of writing. The `TODO: ambient fallback` note at the top of
`Commentary.yarn` is committed, not a stray edit; it is unrelated to this feature. Leave it alone.
---
## 2. The design, restated in full
This section is the complete statement of intent. It is **decided design, not a proposal.** Where
something is genuinely open, §7 says so.
### 2.1 The game
NightclubArcadia is a mystery-driven, dialogue-heavy narrative game. Combat is a lightweight
secondary system; the core is investigation and writing. One developer, roughly a one-year horizon.
### 2.2 The Voice-System — what a "skill" is here
A skill in this game is **not** a stat that unlocks a door. It is one of eleven internal character
voices living in the protagonist's head, each with its own domain, agenda, blind spot, register and
verbal tics. Skill text is written as that character's *opinion*, never as neutral system text.
The cast is four opposed pairs plus three specialists:
- **Reason** — PROVENANCE (*things have an author; a paper trail is a confession*) vs.
CONFLUENCE (*things have a shape across time; connection is authorship*).
- **Body** — HOUSE POUR (*appetite is honesty*) vs. THE LONG SHIFT (*endurance is honesty*).
- **Social** — FACEWORK (*know a person to understand them*) vs. PLACEMENT (*know a person to
position them*).
- **Self** — AMNESTY (*letting go is mercy*) vs. STANDING ORDER (*holding the line is identity*).
- **Specialists**, with no opposite, covering trace channels the pairs miss — UNDISCLOSED
(leverage and reserves), THE FLOAT (money in motion), ROOM TONE (overheard sound).
There are two kinds of skill text:
- **Passives** — fire unprompted, cost nothing, and are the large majority of all skill text.
- **Active checks** — dice rolls against a visible difficulty number, used sparingly (on the order of
once every few minutes of play), and only where *both* success and failure are worth writing.
**Failure is never "nothing happens."** A failed check does one of five things:
1. hands the player a **wrong-but-confident conclusion** (the most common case);
2. **costs something** instead of information;
3. **closes one route while opening an uglier one**;
4. **teaches the player something about themselves** rather than about the case;
5. is **logged silently** and resurfaces later.
Wrong conclusions are never flagged as wrong in the prose. Only the roll's pass/fail state appears in
the UI banner; what is contaminated downstream is not marked. This is already the codebase's
convention — `Common.yarn` and `Commentary.yarn` both carry the header note *"Wrong Truth failures
carry no failure signal in the prose — the UI banner is the only tell."*
Every piece of evidence in the game is reachable through at least one of four **trace channels**:
documentary, physical, testimonial, behavioural. Each voice has a primary and a secondary channel
(table in §1.2).
### 2.3 The problem the Candidate-System solves
The protagonist has amnesia — a **dissociative fugue**. They have lost their own identity, not an
external memory. This is designed to be **genuinely open-ended**: there is no secret canonical answer
the writers are withholding, and no hidden ground-truth value anywhere in the data or the save file.
### 2.4 The three candidates
They are allowed to overlap and coexist. They are not mutually exclusive, and none of them is "the
twist."
| Id | Name | Reading |
|---|---|---|
| `made_asset` | **The Made Asset** | The protagonist was trained, conditioned, or activated by one of the story's factions, for a role near its central conference. |
| `journalist` | **The Journalist** | The protagonist came for a specific *person*, not a story. "Journalist" is a cover identity. |
| `inheritor` | **The Inheritor** | This was never the protagonist's story. A parent or older relative had standing, or a debt, with a faction; it passed to the protagonist without full knowledge or consent. |
### 2.5 Symmetric evidence structure
Each candidate has **exactly one object clue, one testimony clue, one document clue, and one
intuition clue.** The symmetry is deliberate: no evidence type should feel more trustworthy than
another *across* candidates, so a player's growing belief comes from which clues they happen to find
and choose to weight, never from the system tipping its hand through an imbalance.
This is an invariant to enforce mechanically as content grows (Phase 1), not a coincidence of the
current content.
Each candidate's intuition clue is attached to a **different voice pair**, so that trusting "the
smart voice" or "the gut voice" doesn't systematically favour one candidate:
| Candidate | Voice pair (`SkillAxis`) | Shape of the intuition |
|---|---|---|
| `made_asset` | **Reason** | Parsing something too fluently, before consciously trying to. |
| `journalist` | **Social** | A physical or emotional reaction that lands wrong, which the protagonist can't yet explain. |
| `inheritor` | **Self** | Correcting an old etiquette rule they never consciously learned. |
| — | **Body** | **Attached to no candidate, on purpose.** It stays neutral texture, so that not every voice pulls the player toward a theory of their identity. |
Because each pair is two characters with opposite blind spots, a line leaning toward a candidate is
**that character arguing for it in its own biased register** — never a flat statement of fact. Both
voices of an attached pair may argue for the same candidate from opposite directions; which of the
two speaks in a given beat is a writing decision.
### 2.6 How belief accumulates
- There is **no "reveal the truth" moment.** Belief in a candidate accumulates across the whole game
from which clues the player finds and which clues and voices they choose to trust.
- **Clue-surfacing order must be steerable through ordinary player choices** — which errands they
run, which locations they visit. It is **exploration-gated, never skill-gated.** This is a hard
constraint, and it exists specifically so that no candidate is reliably surfaced first across
playthroughs.
- A player who has invested in a given voice pair will naturally hear more advocacy for that pair's
attached candidate — but that is an **emergent consequence of play, not a system-imposed lock.**
Lean is a running per-candidate accumulator. The player is never asked to pick an identity, and
nothing ever resolves the three into a winner.
### 2.7 How it appears on the page
The Candidate-System is **not** a separate scene type and **not** separate UI. It rides inside the
ordinary Voice-System, using the same passive/active split and the same *voices argue, they don't
report* rule as everything else.
The opening scene, **SC-101 "The Card at the Desk,"** is the reference example: four voices — one per
pair — introduce themselves, three planting one candidate's intuition each, the fourth (Body) staying
neutral. None of these lines is proof; each is in-character noticing.
> **Flagged exception.** SC-101 uses **four** voice introductions, against a general
> *"cap introductions at three voices"* rule in the project's writing handbook (the handbook is not
> in this repo). This is a **deliberate, signed-off exception for the opening scene only.** Do not
> silently "correct" SC-101 down to three voices, and do not treat it as a new precedent that licenses
> four-voice beats elsewhere. See §7.4.
### 2.8 What this system is explicitly NOT
- Not a personality quiz with three outcomes.
- Not a hard branch that locks the player into one identity path early.
- Not resolved by a single reveal or one late dialogue choice.
- Must never produce a discoverable "correct answer" anywhere in game files or save data.
If a future feature (endings, epilogue text, achievements) needs one clean outcome, **that is a new,
separate design decision.** Nothing built here may silently assume it.
---
## 3. Names and conventions
These are already in force in the repo, enforced by `SkillDatabase.OnValidate()` and
`Assets/Tests/EditMode/SkillRosterTests.cs`. Defer to them.
- **Yarn variables:** `$snake_case`, every one declared in `Common.yarn`'s `Declarations` node, so a
typo is a compile error rather than a silently-created second variable.
- **Ids** (skills, and by extension candidates and clues): `^[a-z][a-z0-9_]*$`. Enforced because ids
become Yarn node-title and variable-name fragments (§8.4).
- **Yarn node titles:** `Start`, `{Scene}_{Beat}`, `{Character}_Talk`,
`Commentary_{ContextId}_{skillId}`, `Debug_*`. Context ids are PascalCase-with-underscores; skill
ids are lowercase.
- **Yarn commands:** lowercase snake_case.
- **C#:** namespace `NightclubArcadia.Skills`, classes `sealed` by default, `[SerializeField]` fields
bare `camelCase`, log prefixes `[TypeName]`.
- **Docs:** lowercase-kebab-case `.md`, flat in `docs/`.
Names locked by the predecessor plan and already in the repo — **do not rename them**, they are now
content-wide:
| Concept | Name |
|---|---|
| Candidate ids | `made_asset`, `journalist`, `inheritor` |
| Lean accumulators | `$self_lean_made_asset`, `$self_lean_journalist`, `$self_lean_inheritor` |
| Scene-witnessed flag | `$seen_<scene_id>`, e.g. `$seen_sc_101` |
| Scene id / node prefix | `SC101`, e.g. `SC101_Desk`, `SC101_Hook_MadeAsset` |
New names introduced by this plan:
| Concept | Name | Where |
|---|---|---|
| Clue ids | `<candidate_id>_<type>`, e.g. `journalist_document` | Phase 1 |
| Clue-found flags | `$found_<clue_id>`, e.g. `$found_journalist_document` (bool) | Phase 2 |
| Location-visited flags | `$visited_<location_id>` (bool) | Phase 4 |
| Errand-state flags | `$errand_<errand_id>` (bool) | Phase 4 |
---
## 4. Architecture decisions
**4.1 — Lean stays a plain Yarn number, written by plain `<<set>>`.**
Already the case, and it stays that way. A `<<add_lean made_asset 1>>` command would take the
candidate id as a *string*, throwing away the compile-time typo checking that declaring everything in
`Common.yarn` buys. Registry consistency is instead enforced at editor time by a lint test (Phase 2),
which gives the same safety without runtime coupling.
**4.2 — The candidate registry is descriptive, not authoritative over runtime.**
The registry (Phase 1) records *what evidence exists, of what type, gated behind what*. It holds no
weight, no truth value, no lean amount, and nothing from which a winner could be derived. No runtime
code reads lean out of it. Its job is to make the symmetry invariant enforceable and the gating
auditable.
**4.3 — Candidate clue text is deterministic and scene-authored; `CommentarySystem` is not the
vehicle.** `CommentarySystem` looks like an exact fit — it fires a named voice from a node — but it
is a *probabilistic, rank-weighted* channel (§8.2). Routing clue-bearing lines through it would make
discovery depend on skill rank, which is exactly the skill-gating that §2.6 forbids. Clue-bearing
lines are authored in scene nodes and reached by `<<detour>>`.
**Where the "invested players hear more advocacy" property comes from, then:**
`CommentarySystem`'s rank-weighted pick is precisely that mechanism — a higher-ranked voice speaks
more often. Candidate-*flavoured* commentary that carries **no clue and no lean** may ride
`CommentarySystem` freely. The split is: *advocacy is ambient and rank-weighted; evidence is
deterministic and exploration-gated.*
**4.4 — Lean is written on failed checks too.**
This follows from §2.3 and §2.2 rather than being a free choice, and it is easy to get wrong. A
failed candidate check most often hands the player a **wrong-but-confident conclusion**, which the
player believes. Lean tracks *belief*, not truth. If lean were only written on successes, the
accumulator would encode which readings were "really" supported, and a player reading their save file
could infer a ground truth that is not supposed to exist. So: a candidate-relevant check writes lean
on both branches, with the failure branch writing its own (possibly different) candidate's lean.
The *amount* is a separate open question (§7.1).
**4.5 — Exploration gating is expressed as data, not as prose discipline.**
Each clue entry names an unlock flag. A lint test forbids that flag from being a skill-derived
expression, and forbids clue-bearing nodes from sitting behind `skill_rank(...)` guards (Phase 4).
This is the one guardrail most likely to be violated by accident, because gating on rank is *easier*
to write than gating on exploration.
---
## 5. Phases
Each phase is independently buildable and independently testable. Phases 1–2 are engineering with no
prose; Phase 5 is where writing lands.
### Phase 0 — Confirm the landed slice, and prove persistence
**Hooks into:** `Common.yarn`, `Debug.yarn`, `DialogueRunner`'s save API.
**Adds:** nothing permanent.
Steps 1, 2 and 4 of the predecessor plan are in the repo (§1.3, §1.4). Step 3 — persistence — has no
committed evidence that it was ever run. Do it now, because everything later assumes it:
1. Open `Assets/Scenes/DialogueTest.unity`, temporarily point the `DialogueRunner`'s `startNode` at
`Debug_CandidateLean`, press Play. Expect `made_asset 2 / journalist 1 / inheritor 0` and the
threshold branch to fire. Restore `startNode` to `Start` afterwards.
2. From a throwaway `[ContextMenu]` component or editor script, call
`SaveStateToPersistentStorage("candidate-lean-smoketest.json")`, exit Play, re-enter, call
`LoadStateFromPersistentStorage(...)`, and confirm the values come back.
3. Inspect the JSON under `Application.persistentDataPath`: `$self_lean_*` should appear in
`floatKeys`/`floatValues`, `$seen_sc_101` in `boolKeys`/`boolValues`.
4. While there, record whether Yarn 3's `when: once` saliency state lives in the same variable
storage. If it does, saliency survives save/load for free. Either way it is **not this feature's
problem** — the explicit `$seen_*` bool convention exists precisely so nothing here depends on it.
5. **Delete the throwaway script.** When the game actually saves is a separate ticket (§7.6).
**Done when:** the round trip is confirmed and no temporary script remains.
---
### Phase 1 — The candidate registry and the symmetry invariant
**Hooks into:** the `skill_bible.json` → generator → `SkillDatabase` → `OnValidate` → EditMode-test
pattern. Mirror it exactly; do not invent a second idiom.
**Adds:** new files only. Touches no existing runtime code.
**This is genuinely new structure** — no clue or evidence system exists in this repo (§1.5). It is
kept as small as it can be while still making §2.5's invariant enforceable.
**1a. Writer-facing source of truth:** `Assets/Candidates/candidates.json`, mirroring
`Assets/Skills/skill_bible.json`. Shape:
```jsonc
{
"candidates": [
{
"id": "journalist",
"displayName": "The Journalist",
"reading": "The PC came for a specific person, not a story; 'journalist' is a cover.",
"intuitionAxis": "Social",
"clues": [
{
"id": "journalist_object",
"type": "Object",
"sceneId": "SC101",
"unlockFlag": "$visited_desk", // Phase 4; empty string until then
"status": "Planned", // Planned | Authored
"notes": "writer-facing one-liner"
}
// ... exactly one each of Object, Testimony, Document, Intuition
]
}
]
}
```
Intuition entries additionally carry `"skillId"` — the voice that speaks the line.
**1b. New enum** `CandidateEvidenceType { Object, Testimony, Document, Intuition }` in
`Assets/Scripts/Skills/Data/`. Keep it separate from `TraceChannel`: the two vocabularies nearly but
not exactly align (document↔Documentary, object↔Physical, testimony↔Testimonial,
intuition↔Behavioural), and collapsing them would quietly redefine one of them. Document the
correspondence in a comment.
**1c. New ScriptableObjects** `CandidateDefinition` and `CandidateDatabase` in the same folder,
following `SkillDefinition` / `SkillDatabase` in style: `[SerializeField]` private fields with
public getters, `#if UNITY_EDITOR EditorSet(...)`, `sealed`.
**1d. Generator** `Assets/Editor/CandidateSystemSetup.cs`, following `SkillSystemSetup.cs`: JSON →
`Assets/Candidates/Definitions/<id>.asset` → `Assets/Candidates/CandidateDatabase.asset`. Same
`[DidReloadScripts]` + `SessionState` guard. Put the *"the JSON is the source of truth; `.asset`
files are build output and hand-edits are lost"* comment at the top, as `SkillSystemSetup` does.
**1e. `CandidateDatabase.OnValidate()` audit**, following `SkillDatabase.OnValidate()`:
- Candidate ids match `^[a-z][a-z0-9_]*$`; no duplicates. Same for clue ids.
- **Exactly one clue of each of the four `CandidateEvidenceType` values per candidate.** This is the
§2.5 invariant. Assert it over the full *planned* set, so the design is symmetric from day one even
while most clues are unwritten; report `Authored` coverage separately as an informational
`Debug.Log`, the way `SkillDatabase` logs its channel tally.
- Each candidate's `intuitionAxis` resolves to a `SkillAxis`; **no two candidates share one**; and
**no candidate uses `SkillAxis.Body`** (§2.5 — Body is deliberately unattached).
- Every intuition clue's `skillId` resolves against `SkillDatabase`, and that skill's `Axis` equals
the candidate's `intuitionAxis`.
- **No field anywhere may express weight, truth, or ranking** — see §6.3. Enforce by keeping the
schema free of such fields; a reviewer reading the whole JSON must be unable to tell which
candidate is "right," because none is.
**1f. EditMode test** `Assets/Tests/EditMode/CandidateRegistryTests.cs`, following
`SkillRosterTests.cs`: load the real `CandidateDatabase.asset` via `AssetDatabase`, assert every
invariant in 1e as hard test failures rather than only editor logs.
**Extensibility is load-bearing.** Adding a fourth candidate must mean adding a JSON entry, one
`<<declare>>`, and content — never editing a `switch`, an enum, or a hardcoded list of three. No code
in this phase may enumerate the three by name.
**Done when:** the three candidates and their twelve clue entries exist in JSON, the database asset
generates, `CandidateRegistryTests` passes, and deliberately breaking symmetry in the JSON (delete
one clue, duplicate a type, point two candidates at the same axis, point one at `Body`) makes the
test fail with a legible message.
---
### Phase 2 — Clue-discovery state, and the lint that keeps lean honest
**Hooks into:** `Common.yarn`'s `Declarations` node; the Phase 1 registry; `Assets/Tests/EditMode/`.
**Adds:** one declaration per clue, and one editor-time lint test. No C# runtime code.
**2a. Declare a `$found_<clue_id>` bool per clue** in `Common.yarn`, grouped under a comment block
that says what they are and that no code reads them. Twelve declarations at three candidates × four
types. Writers set them at the moment the clue is discovered, alongside the lean write.
Why a flag per clue rather than reading the count off lean: lean is *belief*, which can be moved by a
wrong-but-confident failure (§4.4). `$found_*` is *coverage*, which is what the symmetry audit and
the Phase 4 gating actually need. Conflating them would make one of the two useless.
**2b. The lint test** `Assets/Tests/EditMode/CandidateYarnLintTests.cs`. It reads the `.yarn` files
under `Assets/Dialogue/` as text — this is a source lint, not a Yarn-runtime test — and asserts:
1. Every `$self_lean_*` and `$found_*` variable written anywhere in `.yarn` corresponds to a
candidate or clue id in the Phase 1 registry. Catches typos and orphaned content.
2. Every registry clue whose `status` is `Authored` has at least one `<<set $found_<clue_id> = true>>`
somewhere in `.yarn`. Catches content marked done that was never wired.
3. **No `.yarn` file contains a comparison of two `$self_lean_*` variables against each other**, and
no `<<declare>>` derives a variable from such a comparison. Comparing one lean against a *constant*
(`<<if $self_lean_journalist >= 3>>`) is fine and intended; comparing two leans is the
derived-winner the design forbids (§6.1, §6.2).
4. **No node that writes `$self_lean_*` or `$found_*` sits behind a `skill_rank(` guard** in the same
node, and no `<<if>>` guarding a `<<detour>>`/`<<jump>>` to such a node mentions `skill_rank(`.
This is the exploration-gating guardrail (§6.5) made mechanical.
Be honest in the file's header comment about the limits of a regex lint: it catches the easy-and-wrong
wirings, not every possible one. It is a tripwire, not a proof.
**Done when:** the lint passes on current content, and each of the four rules can be shown to fail by
deliberately introducing the violation it targets.
---
### Phase 3 — The authoring surface for candidate passives and active checks
**Hooks into:** existing Yarn commands (`<<check>>`, `<<detour>>`), `Commentary.yarn`'s node-naming
convention, `SkillCheckResultView`.
**Adds:** documented patterns and one debug node. **No new C# commands** — the existing surface is
sufficient, and adding one would trade compile-time safety for a string argument (§4.1).
**3a. The passive pattern (the large majority of candidate text).** A clue-bearing passive is a node
in the owning scene file, spoken by one voice, reached by `<<detour>>` from an exploration-gated beat:
```yarn
title: SC101_Hook_Journalist
---
FACEWORK: <line — FACEWORK arguing for this reading in its own biased register>
<<set $found_journalist_intuition = true>>
<<set $self_lean_journalist += 1>>
===
```
Rules, to be restated in each scene file's header comment:
- One voice per hook node. The voice must be from the candidate's attached pair (§2.5).
- The line is that character's **argument**, never a report. It must be legible as a biased reading
even when it happens to be right (§6.6).
- Exactly one `$self_lean_*` write per hook.
- No `<<if>>` guard on another hook having fired, on any `$self_lean_*`, or on `skill_rank(...)`.
**3b. The active-check pattern.** Reuse `<<check skillId band [situationalMod]>>`, which writes
`$check_result`, `$check_roll`, `$check_total`, `$check_dc`, `$check_modifier`, `$check_degree`,
`$check_skill` and shows the roll banner. Difficulty uses the named bands (`trivial`, `routine`,
`hard`, `specialist`, `build_defining` → 8/11/14/17/20); raw integers work but log a warning.
Candidate checks are **sparse** — active checks across the whole game run at roughly one every few
minutes, and only where both outcomes are worth writing. The failure branch must do one of the five
things in §2.2, and **carry no failure signal in the prose** — the banner is the only tell.
```yarn
<<check confluence hard>>
<<if $check_result>>
CONFLUENCE: <what it concludes>
<<set $found_made_asset_document = true>>
<<set $self_lean_made_asset += 1>>
<<else>>
CONFLUENCE: <a different, equally confident, wrong conclusion — unmarked>
<<set $self_lean_inheritor += 1>>
<<endif>>
```
Note the failure branch writing a *different* candidate's lean: that is the point of §4.4. A failure
that writes nothing is a failure that "does nothing," which the design forbids. Which candidate a
given failure feeds is a writing decision per check.
**3c. Ambient candidate advocacy** — lines that argue for a reading but carry **no clue and no lean**
— may use `CommentarySystem` via `Commentary_{contextId}_{skillId}` nodes. This is where the
"invested players hear more advocacy" property comes from (§4.3). Never put a `$found_*` or
`$self_lean_*` write in a `Commentary_*` node; the Phase 2 lint should be extended to assert that.
**3d. Debug node** `Debug_CandidateCheck` in `Debug.yarn`, exercising 3b with a seeded roll source
(`SkillRuntime.rollSeed` is non-zero → `SeededRollSource`) so both branches are reachable
deterministically.
**Done when:** the patterns are documented in the scene-file headers, `Debug_CandidateCheck` runs
both branches under a fixed seed, and the Phase 2 lint still passes.
---
### Phase 4 — Exploration-gated clue availability
**Hooks into:** `Common.yarn`; the Phase 1 registry's `unlockFlag`; the Phase 2 lint.
**Adds:** the minimal gating vocabulary. **This is new** — no errand, quest, Act, or location system
exists (§1.5), and this phase deliberately does not build one (§7.2).
The constraint, restated: **which candidate a player meets first must depend on where they choose to
go and what they choose to do — never on which voices they have invested in, and never on a fixed
script order.**
**4a. Two flag families**, declared in `Common.yarn` under a comment block explaining the rule:
- `$visited_<location_id>` (bool) — set once on first arrival at a location.
- `$errand_<errand_id>` (bool) — set when an errand reaches the state that matters.
Both are plain Yarn bools set by scene content. That is the whole mechanism for now. When a real
errand/quest system arrives, it can set the same flags, and nothing authored against them breaks.
**4b. Registry wiring.** Each clue's `unlockFlag` names exactly one of those variables. Add to the
Phase 1 `OnValidate` and to `CandidateRegistryTests`:
- Every non-empty `unlockFlag` matches `^\$(visited|errand)_[a-z][a-z0-9_]*$`. This is the
data-level enforcement: **a skill threshold cannot be expressed in this field at all.** It is not a
free-form condition string, precisely so nobody can put `skill_rank(...)` in it.
- Every `unlockFlag` used is declared in `Common.yarn` (grep the declarations file from the test).
- **Not** every clue needs a distinct flag, and clues from different candidates may share one. What
is forbidden is the pattern where all of one candidate's clues sit behind flags that are
themselves only reachable in a fixed order relative to another candidate's — see 4c.
**4c. The order-independence check.** Add to `CandidateRegistryTests`: collect the set of unlock
flags per candidate and assert that no candidate's flag set is a strict superset of another's. That
is a cheap structural proxy for "no candidate is reliably surfaced after another." It is a proxy, not
a proof — say so in the test's comment. Genuine order coverage is a playtest question, and belongs in
the checklist for Phase 5.
**4d. Playable verification.** For SC-101, gating is already handled structurally: four independently
reachable `<<detour>>` hooks from one desk node. Play it in three different orders (A→B→C, C→A→B, B
alone) and confirm the accumulator totals depend only on which hooks were visited, never on the order,
and that visiting one hook never removes or unlocks another.
**Done when:** the flag families are declared, every clue's `unlockFlag` validates, the superset
check passes, and the three-order playthrough gives order-independent totals.
---
### Phase 5 — SC-101 as the reference implementation
**Hooks into:** the existing `Assets/Dialogue/Scenes/SC101.yarn` skeleton (§1.4).
**Adds:** prose, the remaining clue types, and registry entries marked `Authored`.
SC-101 is *"The Card at the Desk."* Four voices — one per pair — introduce themselves. Three plant one
candidate's intuition each; the fourth, from the Body pair, stays neutral. None of the four lines is
proof. Each is in-character noticing.
1. **Write the four hook lines.** `CONFLUENCE:` for `made_asset` (parsing something too fluently,
before consciously trying to), `FACEWORK:` for `journalist` (a reaction that lands wrong and can't
yet be explained), `STANDING ORDER:` for `inheritor` (correcting an etiquette rule never
consciously learned), and a Body voice for the neutral beat. Which Body voice speaks — `HOUSE POUR`
or `THE LONG SHIFT` — is a writing decision; the skeleton currently has `HOUSE POUR`.
2. **Write `SC101_Desk`'s stage-setting and exit beats.**
3. **Restate the four-voice exception** in `SC101.yarn`'s header comment: this scene runs four voice
introductions against the handbook's three-voice cap, deliberately and for this scene only (§2.7,
§7.4). Writing it down in the file is what stops the next writer from either "fixing" it or
copying it.
4. **Author SC-101's non-intuition clues.** The scene currently carries only the three intuition
hooks. The object / testimony / document clues for each candidate do not exist yet anywhere. They
need not all live in SC-101 — most should not — but the registry must show the full symmetric
twelve as `Planned` from Phase 1 onward, and each becomes `Authored` as it is written.
5. **Flip `status` to `Authored`** in `candidates.json` for what was written, and confirm the Phase 2
lint's rule 2 passes.
**Done when:** SC-101 plays end to end with no `TODO` in the prose, the header states the four-voice
exception, all lint and registry tests pass, and the three-order playthrough from Phase 4d still gives
order-independent totals.
---
## 6. Guardrails as implementation constraints
Each of these is something a competent engineer would otherwise do by default. They are constraints,
not flavour text. Phase 1's `OnValidate`, Phase 2's lint, and Phase 4's registry rules are where most
of them are actually enforced.
1. **Never compute a winning candidate.** No `GetLeadingCandidate()`, no `$dominant_candidate`, no
sort, no max, no ranking, no "which is highest" comparison — not in C#, not in Yarn, not in a debug
UI, not in a log line. Comparing one lean against a *constant* threshold is fine and intended.
Comparing two leans against each other is what must not exist. (Phase 2 lint, rule 3.)
2. **Do not add a smart variable for lean.** `Common.yarn` already contains the idiom
(`<<declare $is_vip = $reputation > 10>>`) and it will be tempting to write
`<<declare $leans_journalist = $self_lean_journalist > $self_lean_made_asset>>`. That is exactly the
derived winner guardrail 1 forbids; being a smart variable does not make it less discoverable.
3. **No ground-truth field anywhere.** No `isTrue`, `isCanonical`, `actualIdentity`, or `weight` on any
candidate or clue record — not in JSON, not in a ScriptableObject, not in save data.
4. **Save data must not become the leak.** Persistence is a straight dump of Yarn variable storage.
As long as 1–3 hold, the save file contains three integers, some bools, and no answer. Any future
custom save format must preserve that property.
5. **Clue availability is exploration-gated, never skill-gated.** Enforced at the data level: an
`unlockFlag` is a `$visited_*`/`$errand_*` variable name and syntactically cannot express a skill
threshold (Phase 4b), and clue-bearing nodes may not sit behind `skill_rank(` guards (Phase 2 lint,
rule 4).
6. **No voice ever states a candidate as settled fact.** Every candidate-relevant line is that voice's
own biased reading, even when it happens to be right. This one is unenforceable by code and lives
in the scene-file header comments and in review.
7. **Extensibility is load-bearing.** Three candidates is the current count, not necessarily the final
one. Adding a fourth must mean a JSON entry, one `<<declare>>`, and content — never editing a
`switch`, an enum, or a hardcoded list of three.
8. **The two permanently unresolved lore threads are off-limits.** The project has two threads,
referred to internally as **"Thread 2"** and **"Thread 9"**, that are designed never to resolve.
The Candidate-System must stay **structurally separate** from them: do not resolve them, do not
reference their contents, and do not map any candidate clue onto them. A repo-wide grep finds **no
existing representation of either thread in the codebase** (§1.5), so there is currently nothing to
accidentally couple to — but if such a representation ever appears (a flag, an id, a data file),
candidate clue entries must not reference it, and the Phase 2 lint should grow a rule that fails if
they do. This plan deliberately does not state what the two threads contain, because it does not
need to know.
---
## 7. Open decisions — flag, do not resolve
Each needs the lead developer's sign-off. Where a placeholder was required to keep a phase buildable,
it is marked **provisional** and is safe to change later.
**7.1 — How lean is surfaced, and whether it decays or caps.**
Settled: lean accumulates from play, not from one choice. **Open:** whether the player's accumulated
lean is ever surfaced back to them during play, only reflected at the end, or stays invisible
entirely; and whether it decays over time, caps at a ceiling, or grows without bound. Also open:
whether `+1` per clue stays the only increment size, or larger commitments weigh more, and whether
any threshold means anything mechanically.
**Provisional default, to keep Phases 2–5 buildable:** `+1` per clue moment, no cap, no decay, never
surfaced in UI. Nothing in this plan forecloses the alternatives — lean is a plain number in Yarn
storage, so changing the arithmetic is a content edit, and "surface it" is additive UI work that has
been given no hooks in either direction.
**7.2 — The exploration-gating host system has no owner.**
The design requires clue availability to be errand- or location-gated. No errand, quest, Act, or
location system exists, and `docs/skill-system-refactor-plan.md` §6 explicitly declined to build one.
Phase 4 introduces two flag families as the minimal vocabulary, which is enough for SC-101 and for
one or two more scenes. **The moment candidate evidence spans several scenes, this becomes a hard
dependency on a system somebody has to design.** Flagging it as an open dependency — and specifically
warning against the tempting workaround of building a bespoke candidate-only gating mechanism, which
would be exactly the parallel system this feature is supposed to avoid.
**7.3 — Whether the Inheritor ties into the journalist-hook NPC.**
There is a possible narrative link between the Inheritor candidate and an existing NPC hook. **This is
a lore call, not a mechanical one, and this plan must not decide it.** Do not treat any link as canon
and do not wire anything to it. For reference: `docs/candidate-system-plan.md` §7.6 names a specific
NPC as the candidate for this link and also marks it undecided; no NPC by that name exists in the
codebase, which contains only `Bouncer.yarn`.
**7.4 — The four-voice opening against the three-voice cap.**
SC-101 introduces four voices; the writing handbook (not in this repo) caps introductions at three.
This is a **deliberate, flagged exception for the opening scene**, not an oversight to correct and not
a new precedent to propagate. Recorded here and in `SC101.yarn`'s header so that neither happens by
accident. If the lead developer wants the cap revisited generally, that is a separate handbook
decision.
**7.5 — Voice investment has no mechanism.**
"A player who has invested in a voice pair hears more advocacy for that pair's candidate" presumes
skill ranks change during play. They do not: `PlayerSkills.SetRank` has no caller, ranks come from
`startingRank`, and they are not persisted (§1.5). Nothing in this plan is blocked by that — the
rank-weighted `CommentarySystem` path (§4.3) will simply deliver a flat distribution until a
progression mechanism exists. Worth deciding whether progression is on the roadmap, since it changes
how much the ambient-advocacy channel is worth investing in.
**7.6 — When does the game save?**
Phase 0 proves the mechanism works. No call site exists. Somebody has to choose scene-exit vs.
autosave vs. manual. Separate ticket.
**7.7 — Total candidate touchpoints across the game.**
SC-101 carries the first three intuition hooks plus one neutral beat. The full count of candidate
moments across the game is unknown. It is the input to how much the Phase 1 registry earns its keep,
and to §7.1's question about increment sizes.
---
## 8. Traps
**8.1 — Yarn variables must be declared or they are compile errors.**
Every variable in this project is declared in `Common.yarn`. A `<<set $found_journalist_object = true>>`
without the matching `<<declare>>` fails to compile rather than silently creating a variable. This is
the feature, not the bug — it is what makes plain `<<set>>` safer than a string-keyed C# command
(§4.1).
**8.2 — `CommentarySystem` is the wrong vehicle for clue-bearing lines.**
It picks **one** voice per request, weighted by rank, gated by a per-skill budget
(`3600 / targetFiringsPerHour` seconds), a global `minSecondsBetween` floor, a `minRankToSpeak`
threshold, and a recency penalty — and it refuses to fire while dialogue is running. Clue lines must be
deterministic and each independently discoverable. Route them through scene nodes via `<<detour>>`
(§4.3, Phase 3a).
**8.3 — Node lookups are case-sensitive; skill lookups are not.**
`SkillDatabase` and `PlayerSkills` use `OrdinalIgnoreCase`, but `CommentarySystem.NodeExists` uses
`Ordinal`. So `<<check PLACEMENT routine>>` works while `Commentary_Entrance_Queue_PLACEMENT` silently
never matches. Keep every authored id lowercase.
**8.4 — Ids become node-title and variable-name fragments.**
This is why candidate and clue ids follow `^[a-z][a-z0-9_]*$`. An id with a space or a hyphen produces
a node title or variable name that either fails to compile or silently never matches.
**8.5 — Generated assets are overwritten on script reload.**
`SkillSystemSetup.AutoSetupAfterReload` regenerates every skill definition and the database once per
editor session from `skill_bible.json`. Phase 1's generator follows the same pattern, so the same rule
applies to candidates: **the JSON is the source of truth; `.asset` files are build output.** Hand-edits
to `.asset` files are lost.
**8.6 — Do not delete or recreate `SkillDatabase.asset`.**
`Assets/Scenes/DialogueTest.unity` wires `SkillRuntime.database` and `CommentarySystem.database` to it
by GUID. Nothing in this plan should touch it, but it is the standing landmine in this project. Phase 1
creates a *separate* `CandidateDatabase.asset`; wiring it into the scene (if it ever needs to be) is
additive and must not disturb the skill database's GUID.
**8.7 — Declared Yarn numbers are floats.**
`<<declare $x = 0 as number>>` stores a float and serialises into `floatKeys`/`floatValues`. Integer
comparisons behave correctly; do not be surprised by `0.0` in the save JSON.
**8.8 — `float` is a keyword.**
Restated from `docs/skill-system-refactor-plan.md` §7.5 because it will come up: the id for THE FLOAT
is `the_float`.
---
## 9. Definition of done
Per phase. Phases can land in separate commits; each should leave the project green.
**Phase 0**
- [ ] `Debug_CandidateLean` prints `made_asset 2 / journalist 1 / inheritor 0` and takes the threshold branch.
- [ ] Lean values and `$seen_sc_101` survive a save → exit Play → load round trip; verified in the JSON under `Application.persistentDataPath`.
- [ ] The temporary verification script is deleted.
**Phase 1**
- [ ] `Assets/Candidates/candidates.json` holds three candidates × four clue entries, one of each type.
- [ ] `CandidateEvidenceType`, `CandidateDefinition`, `CandidateDatabase`, and `CandidateSystemSetup` exist, following the skill-system idiom.
- [ ] `CandidateDatabase.OnValidate()` audits ids, per-candidate type symmetry, unique non-Body intuition axes, and intuition `skillId` → `SkillDatabase` resolution.
- [ ] `CandidateRegistryTests` passes, and fails legibly when symmetry, axis uniqueness, or the Body exclusion is deliberately broken.
- [ ] No code enumerates the three candidates by name.
**Phase 2**
- [ ] `Common.yarn` declares one `$found_<clue_id>` bool per registry clue, under an explanatory comment block.
- [ ] `CandidateYarnLintTests` implements all four rules and each can be shown to fail on a deliberate violation.
**Phase 3**
- [ ] Passive and active-check patterns are documented in the scene-file header comments.
- [ ] `Debug_CandidateCheck` exercises both branches deterministically under a fixed roll seed.
- [ ] The failure branch writes lean (§4.4) and carries no failure signal in its prose.
- [ ] No `Commentary_*` node contains a `$found_*` or `$self_lean_*` write.
- [ ] Zero new Yarn commands; zero changes to `SkillRuntime`, `SkillCheckSystem`, `YarnSkillCommands`, or the skill roster.
**Phase 4**
- [ ] `$visited_*` / `$errand_*` families are declared in `Common.yarn` with the gating rule stated.
- [ ] Every clue `unlockFlag` matches `^\$(visited|errand)_[a-z][a-z0-9_]*$` and is declared.
- [ ] The no-superset order-independence check passes.
- [ ] SC-101 played in three different hook orders gives order-independent totals.
**Phase 5**
- [ ] SC-101 has no `TODO` prose; four voices, three planting intuitions, the Body voice neutral.
- [ ] `SC101.yarn`'s header states the four-voice exception, the one-of-each-evidence-type rule, and the discovery-order-independence rule.
- [ ] Authored clues are flipped to `Authored` in `candidates.json` and pass lint rule 2.
**Every phase**
- [ ] The yarnproject reimports with no compiler diagnostics.
- [ ] `grep -rniE "leading_?candidate|dominant_?candidate|winning_?candidate|actual_?identity|is_?canonical" NightclubArcadia/Assets` returns nothing.
- [ ] EditMode tests pass:
```bash
/Applications/Unity/Hub/Editor/6000.5.8f1/Unity.app/Contents/MacOS/Unity -batchmode -quit -nographics -projectPath /Users/lennart/Dev/nightlcub-arcadia/NightclubArcadia -runTests -testPlatform EditMode -testFilter "NightclubArcadia.Skills.Tests" -testResults /tmp/candidate-tests.xml -logFile -
```
+415
View File
@@ -0,0 +1,415 @@
# Candidate System Implementation Plan — identity-lean tracking
**Audience:** the developer implementing this, assumed to have no prior context on this project or its design documents. Everything you need is in this file.
**Repo root:** `/Users/lennart/Dev/nightlcub-arcadia`
**Unity project:** `/Users/lennart/Dev/nightlcub-arcadia/NightclubArcadia`
**Unity editor binary:** `/Applications/Unity/Hub/Editor/6000.5.8f1/Unity.app/Contents/MacOS/Unity`
**Status:** ⚠️ **Superseded.** Steps 1, 2 and 4 landed in commit `22b8c7a`; Step 3 (persistence
verification) has no committed evidence and Step 5 was deferred. The design in §1 and the traps in §8
remain accurate and worth reading. For the remaining and wider work, the authoritative document is
now [`docs/candidate-system-implementation-plan.md`](./candidate-system-implementation-plan.md).
**Related:** [`docs/skill-system-refactor-plan.md`](./skill-system-refactor-plan.md) — the completed plan for the skill-voice system this one builds on.
> The design below is **decided intent, not a proposal**. Do not redesign it. Where it leaves something open, §7 says so explicitly — surface those as questions rather than inventing answers.
---
## 0. Glossary
| Term | Meaning |
|---|---|
| **PC identity fugue** | The player character starts the game with no memory of who they are. This is not a framing device resolved by a cutscene — the PC's identity is the central mystery of the opening act, and the player *builds* it rather than uncovering it. |
| **Candidate** | One of three permanently-live readings of who the PC might be. Not mutually exclusive; a player's belief can blend more than one. **There is no ground-truth identity value anywhere in the data.** |
| **Lean** | A per-candidate integer accumulator that grows as the player investigates, believes, and acts. Never chosen by the player, never resolved, never reduced to a winner. |
| **Evidence types** | The four kinds of support a candidate can have: **object**, **testimony** (NPC dialogue), **document**, and **intuition** (a skill voice arguing for a reading). Each candidate gets exactly one of each. |
| **Skill voice** | One of eleven in-head narrator characters (Disco Elysium-style skill-as-character narration). Implemented in this repo as `SkillDefinition` ScriptableObjects. A voice **advocates**; it does not report verified fact. |
| **Skill-voice pair** | The design's word for what this repo calls `SkillAxis`: Reason, Body, Social, Self (two voices each) plus Specialist (three). See `Assets/Scripts/Skills/Data/SkillAxis.cs`. |
| **SC-101** | The opening scene, "The Card at the Desk". Carries the first four candidate hooks. |
| **Yarn Spinner** | The dialogue scripting system this project uses (v3.2.8, vendored). `.yarn` files under `Assets/Dialogue/`. |
---
## 1. Design context
This section is copied forward from the design document *"Candidate System — Design Explanation for Lead Dev"*, which is **not in this repo**. Treat it as the complete statement of intent.
### 1.1 The problem it solves
The PC wakes with no memory of who they are. The system must genuinely support **more than one reading being true at once, indefinitely**, without ever collapsing to a single canonical state — unless a future, explicit design decision changes that.
### 1.2 The three candidates
Not mutually exclusive. None of these is "the twist"; they are three permanently live readings of the same character.
| Id | Name | Reading |
|---|---|---|
| `made_asset` | **Candidate A — The Made Asset** | The PC was trained / conditioned / activated by one of the factions orbiting the "Vesalian Concordat" for a role in Basel. |
| `journalist` | **Candidate B — The Journalist** | The PC's journalist framing is a cover; they're really here for a *person*, not a story. |
| `inheritor` | **Candidate C — The Inheritor** | The PC didn't choose to be here. A parent or elder relative had standing (or a debt) with one of the societies, and it passed to the PC without their consent. |
### 1.3 Evidence structure
Each candidate is supported by **exactly four evidence pieces, one of each type**: object, testimony, document, intuition.
This 1-of-each-type pattern **must be preserved for all future candidate evidence**, so that trust in a candidate is never an artifact of evidence-type imbalance. It is an invariant to enforce, not a coincidence of the current content.
### 1.4 Skill voices advocate, they don't report
A skill voice reacting to candidate-relevant material is **arguing for a reading**, not stating verified fact. This is already a general property of the skill-voice system in this project (see `Assets/Skills/skill_bible.json` — every voice has an explicit "what it's wrong about" and a FAILURE VOICE that is indistinguishable in tone from its SUCCESS VOICE). Nothing new needs building for it.
Each candidate's intuition clue in the opening scene comes from a **different voice-pair**, so that trust in "the smart voice" or "the gut voice" doesn't systematically favour one candidate:
| Candidate | Voice-pair (`SkillAxis`) | Voice | Roster id |
|---|---|---|---|
| A — Made Asset | Reason | CONFLUENCE | `confluence` |
| B — Journalist | Social | FACEWORK | `facework` |
| C — Inheritor | Self | STANDING ORDER | `standing_order` |
A **fourth voice may appear as pure neutral texture with no lean at all**, so that voice interactions don't universally read as votes. The Body pair (`house_pour` / `long_shift`) is the natural home for it — it is the one axis with no candidate assigned. Choosing which of the two is a writing decision.
### 1.5 Discovery order is not fixed
Which candidate a player meets first must depend on **where they choose to go and what they choose to investigate** — errand- or location-gated, never skill-gated and never a fixed script order.
This mirrors an existing decision recorded in `Assets/Dialogue/Common.yarn`'s header and `docs/skill-system-refactor-plan.md` §6: *documentary routes are gated on Act and errand state, never on a voice.* **See §7.1 — the errand/location gating system does not exist yet.**
### 1.6 Lean accumulates, it isn't chosen
The player is never asked to pick an identity. Moments throughout the game quietly add weight toward one or more candidates. This is a running, per-candidate accumulator — not a single decision point.
Endings and epilogue material, if ever built, read off accumulated lean rather than a "declare your identity" choice. This also means **there must be no single flag anywhere a player could datamine to learn "the real answer," because there isn't one.**
### 1.7 The SC-101 flag set
From the opening scene SC-101 ("The Card at the Desk"), four Yarn-facing flags are the intended pattern:
- `seen_sc_101` — scene witnessed
- `self_lean_madeAsset +1` — increments on the Candidate A hook (fires with skill voice CONFLUENCE)
- `self_lean_journalist +1` — increments on the Candidate B hook (fires with skill voice FACEWORK)
- `self_lean_inheritor +1` — increments on the Candidate C hook (fires with skill voice STANDING ORDER)
The general shape `self_lean_<candidateId>`, incrementing by integer amounts, is the intended design: a small, growing, per-candidate numeric accumulator, readable by Yarn conditions, with **no single derived "winner" value computed anywhere**.
The design document explicitly marks the exact spellings as *proposed naming, not a locked registry*. §3 of this plan resolves them against the repo's existing conventions.
### 1.8 What this system is explicitly NOT
- Not a personality quiz with three outcomes.
- Not a hard branch that locks the player into one identity path early.
- Not resolved by a single boss-fight-style reveal or one late dialogue choice.
- Must never produce a discoverable "correct answer" anywhere in game files or save data.
If a future feature (endings, epilogue text, achievements) needs to pick one clean outcome, **that is a new, separate design decision.** The underlying lean-tracking system must not silently assume it.
---
## 2. What already exists in the repo
Survey findings. Read this before writing anything — most of what this feature needs is already here.
### 2.1 Yarn Spinner wiring
- **Yarn Spinner 3.2.8**, vendored as an embedded package at `NightclubArcadia/Packages/dev.yarnspinner.unity/`. It is *not* listed in `Packages/manifest.json`.
- `Assets/Dialogue/NightclubArcadia.yarnproject` compiles `**/*.yarn` under `Assets/Dialogue/`. **New `.yarn` files anywhere under that folder are picked up automatically** — no importer or project-file change is needed to add a scene.
- `Assets/Scenes/DialogueTest.unity` is the working scene. It contains a `Dialogue System` prefab instance (`DialogueRunner`, Line Presenter, Options Presenter, Line Advancer, Canvas, and an **`InMemoryVariableStorage`**), with `autoStart: 1` and `startNode: "Start"`.
- `Assets/Scenes/SampleScene.unity` is the URP template leftover and is the *only* scene in `EditorBuildSettings`. `DialogueTest` is opened manually.
Existing `.yarn` files:
| File | Contents |
|---|---|
| `Assets/Dialogue/Common.yarn` | The `Declarations` node. Never played. Every variable in the project is declared here. |
| `Assets/Dialogue/Scenes/Entrance.yarn` | `Start`, `Entrance_Inside`, `Entrance_Outside`. Scene files stay thin: set the stage, `<<detour>>` into character nodes, branch on the outcome. |
| `Assets/Dialogue/Characters/Bouncer.yarn` | Four nodes all titled `Bouncer_Talk` — a Yarn 3 node group with `when:` saliency variations. |
| `Assets/Dialogue/Scenes/Debug.yarn` | `Debug_SkillCheck`. Not part of the story; exists to exercise mechanics in isolation. **This is the precedent to follow for verifying each step below.** |
| `Assets/Dialogue/Commentary.yarn` | Passive skill barks. Node names follow `Commentary_{contextId}_{skillId}`. |
Every one of these files opens with a `//` header comment explaining the file's role and its writing conventions. **Match that.**
### 2.2 Variable storage and persistence
There is **no custom variable storage subclass, no save system, and no game-state singleton beyond `SkillRuntime`**. There is also nothing that needs one:
- `VariableStorageBehaviour` (`Packages/dev.yarnspinner.unity/Runtime/Storage/VariableStorageBehaviour.cs`) exposes `SetValue(string, float/bool/string)`, `TryGetValue<T>`, `GetAllVariables()`, `SetAllVariables(...)`, `Contains(...)`, and `AddChangeListener(...)`.
- `DialogueRunner.SaveStateToPersistentStorage(string fileName)` and `LoadStateFromPersistentStorage(string fileName)` (`Runtime/DialogueRunner/DialogueRunner.Utility.cs`) serialise the entire variable storage to JSON under `Application.persistentDataPath`. **This is the persistence mechanism. Do not build another one.**
- The yarnproject importer's generated-variables feature (`generateVariablesSourceFile`) is **switched off**. Leave it off; turning it on is a project-wide change out of scope here.
There is one existing precedent for engine-written Yarn variables: the `$check_*` family in `Common.yarn`, written by `YarnSkillCommands.WriteResult(...)` and marked *"Never `<<set>>` these by hand."* Lean variables are the opposite case — they are **writer-set, never engine-set** — but the declaration discipline is identical.
### 2.3 The skill-voice system
Assembly `NightclubArcadia.Skills` (`Assets/Scripts/Skills/NightclubArcadia.Skills.asmdef`, references `YarnSpinner.Unity` and `Unity.TextMeshPro`).
| Piece | Path | Relevance here |
|---|---|---|
| `SkillAxis` enum | `Scripts/Skills/Data/SkillAxis.cs` | `{ Reason, Body, Social, Self, Specialist }` — this **is** the design's "voice-pair" concept. Do not duplicate it. |
| `SkillDefinition` | `Scripts/Skills/Data/SkillDefinition.cs` | ScriptableObject per voice. **Generated** from JSON; hand-edits are overwritten on script reload. |
| `SkillDatabase` | `Scripts/Skills/Data/SkillDatabase.cs` | ScriptableObject holding the roster. Its `OnValidate()` runs a full audit (id regex, duplicates, pair symmetry, axis counts). **This is the pattern to copy if a candidate registry is ever built — see Step 5.** |
| `skill_bible.json` | `Assets/Skills/skill_bible.json` | The roster source of truth. 11 voices, each with id, displayName, axis, domain, full prose notes, channels, accent colour. |
| `SkillSystemSetup` | `Assets/Editor/SkillSystemSetup.cs` | `[DidReloadScripts]` generator: JSON → `.asset` files → database → scene wiring. |
| `CommentarySystem` | `Scripts/Skills/Commentary/CommentarySystem.cs` | Fires **one** passive bark per request from node `Commentary_{contextId}_{skillId}`, weighted by rank, gated by per-skill firing budget and recency. **See §8.2 — do not route candidate intuition clues through this.** |
| `YarnSkillCommands` | `Scripts/Skills/Yarn/YarnSkillCommands.cs` | The entire Yarn↔C# surface: `<<check>>`, `<<skill_mod>>`, `<<clear_skill_mods>>`, `skill_rank()`. |
The three candidate voices already exist in the roster with these exact ids: **`confluence`** (axis Reason), **`facework`** (axis Social), **`standing_order`** (axis Self). Nothing needs to be added to the roster for this feature.
### 2.4 Naming conventions already in force
Defer to these. They are enforced by `SkillDatabase.OnValidate()` and by `Assets/Tests/EditMode/SkillRosterTests.cs`.
- **Yarn variables:** `$snake_case`. Every one declared in `Common.yarn`'s `Declarations` node, so a typo is a compile error rather than a silently-created second variable.
- **Ids** (skills, and by extension candidates): `^[a-z][a-z0-9_]*$`. Enforced because ids become Yarn node-title fragments, and node titles must be valid identifiers.
- **Yarn node titles:** `Start`, `{Scene}_{Beat}`, `{Character}_Talk`, `Commentary_{ContextId}_{skillId}`, `Debug_*`. Context ids are PascalCase-with-underscores; skill ids are lowercase.
- **Yarn commands:** lowercase snake_case.
- **C#:** namespace `NightclubArcadia.Skills`, classes `sealed` by default, `[SerializeField]` fields bare `camelCase`, log prefixes `[TypeName]`.
- **There is no `seen_*` convention yet.** Once-only behaviour is currently handled by Yarn's `when: once` saliency and by `CommentarySystem._firedContexts`, not by flags. §3 establishes `$seen_*` deliberately.
- **Docs:** lowercase-kebab-case `.md` in `docs/`.
### 2.5 What does not exist
Say so plainly, because these are the starting conditions:
- No errand, quest, Act, location, or scene-progression system of any kind. `docs/skill-system-refactor-plan.md` §6 lists "Act / errand state, or any gating for documentary routes" as an explicit non-goal of that pass.
- No clue, evidence, or deduction objects. Same doc, same section: *"That system does not exist in this repo and is out of scope. Do not invent a schema for it."*
- No save/load call sites (the Yarn API in §2.2 is present but never called).
- No `CLAUDE.md`, no project `README.md`.
- No SC-101 scene file, and no candidate content of any kind.
Note: `Assets/Dialogue/Commentary.yarn` currently has an uncommitted working-tree edit (a `TODO: ambient fallback` block). It is unrelated to this feature. **Leave it alone.**
---
## 3. Naming decisions
The design document (§1.7) marks its flag spellings as proposed. Resolved against §2.4 and confirmed by a human:
| Concept | Locked name | Rationale |
|---|---|---|
| Candidate ids | `made_asset`, `journalist`, `inheritor` | snake_case, satisfies the `^[a-z][a-z0-9_]*$` id regex, so a candidate id can safely become a Yarn node-title fragment later — same reasoning as skill ids (§8.4). |
| Lean accumulators | `$self_lean_made_asset`, `$self_lean_journalist`, `$self_lean_inheritor` | The design's `self_lean_madeAsset` camelCase suffix is the only camelCase identifier that would exist in the project. Converted to snake_case; the `self_lean_` prefix is kept verbatim. |
| Scene-witnessed flag | `$seen_sc_101` | Establishes a new `$seen_<scene_id>` convention. Chosen over Yarn's `when: once` / node-visit tracking because it is readable from any node and from C#, and keys on a stable scene id rather than a node title. |
| Scene id / node prefix | `SC101` | e.g. `SC101_Desk`, `SC101_Card_Object`. Matches the existing PascalCase-with-underscores context-id style (`Entrance_Queue`). |
**These names are extensible by construction.** Adding a fourth candidate means adding one `<<declare $self_lean_<new_id> = 0 as number>>` line and authoring content. It requires no code change, because there is no code (§4, Step 1).
If a human wants to revisit the `self_lean_` prefix, now is the moment — after Step 1 lands it becomes a rename across content.
---
## 4. Implementation steps
Each step is independently verifiable in-engine before the next begins. Steps 1–3 involve **zero C#**.
### Step 1 — Declare the accumulators
**Adds:** three lean variables and one scene flag.
**Builds on:** `Assets/Dialogue/Common.yarn`, the `Declarations` node.
**Does NOT touch:** any C#, any `.asset`, `SkillRuntime`, `CommentarySystem`, the scene, the yarnproject.
Append to `Common.yarn` with a comment block explaining what lean is and the guardrail that no winner is ever computed:
```yarn
// Candidate lean. Three permanently-live readings of who the PC is; they are
// not mutually exclusive and never resolve. Writers <<set>> these; no code
// reads them. There is deliberately NO derived "winning candidate" variable —
// see docs/candidate-system-plan.md §5.
<<declare $self_lean_made_asset = 0 as number>>
<<declare $self_lean_journalist = 0 as number>>
<<declare $self_lean_inheritor = 0 as number>>
// Scene witnessed. New convention: $seen_<scene_id>.
<<declare $seen_sc_101 = false as bool>>
```
**Why plain `<<set>>` and no C# command:** Yarn's own variable storage holds these directly as numbers. A `<<add_lean made_asset 1>>` command would take the candidate id as a *string*, which throws away the compile-time typo checking that declaring everything in `Common.yarn` buys. Adding an abstraction here would be strictly worse than the built-in. See §5 for when to revisit.
**Verify:** the yarnproject reimports with no compiler diagnostics.
### Step 2 — Prove the accumulator round-trips, in isolation
**Adds:** a debug node.
**Builds on:** `Assets/Dialogue/Scenes/Debug.yarn` — the existing precedent for exercising a mechanic outside the story.
**Does NOT touch:** story content, C#, the scene hierarchy.
Add `Debug_CandidateLean` to `Debug.yarn`: increment each accumulator, print the values with inline interpolation, and branch on a threshold. Something equivalent to:
```yarn
title: Debug_CandidateLean
---
<<set $self_lean_made_asset += 1>>
<<set $self_lean_made_asset += 1>>
<<set $self_lean_journalist += 1>>
Narrator: made_asset {$self_lean_made_asset} / journalist {$self_lean_journalist} / inheritor {$self_lean_inheritor}
<<if $self_lean_made_asset >= 2>>
Narrator: threshold branch reached.
<<endif>>
===
```
**Verify in-engine:** open `Assets/Scenes/DialogueTest.unity`, temporarily point the `DialogueRunner`'s `startNode` at `Debug_CandidateLean`, press Play. Expect `made_asset 2 / journalist 1 / inheritor 0` and the threshold branch to fire. Restore `startNode` to `Start` afterwards.
This is the step the design brief calls out as the gate: prove increment-and-branch works before layering anything on top.
### Step 3 — Prove persistence, without building a save system
**Adds:** a temporary verification only. Ideally **no committed file at all**.
**Builds on:** `DialogueRunner.SaveStateToPersistentStorage` / `LoadStateFromPersistentStorage` (§2.2).
**Does NOT touch:** variable storage — the stock `InMemoryVariableStorage` on the prefab stays exactly as it is.
Lean must persist across play sessions "the same way other game state does". In this project *all* game state is Yarn variables, so this is already true the moment a save call site exists. Confirm it rather than assume it: from a temporary editor script or a `[ContextMenu]` on a throwaway component, run Step 2's node, call `SaveStateToPersistentStorage("candidate-lean-smoketest.json")`, exit Play, re-enter, call `LoadStateFromPersistentStorage(...)`, and confirm the values come back.
**Verify:** inspect the JSON at `Application.persistentDataPath` and confirm `$self_lean_*` appear in `floatKeys`/`floatValues` and `$seen_sc_101` in `boolKeys`/`boolValues`.
**Also confirm while you're here:** whether Yarn 3's `when: once` saliency state is stored in the same variable storage (it is generated as an internal visited-variable per node). If it is, saliency survives save/load for free; if not, that is a project-wide finding worth recording — but it is **not** this feature's problem to fix, because §3 chose an explicit `$seen_sc_101` bool precisely to avoid depending on it.
Then delete the temporary script. Choosing when the game actually saves is a separate ticket (§7.5).
### Step 4 — Author the SC-101 hook structure
**Adds:** `Assets/Dialogue/Scenes/SC101.yarn` — node structure, flag writes, and voice attribution. **Prose is a writer's job and is left as TODO placeholders.**
**Builds on:** the thin-scene pattern from `Entrance.yarn`; the `<<detour>>` idiom; the speaker-name convention from `Commentary.yarn` (uppercase displayName as speaker: `CONFLUENCE:`, `FACEWORK:`, `STANDING ORDER:`).
**Does NOT touch:** `CommentarySystem` (see §8.2), the skill roster, `Entrance.yarn`.
Structure, following the design in §1.3–§1.7:
- One thin entry node `SC101_Desk` that sets `<<set $seen_sc_101 = true>>` and offers routes into the hooks.
- **Four separate hook nodes**, one per candidate plus one neutral. Each hook node carries exactly one `<<set $self_lean_<id> += 1>>` and speaks in exactly one voice:
| Node | Voice | Lean write |
|---|---|---|
| `SC101_Hook_MadeAsset` | `CONFLUENCE:` | `<<set $self_lean_made_asset += 1>>` |
| `SC101_Hook_Journalist` | `FACEWORK:` | `<<set $self_lean_journalist += 1>>` |
| `SC101_Hook_Inheritor` | `STANDING ORDER:` | `<<set $self_lean_inheritor += 1>>` |
| `SC101_Hook_Neutral` | Body-pair voice (writer picks `HOUSE POUR` or `THE LONG SHIFT`) | **none — deliberately** |
Each hook node must be reachable **independently of the others and in any order**. Concretely:
- No hook may be `<<if>>`-guarded on another hook having fired, on any `$self_lean_*` value, or on `skill_rank(...)`.
- No hook may be reachable only as a fall-through from another hook.
- Use `<<detour>>` from `SC101_Desk` so each hook returns to the desk and the others stay available.
Put the header comment in the file stating this rule, in the style of the other `.yarn` headers — this is the file where the discovery-order-independence guardrail (§1.5) is actually enforced.
**Verify in-engine:** point `startNode` at `SC101_Desk`, play through in three different orders (A→B→C, C→A→B, B only), and confirm each order produces the same accumulator totals for the hooks visited, and that visiting one hook never removes or unlocks another.
### Step 5 — *(Deferred until there is content to validate)* The evidence registry
**Do not build this yet.** It is written down here so the shape is agreed before it is needed.
The 1-of-each-evidence-type invariant (§1.3) is an authoring rule that will rot silently as content grows. When the project has enough candidate evidence authored to make that a real risk — realistically, when the second candidate's full four pieces exist beyond SC-101 — mirror the pattern the skill roster already uses, rather than inventing a new one:
- `Assets/Candidates/candidates.json` as the writer-facing source of truth (mirroring `Assets/Skills/skill_bible.json`), holding candidate id, display name, and an evidence list of `{ id, type, sceneId, notes }` where `type` ∈ `{ Object, Testimony, Document, Intuition }` and intuition entries carry the `skillId` of the advocating voice.
- A generator following `Assets/Editor/SkillSystemSetup.cs` (JSON → `.asset` → database).
- A `CandidateDatabase.OnValidate()` audit following `SkillDatabase.OnValidate()`: id regex, no duplicates, **exactly one evidence piece of each of the four types per candidate**, intuition `skillId` resolves against `SkillDatabase`, and — for as long as the design in §1.4 holds — no two candidates' intuition clues share a `SkillAxis`.
- An EditMode test following `Assets/Tests/EditMode/SkillRosterTests.cs`, which loads the real asset via `AssetDatabase` and asserts the invariants.
**This registry must remain descriptive.** It records what evidence exists and its type; it must not hold a weight, a truth value, or anything from which a winner could be derived. Adding a fourth candidate must mean adding a JSON entry and authoring content — no code change.
**Until then:** the invariant is a writing-discipline rule stated in `SC101.yarn`'s header comment. That is honest and sufficient for one scene.
---
## 5. Guardrails — things to actively avoid
These are implementation constraints, not flavour text. Each one is something a reasonable engineer would otherwise do.
1. **Never compute a winning candidate.** No `GetLeadingCandidate()`, no `$dominant_candidate`, no sort, no max, no ranking, no "which is highest" comparison — not in C#, not in Yarn, not in a debug UI, not in a log line. Comparing one lean value against a *constant* threshold (`<<if $self_lean_journalist >= 3>>`) is fine and intended. Comparing two lean values *against each other* is the thing that must not exist.
2. **Do not add a smart variable for lean.** `Common.yarn` already contains the idiom (`<<declare $is_vip = $reputation > 10>>`) and it will be tempting to write `<<declare $leans_journalist = $self_lean_journalist > $self_lean_made_asset>>`. That is exactly the derived-winner value guardrail 1 forbids, and being a smart variable does not make it less discoverable.
3. **No ground-truth field anywhere.** No `isTrue`, `isCanonical`, `actualIdentity`, or `weight` on any candidate or evidence record, in JSON, ScriptableObject, or save data. If the registry in Step 5 is ever built, a reviewer should be able to read the whole JSON and be unable to tell which candidate is "right" — because none is.
4. **Save data must not become the leak.** Persistence is a straight dump of Yarn variable storage (§2.2). As long as guardrails 1–3 hold, the save file contains three integers and no answer. Any future custom save format must preserve that property.
5. **Discovery order lives in the wiring, not the prose.** Enforced in Step 4's constraints. Restated because it is easy to satisfy in writing and violate in structure.
6. **Do not route candidate intuition clues through `CommentarySystem`.** See §8.2.
7. **Extensibility is load-bearing.** Three candidates is the current count, not necessarily the final one. Every mechanism here must accept a fourth by adding a declaration and content, never by editing a `switch`, an enum, or a hardcoded list of three.
---
## 6. Explicit non-goals
Do not build these as a side effect of this feature:
- A clue / deduction / evidence-object system (`CL-0xx`, `DED-0xx`, criticality, routes). Out of scope per `docs/skill-system-refactor-plan.md` §6, and still out of scope here.
- An errand, quest, Act, or location-gating system. This feature *depends* on one (§1.5) but must not invent one — see §7.1.
- A save/load call-site policy, autosave, or save slots. Step 3 verifies the mechanism; deciding when the game saves is a separate ticket.
- Any UI that surfaces lean to the player. Whether lean is ever shown during play is undecided — §7.3.
- A custom `VariableStorageBehaviour` subclass. The stock `InMemoryVariableStorage` is sufficient and the yarnproject's generated-variables feature stays off.
- Any narrative content. Node structure and flag placement are engineering; the lines are a writing pass.
---
## 7. Open questions and deferred decisions
These are genuinely undecided. Do not invent answers — raise them.
**7.1 — Discovery-order gating has no host system. (Blocking for anything past SC-101.)**
The design requires candidate evidence to be errand- or location-gated. No errand, quest, Act, or location system exists in this repo, and the previous plan explicitly declined to build one. Within SC-101 this is handled structurally (Step 4: four independently reachable `<<detour>>` hooks). **The moment candidate evidence spans more than one scene, this becomes a hard dependency on a system somebody has to design.** Flagging it as an open dependency; do not build a bespoke candidate-only gating mechanism to work around it — that would be exactly the parallel system the design warns against.
**7.2 — How lean is measured, and whether thresholds mean anything.** Does `+1` per hook stay the only increment size? Do larger commitments weigh more? Are there thresholds at which anything changes, and does lean decay over time? Undecided. Step 1 supports any of these without change (they are integers in a Yarn variable), so nothing is being foreclosed.
**7.3 — Is lean ever surfaced to the player during play?** Or only reflected at the end? Undecided. Affects whether any UI work is ever needed. Nothing here assumes either.
**7.4 — Total number of candidate touchpoints across the game.** SC-101 has the first four hooks (one lean signal per candidate, plus one neutral voice). The full count is unknown. This is the input to the §4 Step 5 question of when a registry earns its keep.
**7.5 — When does the game save?** Step 3 proves the mechanism works; no call site exists. Somebody has to decide scene-exit vs. autosave vs. manual.
**7.6 — Candidate C and "Ferran Doss."** The design document flags a possible narrative link between Candidate C (The Inheritor) and an existing NPC hook named Ferran Doss as **undecided**. Do not treat it as canon and do not wire anything to it.
**7.7 — The neutral fourth voice.** §1.4 says a neutral, lean-free voice may exist so that voice interactions don't universally read as votes. The Body pair (`house_pour` / `long_shift`) is the only axis without a candidate, making it the natural home. Which of the two speaks in SC-101 is a writing decision, not an engineering one.
**7.8 — The `self_lean_` prefix.** Kept verbatim from the design document. If it should instead be `lean_`, `identity_lean_`, or namespaced differently, decide before Step 1 lands — afterwards it is a rename across content.
---
## 8. Traps
**8.1 — Yarn variables must be declared or they are compile errors.**
Every variable in this project is declared in `Common.yarn`. A `<<set $self_lean_inheritor += 1>>` without the matching `<<declare>>` will not silently create a variable — it will fail. This is the feature, not the bug: it is what makes plain `<<set>>` safer than a string-keyed C# command.
**8.2 — `CommentarySystem` is the wrong vehicle for intuition clues.**
It looks like an exact fit — it fires a named skill voice from a node — but it is a *probabilistic* channel: it picks **one** voice per request, weighted by rank, gated by a per-skill firing budget (`3600 / targetFiringsPerHour` seconds), a global `minSecondsBetween` floor, a `minRankToSpeak` threshold, and a recency penalty. It also refuses to fire while dialogue is running. Candidate intuition clues must be **deterministic and each independently discoverable**. Route them through the scene's own nodes via `<<detour>>` (Step 4). Leave ambient commentary in SC-101, if any, as a separate and additive concern.
**8.3 — Node lookups are case-sensitive; skill lookups are not.**
`SkillDatabase` and `PlayerSkills` use `OrdinalIgnoreCase`, but `CommentarySystem.NodeExists` uses `Ordinal`. Keep every authored id lowercase. Inherited verbatim from `docs/skill-system-refactor-plan.md` §7.3.
**8.4 — Ids become node-title fragments.**
This is why candidate ids follow the `^[a-z][a-z0-9_]*$` regex even though nothing enforces it for candidates *yet*. An id with a space or a hyphen produces a node title that either fails to compile or silently never matches.
**8.5 — Generated assets are overwritten on script reload.**
`SkillSystemSetup.AutoSetupAfterReload` regenerates every skill definition and the database once per editor session from `skill_bible.json`. If Step 5 is ever built on the same pattern, its JSON is the source of truth and its `.asset` files are build output. Hand-edits to `.asset` files are lost.
**8.6 — Do not delete or recreate `SkillDatabase.asset`.**
`Assets/Scenes/DialogueTest.unity` wires `SkillRuntime.database` and `CommentarySystem.database` to it by GUID. Nothing in this plan should touch it, but it is the standing landmine in this project.
**8.7 — Declared Yarn numbers are floats.**
`<<declare $x = 0 as number>>` stores a float and serialises into `floatKeys`/`floatValues`. Integer comparisons (`>= 1`) behave correctly; do not be surprised by `0.0` in the save JSON.
---
## 9. Definition of done
For the scope covered by Steps 1–4. Step 5 is deliberately deferred (§4).
- [ ] `Common.yarn` declares `$self_lean_made_asset`, `$self_lean_journalist`, `$self_lean_inheritor` (number) and `$seen_sc_101` (bool), with a comment block stating the no-winner guardrail.
- [ ] `Debug.yarn` has a `Debug_CandidateLean` node; playing it prints the expected totals and takes the threshold branch.
- [ ] Lean values and `$seen_sc_101` survive a `SaveStateToPersistentStorage` → exit Play → `LoadStateFromPersistentStorage` round trip, and the temporary verification script is deleted.
- [ ] `Assets/Dialogue/Scenes/SC101.yarn` exists with `SC101_Desk` plus four hook nodes; each candidate hook carries exactly one `<<set $self_lean_* += 1>>`; the neutral hook carries none.
- [ ] Each hook is reachable in any order, with no `<<if>>` guard on another hook, on any `$self_lean_*`, or on `skill_rank(...)`. Verified by playing three different orders.
- [ ] `SC101.yarn` has a header comment in the house style stating the one-of-each-evidence-type rule and the discovery-order-independence rule.
- [ ] Zero new C# files. Zero changes to `SkillRuntime`, `CommentarySystem`, `YarnSkillCommands`, the skill roster, or the `Dialogue System` prefab.
- [ ] `grep -rniE "leading_?candidate|dominant_?candidate|winning_?candidate|actual_?identity|is_?canonical" NightclubArcadia/Assets` returns nothing (§5.1–5.3).
- [ ] The yarnproject reimports with no compiler diagnostics.
- [ ] Existing EditMode tests still pass:
```bash
/Applications/Unity/Hub/Editor/6000.5.8f1/Unity.app/Contents/MacOS/Unity \
-batchmode -quit -nographics \
-projectPath /Users/lennart/Dev/nightlcub-arcadia/NightclubArcadia \
-runTests -testPlatform EditMode -testFilter "NightclubArcadia.Skills.Tests" \
-testResults /tmp/candidate-tests.xml -logFile -
```
- [ ] The uncommitted `Commentary.yarn` working-tree edit is untouched.