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:
|
||||
Reference in New Issue
Block a user