updated Candidate System with Voice System
This commit is contained in:
@@ -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:
|
||||
Reference in New Issue
Block a user