using System; using System.Collections.Generic; using UnityEngine; using Yarn.Unity; namespace NightclubArcadia.Skills { /// /// 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. /// public sealed class CommentarySystem : MonoBehaviour { [SerializeField] DialogueRunner dialogueRunner; [SerializeField] SkillDatabase database; [SerializeField] float minSecondsBetween = 20f; [SerializeField] int minRankToSpeak = 1; [SerializeField] int recencyWindow = 3; readonly List _recent = new List(); readonly HashSet _firedContexts = new HashSet(StringComparer.Ordinal); readonly Dictionary _lastFiredBySkill = new Dictionary(StringComparer.OrdinalIgnoreCase); float _lastFiredAt = float.NegativeInfinity; public void RequestCommentary(string contextId) { RequestCommentary(contextId, repeatable: false); } public void RequestCommentary(string contextId, bool repeatable) { if (string.IsNullOrEmpty(contextId)) { return; } if (dialogueRunner == null || database == null) { Debug.LogWarning($"[{nameof(CommentarySystem)}] Missing DialogueRunner or SkillDatabase.", this); return; } if (dialogueRunner.IsDialogueRunning) { return; } if (Time.time - _lastFiredAt < minSecondsBetween) { return; } if (!repeatable && _firedContexts.Contains(contextId)) { return; } var runtime = SkillRuntime.Instance; if (runtime == null || runtime.Skills == null || runtime.Rolls == null) { Debug.LogWarning($"[{nameof(CommentarySystem)}] SkillRuntime is not ready.", this); return; } var now = Time.time; var candidates = new List(); foreach (var skill in database.All) { if (skill == null || string.IsNullOrEmpty(skill.Id)) { continue; } if (runtime.Skills.GetRank(skill.Id) < minRankToSpeak) { 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)) { continue; } candidates.Add(skill); } var picked = Pick(candidates, runtime.Skills, _recent, recencyWindow, runtime.Rolls); if (picked == null) { return; } var startNode = $"Commentary_{contextId}_{picked.Id}"; _ = dialogueRunner.StartDialogue(startNode); _lastFiredAt = now; _lastFiredBySkill[picked.Id] = now; _firedContexts.Add(contextId); _recent.Add(picked.Id); while (_recent.Count > Math.Max(1, recencyWindow)) { _recent.RemoveAt(0); } } /// /// True when enough time has elapsed since lastFired given targetFiringsPerHour. /// targetFiringsPerHour <= 0 means the skill never fires. /// 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)) { return true; } var project = dialogueRunner.YarnProject; if (project != null) { foreach (var name in project.NodeNames) { if (string.Equals(name, nodeName, StringComparison.Ordinal)) { return true; } } } return false; } /// /// Weighted pick: weight = max(1, rank); recent speakers (within recencyWindow) have weight halved. /// public static SkillDefinition Pick( IReadOnlyList candidates, PlayerSkills skills, IReadOnlyList recent, int recencyWindowSize, IRollSource rolls) { if (candidates == null || candidates.Count == 0 || skills == null || rolls == null) { return null; } var weights = new int[candidates.Count]; var total = 0; for (var i = 0; i < candidates.Count; i++) { var skill = candidates[i]; var weight = Math.Max(1, skills.GetRank(skill.Id)); if (WasRecent(skill.Id, recent, recencyWindowSize)) { weight = Math.Max(1, weight / 2); } weights[i] = weight; total += weight; } if (total <= 0) { return null; } // IRollSource returns 1..sides; map onto [0, total). var pick = rolls.Roll(total) - 1; var cumulative = 0; for (var i = 0; i < candidates.Count; i++) { cumulative += weights[i]; if (pick < cumulative) { return candidates[i]; } } return candidates[candidates.Count - 1]; } static bool WasRecent(string skillId, IReadOnlyList recent, int window) { if (recent == null || recent.Count == 0 || window <= 0) { return false; } var start = Math.Max(0, recent.Count - window); for (var i = start; i < recent.Count; i++) { if (string.Equals(recent[i], skillId, StringComparison.OrdinalIgnoreCase)) { return true; } } return false; } } }