using System; using System.Collections.Generic; using UnityEngine; namespace IdleSpectator; /// /// Bounded merge/expiry store for . Sole intake for the director. /// public static class InterestRegistry { public const int MaxCandidates = 96; public const float DefaultTtlSeconds = 45f; private static readonly object Gate = new object(); private static readonly Dictionary ByKey = new Dictionary(128); private static readonly List Scratch = new List(128); public static int Count { get { lock (Gate) { return ByKey.Count; } } } /// Unselected candidates still eligible for scheduling. public static int PendingCount { get { lock (Gate) { int n = 0; foreach (KeyValuePair kv in ByKey) { if (kv.Value != null && !kv.Value.Selected && kv.Value.HasValidPosition) { n++; } } return n; } } } public static void Clear() { lock (Gate) { ByKey.Clear(); } } public static InterestCandidate Upsert(InterestCandidate incoming) { if (incoming == null || string.IsNullOrEmpty(incoming.Key) || !incoming.HasValidPosition) { return null; } float now = Time.unscaledTime; if (incoming.CreatedAt <= 0f) { incoming.CreatedAt = now; } if (incoming.LastSeenAt <= 0f) { incoming.LastSeenAt = now; } if (incoming.ExpiresAt <= 0f) { incoming.ExpiresAt = now + DefaultTtlSeconds; } lock (Gate) { if (ByKey.TryGetValue(incoming.Key, out InterestCandidate existing) && existing != null) { // Ownership merge: same key must not adopt a different subject's FollowUnit/Label. bool sameSubject = incoming.SubjectId == 0 || existing.SubjectId == 0 || incoming.SubjectId == existing.SubjectId; if (!sameSubject) { // Reject subject hijack; keep existing ownership, still refresh TTL/scores lightly. existing.LastSeenAt = now; existing.ExpiresAt = Mathf.Max(existing.ExpiresAt, incoming.ExpiresAt); existing.EventStrength = Mathf.Max(existing.EventStrength, incoming.EventStrength); existing.TotalScore = Mathf.Max(existing.TotalScore, incoming.TotalScore); existing.Selected = false; return existing; } existing.LastSeenAt = now; existing.ExpiresAt = Mathf.Max(existing.ExpiresAt, incoming.ExpiresAt); existing.EventStrength = Mathf.Max(existing.EventStrength, incoming.EventStrength); existing.CharacterSignificance = Mathf.Max( existing.CharacterSignificance, incoming.CharacterSignificance); existing.VisualConfidence = Mathf.Max(existing.VisualConfidence, incoming.VisualConfidence); existing.TotalScore = Mathf.Max(existing.TotalScore, incoming.TotalScore); if (!string.IsNullOrEmpty(incoming.Label)) { existing.Label = incoming.Label; } if (incoming.FollowUnit != null && incoming.FollowUnit.isAlive()) { existing.FollowUnit = incoming.FollowUnit; existing.SubjectId = incoming.SubjectId != 0 ? incoming.SubjectId : EventFeedUtil.SafeId(incoming.FollowUnit); existing.Position = incoming.FollowUnit.current_position; } else if (incoming.Position != Vector3.zero) { existing.Position = incoming.Position; } if (incoming.RelatedUnit != null) { existing.RelatedUnit = incoming.RelatedUnit; existing.RelatedId = incoming.RelatedId; } if (incoming.ParticipantCount > existing.ParticipantCount) { existing.ParticipantCount = incoming.ParticipantCount; } if (incoming.NotableParticipantCount > existing.NotableParticipantCount) { existing.NotableParticipantCount = incoming.NotableParticipantCount; } if (incoming.ForceActive) { existing.ForceActive = true; } existing.Selected = false; return existing; } ByKey[incoming.Key] = incoming; TrimLocked(now); return incoming; } } public static int CountKeysContaining(string needle) { if (string.IsNullOrEmpty(needle)) { return Count; } lock (Gate) { int n = 0; foreach (KeyValuePair kv in ByKey) { InterestCandidate c = kv.Value; if (c?.Key != null && c.Key.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0) { n++; continue; } if (c?.HappinessEffectId != null && c.HappinessEffectId.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0) { n++; } } return n; } } public static void ForceExpireContaining(string needle, float now) { lock (Gate) { Scratch.Clear(); foreach (KeyValuePair kv in ByKey) { InterestCandidate c = kv.Value; if (c == null) { continue; } bool match = string.IsNullOrEmpty(needle) || (c.Key != null && c.Key.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0); if (match) { c.ExpiresAt = now - 0.01f; Scratch.Add(c); } } } ExpireStale(now); } public static bool TryGet(string key, out InterestCandidate candidate) { lock (Gate) { return ByKey.TryGetValue(key ?? "", out candidate) && candidate != null; } } public static void MarkSelected(string key) { if (string.IsNullOrEmpty(key)) { return; } lock (Gate) { if (ByKey.TryGetValue(key, out InterestCandidate c) && c != null) { c.Selected = true; } } } public static void MarkUnselected(string key) { if (string.IsNullOrEmpty(key)) { return; } lock (Gate) { if (ByKey.TryGetValue(key, out InterestCandidate c) && c != null) { c.Selected = false; c.LastSeenAt = Time.unscaledTime; } } } public static void Remove(string key) { if (string.IsNullOrEmpty(key)) { return; } lock (Gate) { ByKey.Remove(key); } } public static void ExpireStale(float now) { lock (Gate) { Scratch.Clear(); foreach (KeyValuePair kv in ByKey) { InterestCandidate c = kv.Value; if (c == null || now >= c.ExpiresAt || !c.HasValidPosition) { Scratch.Add(c); } } for (int i = 0; i < Scratch.Count; i++) { InterestCandidate c = Scratch[i]; if (c != null && !string.IsNullOrEmpty(c.Key)) { ByKey.Remove(c.Key); } } } } /// Copy live unselected candidates into (cleared first). public static void CopyPending(List dest) { if (dest == null) { return; } dest.Clear(); lock (Gate) { foreach (KeyValuePair kv in ByKey) { InterestCandidate c = kv.Value; if (c == null || c.Selected || !c.HasValidPosition) { continue; } dest.Add(c); } } } public static bool HasPendingScoreAtLeast(float minScore) { lock (Gate) { foreach (KeyValuePair kv in ByKey) { InterestCandidate c = kv.Value; if (c != null && !c.Selected && c.HasValidPosition && c.TotalScore >= minScore) { return true; } } return false; } } /// True when any unselected EventLed candidate is waiting for the camera. public static bool HasPendingEvent() { lock (Gate) { foreach (KeyValuePair kv in ByKey) { InterestCandidate c = kv.Value; if (c == null || c.Selected || !c.HasValidPosition) { continue; } if (c.LeadKind == InterestLeadKind.EventLed) { return true; } } return false; } } private static void TrimLocked(float now) { if (ByKey.Count <= MaxCandidates) { return; } Scratch.Clear(); foreach (KeyValuePair kv in ByKey) { if (kv.Value != null) { Scratch.Add(kv.Value); } } Scratch.Sort(InterestScoring.CompareByScore); for (int i = MaxCandidates; i < Scratch.Count; i++) { InterestCandidate drop = Scratch[i]; if (drop != null && !string.IsNullOrEmpty(drop.Key)) { ByKey.Remove(drop.Key); } } } }