refactor the skill system to adhere to the lore handbook

This commit is contained in:
2026-08-15 11:13:15 +02:00
parent 063e94f6eb
commit 9e5c3277bf
70 changed files with 2271 additions and 445 deletions
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.IO;
using NightclubArcadia.Skills;
@@ -10,43 +11,21 @@ using UnityEngine.UI;
using Yarn.Unity;
/// <summary>
/// Creates SkillDefinition assets, SkillDatabase, and wires SkillRuntime + roll
/// banner + commentary into Assets/Scenes/DialogueTest.unity.
/// Regenerates SkillDefinition assets + SkillDatabase from the Skill Bible JSON mirror.
///
/// Source of truth for prose/roster: the Obsidian Skill System Draft.md.
/// Checked-in machine mirror: Assets/Skills/skill_bible.json (edit that, not the .assets).
/// Hand-edits to Definitions/*.asset are overwritten on script reload by EnsureSkillAssetsOnly.
/// </summary>
public static class SkillSystemSetup
{
const string DefinitionsFolder = "Assets/Skills/Definitions";
const string SkillsFolder = "Assets/Skills";
const string BiblePath = "Assets/Skills/skill_bible.json";
const string DatabasePath = "Assets/Skills/SkillDatabase.asset";
const string ScenePath = "Assets/Scenes/DialogueTest.unity";
const string AutoSetupSessionKey = "NightclubArcadia.SkillSystem.AutoSetupDone";
struct SkillSpec
{
public string Id;
public string DisplayName;
public SkillDomain Domain;
public Color Accent;
public int StartingRank;
}
static readonly SkillSpec[] Roster =
{
new SkillSpec { Id = "deduction", DisplayName = "Deduction", Domain = SkillDomain.Reason, Accent = new Color(0.55f, 0.75f, 1f), StartingRank = 1 },
new SkillSpec { Id = "streetwise", DisplayName = "Streetwise", Domain = SkillDomain.Reason, Accent = new Color(0.7f, 0.85f, 0.55f), StartingRank = 1 },
new SkillSpec { Id = "recall", DisplayName = "Recall", Domain = SkillDomain.Reason, Accent = new Color(0.65f, 0.7f, 1f), StartingRank = 1 },
new SkillSpec { Id = "read_room", DisplayName = "Read the Room", Domain = SkillDomain.Instinct, Accent = new Color(1f, 0.75f, 0.45f), StartingRank = 1 },
new SkillSpec { Id = "paranoia", DisplayName = "Paranoia", Domain = SkillDomain.Instinct, Accent = new Color(1f, 0.45f, 0.55f), StartingRank = 1 },
new SkillSpec { Id = "reflexes", DisplayName = "Reflexes", Domain = SkillDomain.Instinct, Accent = new Color(1f, 0.9f, 0.4f), StartingRank = 1 },
new SkillSpec { Id = "persuasion", DisplayName = "Persuasion", Domain = SkillDomain.Presence, Accent = new Color(0.85f, 0.65f, 1f), StartingRank = 1 },
// Rank 2 so the Bouncer "Let me through" option gated by skill_rank >= 2 is visible in the demo.
new SkillSpec { Id = "intimidation", DisplayName = "Intimidation", Domain = SkillDomain.Presence, Accent = new Color(1f, 0.4f, 0.4f), StartingRank = 2 },
new SkillSpec { Id = "rapport", DisplayName = "Rapport", Domain = SkillDomain.Presence, Accent = new Color(0.55f, 0.95f, 0.85f), StartingRank = 1 },
new SkillSpec { Id = "endurance", DisplayName = "Endurance", Domain = SkillDomain.Body, Accent = new Color(0.85f, 0.7f, 0.5f), StartingRank = 1 },
new SkillSpec { Id = "sleight_of_hand", DisplayName = "Sleight of Hand", Domain = SkillDomain.Body, Accent = new Color(0.7f, 0.9f, 0.95f), StartingRank = 1 },
new SkillSpec { Id = "nerve", DisplayName = "Nerve", Domain = SkillDomain.Body, Accent = new Color(0.95f, 0.6f, 0.35f), StartingRank = 1 },
};
[DidReloadScripts]
static void AutoSetupAfterReload()
{
@@ -80,15 +59,24 @@ public static class SkillSystemSetup
/// </summary>
static void EnsureSkillAssetsOnly()
{
if (!TryLoadRoster(out var roster))
{
return;
}
EnsureFolder("Assets", "Skills");
EnsureFolder("Assets/Skills", "Definitions");
var definitions = new List<SkillDefinition>();
foreach (var spec in Roster)
var rosterIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var entry in roster)
{
definitions.Add(CreateOrUpdateDefinition(spec));
rosterIds.Add(entry.id);
definitions.Add(CreateOrUpdateDefinition(entry));
}
DeleteOrphanDefinitions(rosterIds);
var database = AssetDatabase.LoadAssetAtPath<SkillDatabase>(DatabasePath);
if (database == null)
{
@@ -105,15 +93,24 @@ public static class SkillSystemSetup
[MenuItem("Tools/Nightclub Arcadia/Set Up Skill System")]
public static void SetUpSkillSystem()
{
if (!TryLoadRoster(out var roster))
{
return;
}
EnsureFolder("Assets", "Skills");
EnsureFolder("Assets/Skills", "Definitions");
var definitions = new List<SkillDefinition>();
foreach (var spec in Roster)
var rosterIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var entry in roster)
{
definitions.Add(CreateOrUpdateDefinition(spec));
rosterIds.Add(entry.id);
definitions.Add(CreateOrUpdateDefinition(entry));
}
DeleteOrphanDefinitions(rosterIds);
var database = AssetDatabase.LoadAssetAtPath<SkillDatabase>(DatabasePath);
if (database == null)
{
@@ -130,9 +127,75 @@ public static class SkillSystemSetup
Debug.Log($"Skill system setup complete. Database at {DatabasePath}; scene wired at {ScenePath}.");
}
static SkillDefinition CreateOrUpdateDefinition(SkillSpec spec)
static bool TryLoadRoster(out SkillBibleEntry[] roster)
{
var path = $"{DefinitionsFolder}/{spec.Id}.asset";
roster = null;
var text = AssetDatabase.LoadAssetAtPath<TextAsset>(BiblePath);
if (text == null)
{
Debug.LogError($"[SkillSystemSetup] Missing bible at {BiblePath}.");
return false;
}
SkillBibleRoot root;
try
{
root = JsonUtility.FromJson<SkillBibleRoot>(text.text);
}
catch (Exception ex)
{
Debug.LogError($"[SkillSystemSetup] Failed to parse {BiblePath}: {ex.Message}");
return false;
}
if (root?.skills == null || root.skills.Length == 0)
{
Debug.LogError($"[SkillSystemSetup] {BiblePath} has no skills.");
return false;
}
foreach (var entry in root.skills)
{
if (entry == null || string.IsNullOrEmpty(entry.id))
{
Debug.LogError($"[SkillSystemSetup] {BiblePath} contains a skill with empty id.");
return false;
}
// Fail loudly on enum typos — silent defaults are content bugs.
if (!Enum.TryParse<SkillAxis>(entry.axis, ignoreCase: true, out _))
{
Debug.LogError($"[SkillSystemSetup] Skill '{entry.id}': invalid axis '{entry.axis}'.");
return false;
}
if (!Enum.TryParse<TraceChannel>(entry.primaryChannel, ignoreCase: true, out _))
{
Debug.LogError($"[SkillSystemSetup] Skill '{entry.id}': invalid primaryChannel '{entry.primaryChannel}'.");
return false;
}
var secondary = string.IsNullOrEmpty(entry.secondaryChannel) ? "None" : entry.secondaryChannel;
if (!Enum.TryParse<TraceChannel>(secondary, ignoreCase: true, out _))
{
Debug.LogError($"[SkillSystemSetup] Skill '{entry.id}': invalid secondaryChannel '{entry.secondaryChannel}'.");
return false;
}
if (!Enum.TryParse<ClueYield>(entry.clueYield, ignoreCase: true, out _))
{
Debug.LogError($"[SkillSystemSetup] Skill '{entry.id}': invalid clueYield '{entry.clueYield}'.");
return false;
}
}
roster = root.skills;
return true;
}
static SkillDefinition CreateOrUpdateDefinition(SkillBibleEntry entry)
{
var path = $"{DefinitionsFolder}/{entry.id}.asset";
var asset = AssetDatabase.LoadAssetAtPath<SkillDefinition>(path);
if (asset == null)
{
@@ -140,17 +203,33 @@ public static class SkillSystemSetup
AssetDatabase.CreateAsset(asset, path);
}
asset.EditorSet(
spec.Id,
spec.DisplayName,
spec.Domain,
$"Placeholder skill — replace when Skill_System_Draft.md is available.",
spec.StartingRank,
spec.Accent);
asset.EditorSet(entry);
EditorUtility.SetDirty(asset);
return asset;
}
static void DeleteOrphanDefinitions(HashSet<string> rosterIds)
{
if (!AssetDatabase.IsValidFolder(DefinitionsFolder))
{
return;
}
var guids = AssetDatabase.FindAssets("t:SkillDefinition", new[] { DefinitionsFolder });
foreach (var guid in guids)
{
var path = AssetDatabase.GUIDToAssetPath(guid);
var fileName = Path.GetFileNameWithoutExtension(path);
if (rosterIds.Contains(fileName))
{
continue;
}
Debug.Log($"[SkillSystemSetup] Deleting orphan definition {path}");
AssetDatabase.DeleteAsset(path);
}
}
static void WireDialogueTestScene(SkillDatabase database)
{
if (!File.Exists(ScenePath))
@@ -161,7 +240,7 @@ public static class SkillSystemSetup
var scene = EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Single);
var runner = Object.FindAnyObjectByType<DialogueRunner>();
var runner = UnityEngine.Object.FindAnyObjectByType<DialogueRunner>();
if (runner == null)
{
Debug.LogError("No DialogueRunner in DialogueTest.unity.");