Files
nightclub-arcadia/NightclubArcadia/Assets/Editor/CandidateSystemSetup.cs
T

219 lines
6.9 KiB
C#

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);
}
}
}