updated Candidate System with Voice System

This commit is contained in:
2026-08-15 13:25:04 +02:00
parent 22b8c7a438
commit 57bc10ba79
35 changed files with 2406 additions and 13 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:
+34 -2
View File
@@ -40,11 +40,43 @@ title: Declarations
// 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.
// 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. New convention: $seen_<scene_id>.
// 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 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
---
@@ -33,3 +39,21 @@ Narrator: made_asset {$self_lean_made_asset} / journalist {$self_lean_journalist
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}.
===
@@ -1,21 +1,34 @@
// SC-101 — The Card at the Desk. Opening candidate hooks.
//
// Scene files stay thin: set the stage, <<detour>> into hook nodes, return.
// Prose is TODO until the writing pass; structure and flag writes are locked.
//
// Evidence rule (design §1.3): each candidate gets exactly one of each evidence
// type — object, testimony, document, intuition. Preserve that balance; do not
// 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 (§1.5): hooks are independently reachable in any order via
// 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>>
Narrator: TODO — desk / card stage-setting.
<<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>>
@@ -29,28 +42,31 @@ Narrator: TODO — desk / card stage-setting.
<<detour SC101_Hook_Neutral>>
<<jump SC101_Desk>>
-> Leave the desk
Narrator: TODO — exit the desk beat.
Narrator: You leave the card where it is. The lamp keeps the shape of your hands on the blotter.
===
title: SC101_Hook_MadeAsset
---
CONFLUENCE: TODO — Made Asset intuition.
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: TODO — Journalist intuition.
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: TODO — Inheritor intuition.
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: TODO — neutral Body texture; no lean.
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,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: