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 candidates = new List(); Dictionary _lookup; public IReadOnlyList 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(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(StringComparer.OrdinalIgnoreCase); var seenClues = new HashSet(StringComparer.OrdinalIgnoreCase); var axesUsed = new HashSet(); var authoredCount = 0; var plannedCount = 0; SkillDatabase skillDb = null; #if UNITY_EDITOR skillDb = AssetDatabase.LoadAssetAtPath(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(); 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 newCandidates) { candidates = newCandidates ?? new List(); _lookup = null; } #endif } }