worldbox-observer-mod/IdleSpectator/InterestRegistry.cs
2026-07-15 22:16:24 -05:00

354 lines
9.9 KiB
C#

using System;
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Bounded merge/expiry store for <see cref="InterestCandidate"/>. Sole intake for the director.
/// </summary>
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<string, InterestCandidate> ByKey =
new Dictionary<string, InterestCandidate>(128);
private static readonly List<InterestCandidate> Scratch = new List<InterestCandidate>(128);
public static int Count
{
get
{
lock (Gate)
{
return ByKey.Count;
}
}
}
/// <summary>Unselected candidates still eligible for scheduling.</summary>
public static int PendingCount
{
get
{
lock (Gate)
{
int n = 0;
foreach (KeyValuePair<string, InterestCandidate> 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.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<string, InterestCandidate> 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<string, InterestCandidate> 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<string, InterestCandidate> 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);
}
}
}
}
/// <summary>Copy live unselected candidates into <paramref name="dest"/> (cleared first).</summary>
public static void CopyPending(List<InterestCandidate> dest)
{
if (dest == null)
{
return;
}
dest.Clear();
lock (Gate)
{
foreach (KeyValuePair<string, InterestCandidate> 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<string, InterestCandidate> kv in ByKey)
{
InterestCandidate c = kv.Value;
if (c != null && !c.Selected && c.HasValidPosition && c.TotalScore >= minScore)
{
return true;
}
}
return false;
}
}
private static void TrimLocked(float now)
{
if (ByKey.Count <= MaxCandidates)
{
return;
}
Scratch.Clear();
foreach (KeyValuePair<string, InterestCandidate> 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);
}
}
}
}