239 lines
8.9 KiB
C#
239 lines
8.9 KiB
C#
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
|
|
}
|
|
}
|