updated Candidate System with Voice System

This commit is contained in:
2026-08-15 13:25:04 +02:00
parent 22b8c7a438
commit 57bc10ba79
35 changed files with 2406 additions and 13 deletions
@@ -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: