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
@@ -0,0 +1,9 @@
namespace NightclubArcadia.Skills
{
public enum ClueYield
{
Low,
Medium,
High,
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 98fef2c3069c3479884778df838f7dc2
@@ -1,20 +1,29 @@
using System;
using System.Globalization;
using UnityEngine;
namespace NightclubArcadia.Skills
{
public enum DcBand
{
Trivial = 5,
Easy = 8,
Standard = 11,
Trivial = 8,
Routine = 11,
Hard = 14,
Formidable = 17,
Legendary = 20,
Specialist = 17,
BuildDefining = 20,
}
public static class DcBands
{
static readonly int[] BandValues =
{
(int)DcBand.Trivial,
(int)DcBand.Routine,
(int)DcBand.Hard,
(int)DcBand.Specialist,
(int)DcBand.BuildDefining,
};
public static bool TryParse(string input, out int dc)
{
dc = 0;
@@ -28,6 +37,13 @@ namespace NightclubArcadia.Skills
if (int.TryParse(input, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numeric))
{
dc = numeric;
if (!IsBandValue(numeric))
{
Debug.LogWarning(
$"[DcBands] DC {numeric} is not a named band " +
$"(8 / 11 / 14 / 17 / 20). Prefer band names.");
}
return true;
}
@@ -39,5 +55,18 @@ namespace NightclubArcadia.Skills
return false;
}
public static bool IsBandValue(int dc)
{
for (var i = 0; i < BandValues.Length; i++)
{
if (BandValues[i] == dc)
{
return true;
}
}
return false;
}
}
}
@@ -1,10 +1,11 @@
namespace NightclubArcadia.Skills
{
public enum SkillDomain
public enum SkillAxis
{
Reason,
Instinct,
Presence,
Body,
Social,
Self,
Specialist,
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c8cafa1b8c20645ea81912844473c92e
@@ -0,0 +1,33 @@
using System;
using UnityEngine;
namespace NightclubArcadia.Skills
{
/// <summary>
/// JSON mirror of the Skill Bible. Deserialized by SkillSystemSetup; also the
/// EditorSet DTO for SkillDefinition. Enum fields are strings in JSON.
/// </summary>
[Serializable]
public sealed class SkillBibleRoot
{
public SkillBibleEntry[] skills;
}
[Serializable]
public sealed class SkillBibleEntry
{
public string id;
public string displayName;
public string axis;
public string domain;
public string notes;
public float targetFiringsPerHour;
public string opposedTo;
public string[] alliedWith;
public string primaryChannel;
public string secondaryChannel;
public string clueYield;
public int startingRank;
public Color accentColor;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 47ea723e59b8c45f688a38c9b4ede3a5
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using UnityEngine;
namespace NightclubArcadia.Skills
@@ -7,6 +8,8 @@ namespace NightclubArcadia.Skills
[CreateAssetMenu(menuName = "Nightclub Arcadia/Skill Database", fileName = "SkillDatabase")]
public sealed class SkillDatabase : ScriptableObject
{
static readonly Regex IdPattern = new Regex(@"^[a-z][a-z0-9_]*$", RegexOptions.Compiled);
[SerializeField] List<SkillDefinition> skills = new List<SkillDefinition>();
Dictionary<string, SkillDefinition> _lookup;
@@ -58,6 +61,11 @@ namespace NightclubArcadia.Skills
}
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var byId = new Dictionary<string, SkillDefinition>(StringComparer.OrdinalIgnoreCase);
var axisCounts = new Dictionary<SkillAxis, int>();
var channelCounts = new Dictionary<TraceChannel, int>();
var firingSum = 0f;
foreach (var skill in skills)
{
if (skill == null)
@@ -72,10 +80,127 @@ namespace NightclubArcadia.Skills
continue;
}
if (!IdPattern.IsMatch(skill.Id))
{
Debug.LogError(
$"[{nameof(SkillDatabase)}] Skill Id '{skill.Id}' must match ^[a-z][a-z0-9_]*$.",
this);
}
if (!seen.Add(skill.Id))
{
Debug.LogError($"[{nameof(SkillDatabase)}] Duplicate skill Id '{skill.Id}'.", this);
}
else
{
byId[skill.Id] = skill;
}
if (skill.TargetFiringsPerHour <= 0f)
{
Debug.LogError(
$"[{nameof(SkillDatabase)}] Skill '{skill.Id}' has targetFiringsPerHour <= 0.",
this);
}
else
{
firingSum += skill.TargetFiringsPerHour;
}
if (!axisCounts.ContainsKey(skill.Axis))
{
axisCounts[skill.Axis] = 0;
}
axisCounts[skill.Axis]++;
if (!channelCounts.ContainsKey(skill.PrimaryChannel))
{
channelCounts[skill.PrimaryChannel] = 0;
}
channelCounts[skill.PrimaryChannel]++;
}
foreach (var skill in byId.Values)
{
ValidateReference(skill.OpposedTo, skill.Id, "opposedTo", byId);
if (skill.AlliedWith != null)
{
foreach (var ally in skill.AlliedWith)
{
ValidateReference(ally, skill.Id, "alliedWith", byId);
}
}
if (string.IsNullOrEmpty(skill.OpposedTo) || !byId.TryGetValue(skill.OpposedTo, out var opposed))
{
continue;
}
// Symmetry required only when both skills are non-Specialist.
if (skill.Axis == SkillAxis.Specialist || opposed.Axis == SkillAxis.Specialist)
{
continue;
}
if (!string.Equals(opposed.OpposedTo, skill.Id, StringComparison.OrdinalIgnoreCase))
{
Debug.LogError(
$"[{nameof(SkillDatabase)}] opposedTo asymmetry: '{skill.Id}' → '{skill.OpposedTo}' " +
$"but '{opposed.Id}' → '{opposed.OpposedTo}'.",
this);
}
}
AssertAxisCount(axisCounts, SkillAxis.Reason, 2);
AssertAxisCount(axisCounts, SkillAxis.Body, 2);
AssertAxisCount(axisCounts, SkillAxis.Social, 2);
AssertAxisCount(axisCounts, SkillAxis.Self, 2);
AssertAxisCount(axisCounts, SkillAxis.Specialist, 3);
Debug.Log(
$"[{nameof(SkillDatabase)}] targetFiringsPerHour sum = {firingSum} (Bible expects 71).",
this);
channelCounts.TryGetValue(TraceChannel.Physical, out var physical);
channelCounts.TryGetValue(TraceChannel.Testimonial, out var testimonial);
channelCounts.TryGetValue(TraceChannel.Behavioural, out var behavioural);
channelCounts.TryGetValue(TraceChannel.Documentary, out var documentary);
Debug.Log(
$"[{nameof(SkillDatabase)}] primary-channel tally: physical {physical} / " +
$"testimonial {testimonial} / behavioural {behavioural} / documentary {documentary} " +
$"(Bible expects 3 / 3 / 3 / 2).",
this);
}
void ValidateReference(
string refId,
string ownerId,
string fieldName,
Dictionary<string, SkillDefinition> byId)
{
if (string.IsNullOrEmpty(refId))
{
return;
}
if (!byId.ContainsKey(refId))
{
Debug.LogError(
$"[{nameof(SkillDatabase)}] Skill '{ownerId}' {fieldName} '{refId}' is not in this database.",
this);
}
}
void AssertAxisCount(Dictionary<SkillAxis, int> counts, SkillAxis axis, int expected)
{
counts.TryGetValue(axis, out var actual);
if (actual != expected)
{
Debug.LogError(
$"[{nameof(SkillDatabase)}] Axis {axis} has {actual} skills; expected {expected}.",
this);
}
}
@@ -1,39 +1,77 @@
using System;
using UnityEngine;
namespace NightclubArcadia.Skills
{
// Generated from Assets/Skills/skill_bible.json by SkillSystemSetup.
// Hand-edits to .asset files are overwritten on script reload — edit the JSON.
[CreateAssetMenu(menuName = "Nightclub Arcadia/Skill Definition", fileName = "NewSkill")]
public sealed class SkillDefinition : ScriptableObject
{
[SerializeField] string id;
[SerializeField] string displayName;
[SerializeField] SkillDomain domain;
[TextArea] [SerializeField] string description;
[SerializeField] SkillAxis axis;
[SerializeField] string domain;
[TextArea(8, 24)] [SerializeField] string notes;
[SerializeField] float targetFiringsPerHour = 5f;
[SerializeField] string opposedTo;
[SerializeField] string[] alliedWith;
[SerializeField] TraceChannel primaryChannel;
[SerializeField] TraceChannel secondaryChannel;
[SerializeField] ClueYield clueYield;
[SerializeField] int startingRank;
[SerializeField] Color accentColor = Color.white;
public string Id => id;
public string DisplayName => displayName;
public SkillDomain Domain => domain;
public string Description => description;
public SkillAxis Axis => axis;
public string Domain => domain;
public string Notes => notes;
public float TargetFiringsPerHour => targetFiringsPerHour;
public string OpposedTo => opposedTo;
public string[] AlliedWith => alliedWith;
public TraceChannel PrimaryChannel => primaryChannel;
public TraceChannel SecondaryChannel => secondaryChannel;
public ClueYield ClueYield => clueYield;
public int StartingRank => startingRank;
public Color AccentColor => accentColor;
#if UNITY_EDITOR
public void EditorSet(
string newId,
string newDisplayName,
SkillDomain newDomain,
string newDescription,
int newStartingRank,
Color newAccentColor)
public void EditorSet(SkillBibleEntry entry)
{
id = newId;
displayName = newDisplayName;
domain = newDomain;
description = newDescription;
startingRank = newStartingRank;
accentColor = newAccentColor;
if (entry == null)
{
throw new ArgumentNullException(nameof(entry));
}
id = entry.id;
displayName = entry.displayName;
axis = ParseEnumRequired<SkillAxis>(entry.axis, nameof(entry.axis), entry.id);
domain = entry.domain ?? string.Empty;
notes = entry.notes ?? string.Empty;
targetFiringsPerHour = entry.targetFiringsPerHour;
opposedTo = entry.opposedTo ?? string.Empty;
alliedWith = entry.alliedWith ?? Array.Empty<string>();
primaryChannel = ParseEnumRequired<TraceChannel>(entry.primaryChannel, nameof(entry.primaryChannel), entry.id);
secondaryChannel = ParseEnumRequired<TraceChannel>(
string.IsNullOrEmpty(entry.secondaryChannel) ? "None" : entry.secondaryChannel,
nameof(entry.secondaryChannel),
entry.id);
clueYield = ParseEnumRequired<ClueYield>(entry.clueYield, nameof(entry.clueYield), entry.id);
startingRank = entry.startingRank;
accentColor = entry.accentColor;
}
static TEnum ParseEnumRequired<TEnum>(string value, string fieldName, string skillId)
where TEnum : struct
{
if (!Enum.TryParse(value, ignoreCase: true, out TEnum parsed))
{
throw new ArgumentException(
$"Skill '{skillId}': invalid {fieldName} '{value}' for {typeof(TEnum).Name}.");
}
return parsed;
}
#endif
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 0bdf9a02be4b42a4a48a099fe924980e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,11 @@
namespace NightclubArcadia.Skills
{
public enum TraceChannel
{
None,
Documentary,
Behavioural,
Testimonial,
Physical,
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 94dae2d20cc784f0198944f76cc362ef