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();
}
EventPresentability.ClearCache();
}
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.
// Combat pair locks also reject A↔B flips under combat:pair:lo:hi keys.
bool pairLocked = existing.HasCombatPairLock
&& existing.Completion == InterestCompletionKind.CombatActive;
bool sameSubject = incoming.SubjectId == 0
|| existing.SubjectId == 0
|| incoming.SubjectId == existing.SubjectId;
if (pairLocked
&& incoming.SubjectId != 0
&& incoming.SubjectId != existing.PairOwnerId
&& existing.PairOwnerId != 0)
{
sameSubject = false;
}
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);
if (existing.PairPartnerId == 0 && incoming.PairPartnerId != 0)
{
existing.PairPartnerId = incoming.PairPartnerId;
}
if (incoming.RelatedUnit != null
&& incoming.RelatedUnit.isAlive()
&& EventFeedUtil.SafeId(incoming.RelatedUnit) == existing.PairPartnerId)
{
existing.RelatedUnit = incoming.RelatedUnit;
existing.RelatedId = incoming.RelatedId;
}
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 (existing.PairOwnerId == 0 && incoming.PairOwnerId != 0)
{
existing.PairOwnerId = incoming.PairOwnerId;
}
if (existing.PairPartnerId == 0 && incoming.PairPartnerId != 0)
{
existing.PairPartnerId = incoming.PairPartnerId;
}
if (!string.IsNullOrEmpty(incoming.Label))
{
// Pair-locked Duels keep their owned tip wording across upserts.
bool keepPairLabel = pairLocked
&& !string.IsNullOrEmpty(existing.Label)
&& existing.Label.StartsWith("Duel", System.StringComparison.OrdinalIgnoreCase);
if (!keepPairLabel)
{
existing.Label = incoming.Label;
}
}
if (incoming.FollowUnit != null && incoming.FollowUnit.isAlive())
{
long incomingId = EventFeedUtil.SafeId(incoming.FollowUnit);
if (!pairLocked
|| existing.PairOwnerId == 0
|| incomingId == existing.PairOwnerId)
{
existing.FollowUnit = incoming.FollowUnit;
existing.SubjectId = incoming.SubjectId != 0
? incoming.SubjectId
: incomingId;
existing.Position = incoming.FollowUnit.current_position;
}
else if (existing.FollowUnit != null && existing.FollowUnit.isAlive())
{
existing.Position = existing.FollowUnit.current_position;
}
else if (incoming.Position != Vector3.zero)
{
existing.Position = incoming.Position;
}
}
else if (incoming.Position != Vector3.zero)
{
existing.Position = incoming.Position;
}
if (incoming.RelatedUnit != null && incoming.RelatedUnit.isAlive())
{
long incomingRelatedId = EventFeedUtil.SafeId(incoming.RelatedUnit);
// Pair-locked Duels keep the locked partner across attack_target upserts.
bool adoptRelated = !pairLocked
|| existing.PairPartnerId == 0
|| incomingRelatedId == existing.PairPartnerId;
if (adoptRelated)
{
existing.RelatedUnit = incoming.RelatedUnit;
existing.RelatedId = incoming.RelatedId;
if (existing.PairPartnerId == 0 && incomingRelatedId != 0)
{
existing.PairPartnerId = incomingRelatedId;
}
}
}
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)
|| (c.AssetId != null
&& c.AssetId.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);
}
}
}
}