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
@@ -8,6 +8,8 @@ namespace NightclubArcadia.Skills
/// <summary>
/// Passive skill commentary outside the Yarn node tree.
/// Fires at most one speaker per RequestCommentary call.
/// Per-skill budget: 3600 / targetFiringsPerHour seconds between fires for that voice,
/// on top of the global minSecondsBetween floor.
/// </summary>
public sealed class CommentarySystem : MonoBehaviour
{
@@ -19,6 +21,8 @@ namespace NightclubArcadia.Skills
readonly List<string> _recent = new List<string>();
readonly HashSet<string> _firedContexts = new HashSet<string>(StringComparer.Ordinal);
readonly Dictionary<string, float> _lastFiredBySkill =
new Dictionary<string, float>(StringComparer.OrdinalIgnoreCase);
float _lastFiredAt = float.NegativeInfinity;
public void RequestCommentary(string contextId)
@@ -61,6 +65,7 @@ namespace NightclubArcadia.Skills
return;
}
var now = Time.time;
var candidates = new List<SkillDefinition>();
foreach (var skill in database.All)
{
@@ -74,6 +79,16 @@ namespace NightclubArcadia.Skills
continue;
}
if (!_lastFiredBySkill.TryGetValue(skill.Id, out var lastFired))
{
lastFired = float.NegativeInfinity;
}
if (!IsOffCooldown(lastFired, now, skill.TargetFiringsPerHour))
{
continue;
}
var nodeName = $"Commentary_{contextId}_{skill.Id}";
if (!NodeExists(nodeName))
{
@@ -91,7 +106,8 @@ namespace NightclubArcadia.Skills
var startNode = $"Commentary_{contextId}_{picked.Id}";
_ = dialogueRunner.StartDialogue(startNode);
_lastFiredAt = Time.time;
_lastFiredAt = now;
_lastFiredBySkill[picked.Id] = now;
_firedContexts.Add(contextId);
_recent.Add(picked.Id);
while (_recent.Count > Math.Max(1, recencyWindow))
@@ -100,6 +116,21 @@ namespace NightclubArcadia.Skills
}
}
/// <summary>
/// True when enough time has elapsed since lastFired given targetFiringsPerHour.
/// targetFiringsPerHour &lt;= 0 means the skill never fires.
/// </summary>
public static bool IsOffCooldown(float lastFired, float now, float targetPerHour)
{
if (targetPerHour <= 0f)
{
return false;
}
var minInterval = 3600f / targetPerHour;
return now - lastFired >= minInterval;
}
bool NodeExists(string nodeName)
{
if (dialogueRunner.Dialogue != null && dialogueRunner.Dialogue.NodeExists(nodeName))
@@ -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
@@ -6,7 +6,18 @@ namespace NightclubArcadia.Skills
{
/// <summary>
/// Static Yarn adapter. No MonoBehaviour, no check math — translation only.
/// Call sites: &lt;&lt;check persuasion standard&gt;&gt; / &lt;&lt;check intimidation hard -1&gt;&gt;
///
/// Commands:
/// &lt;&lt;check skillId band-or-int [situationalMod]&gt;&gt;
/// &lt;&lt;skill_mod sourceKey skillId delta&gt;&gt; — set a state/situational mod
/// &lt;&lt;clear_skill_mods sourceKey&gt;&gt; — clear one source key
/// Function (kept as skill_rank; skeleton's "rank" would collide with future helpers):
/// skill_rank("skillId")
///
/// Modifier convention: source key "scene" is the situational bucket and must be
/// cleared on scene exit; any other key (drunk, injured, …) is a state bucket.
/// Both feed SkillStateModifiers — three modifier sources total (rank / check arg /
/// state), not a fourth dictionary.
/// </summary>
public static class YarnSkillCommands
{
@@ -28,8 +39,8 @@ namespace NightclubArcadia.Skills
if (!DcBands.TryParse(difficulty, out var dc))
{
Debug.LogError($"[YarnSkillCommands] <<check {skillId} {difficulty}>>: unknown difficulty; treating as Standard (11).");
dc = (int)DcBand.Standard;
Debug.LogError($"[YarnSkillCommands] <<check {skillId} {difficulty}>>: unknown difficulty; treating as Routine (11).");
dc = (int)DcBand.Routine;
}
if (runtime.Database == null || !runtime.Database.TryGet(skillId, out _))
@@ -49,6 +60,44 @@ namespace NightclubArcadia.Skills
yield return runtime.StartCoroutine(runtime.ResultView.Show(result));
}
[YarnCommand("skill_mod")]
public static void SkillMod(string sourceKey, string skillId, int delta)
{
var runtime = SkillRuntime.Instance;
if (runtime == null || runtime.States == null)
{
Debug.LogError("[YarnSkillCommands] <<skill_mod>> called but SkillRuntime is not ready.");
return;
}
if (string.IsNullOrEmpty(sourceKey) || string.IsNullOrEmpty(skillId))
{
Debug.LogError("[YarnSkillCommands] <<skill_mod>> requires sourceKey and skillId.");
return;
}
runtime.States.Set(sourceKey, skillId, delta);
}
[YarnCommand("clear_skill_mods")]
public static void ClearSkillMods(string sourceKey)
{
var runtime = SkillRuntime.Instance;
if (runtime == null || runtime.States == null)
{
Debug.LogError("[YarnSkillCommands] <<clear_skill_mods>> called but SkillRuntime is not ready.");
return;
}
if (string.IsNullOrEmpty(sourceKey))
{
Debug.LogError("[YarnSkillCommands] <<clear_skill_mods>> requires a sourceKey.");
return;
}
runtime.States.Clear(sourceKey);
}
[YarnFunction("skill_rank")]
public static float SkillRank(string skillId)
{
@@ -87,7 +136,7 @@ namespace NightclubArcadia.Skills
DcBands.TryParse(difficulty, out var dc);
if (dc == 0)
{
dc = (int)DcBand.Standard;
dc = (int)DcBand.Routine;
}
var storage = runner.VariableStorage;