updated Candidate System with Voice System
This commit is contained in:
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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 -
|
||||
```
|
||||
@@ -4,7 +4,10 @@
|
||||
**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:** not started. Nothing described here exists in the repo yet.
|
||||
**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.
|
||||
|
||||
Reference in New Issue
Block a user