worldbox-observer-mod/IdleSpectator/Events/EventPresentability.cs
DazedAnon 54476fb7a1 feat(narrative): enhance narrative probes and combat lifecycle tracking
- Introduce new probes for combat lifecycle, scheduler bounds, and exposure
- Implement narrative scheduler health probe in harness scenarios
- Update InterestCandidate to include approved presentation details
- Refactor EventPresentability to manage candidate approval and caching
- Improve IdleHitchProbe to track performance metrics during narrative events
2026-07-23 17:21:23 -05:00

176 lines
5.6 KiB
C#

using System;
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Single source of truth: would the orange dossier show this EventLed Label?
/// Selection must equal presentation - unpresentable beats must not win Watching.
/// </summary>
public static class EventPresentability
{
private const int CacheLimit = 256;
private sealed class CacheEntry
{
public string Label = "";
public long SubjectId;
public long RelatedId;
public InterestLeadKind LeadKind;
public bool Result;
public float LastAccessAt;
}
private sealed class CacheTicket
{
public string Key = "";
public CacheEntry Entry;
}
private static readonly Dictionary<string, CacheEntry> Cache =
new Dictionary<string, CacheEntry>(CacheLimit, StringComparer.Ordinal);
private static readonly Queue<CacheTicket> CacheOrder =
new Queue<CacheTicket>(CacheLimit + 1);
public static void ClearCache()
{
Cache.Clear();
CacheOrder.Clear();
}
/// <summary>
/// True when the candidate may own Layer A camera.
/// Empty Label is only allowed for CharacterLed fill (task chip, no orange reason).
/// </summary>
public static bool WouldShow(InterestCandidate scene)
{
if (scene == null)
{
return false;
}
if (IsApproved(scene))
{
return true;
}
if (string.IsNullOrEmpty(scene.Label))
{
return scene.LeadKind == InterestLeadKind.CharacterLed;
}
string key = scene.Key ?? "";
if (!string.IsNullOrEmpty(key)
&& Cache.TryGetValue(key, out CacheEntry cached)
&& cached.SubjectId == scene.SubjectId
&& cached.RelatedId == scene.RelatedId
&& cached.LeadKind == scene.LeadKind
&& string.Equals(cached.Label, scene.Label, StringComparison.Ordinal))
{
cached.LastAccessAt = Time.unscaledTime;
return cached.Result;
}
bool result =
!string.IsNullOrEmpty(UnitDossier.EvaluateSceneLabel(scene, recordDrops: false));
if (!string.IsNullOrEmpty(key))
{
var entry = new CacheEntry
{
Label = scene.Label,
SubjectId = scene.SubjectId,
RelatedId = scene.RelatedId,
LeadKind = scene.LeadKind,
Result = result,
LastAccessAt = Time.unscaledTime
};
Cache[key] = entry;
CacheOrder.Enqueue(new CacheTicket { Key = key, Entry = entry });
TrimCache();
}
return result;
}
/// <summary>
/// Stamp the exact identity that passed the intake gate. Repeated scheduler/director
/// checks can trust this fingerprint without rescanning the world; any later mutation
/// naturally invalidates it.
/// </summary>
public static void MarkApproved(InterestCandidate scene)
{
if (scene == null)
{
return;
}
scene.ApprovedPresentationLabel = scene.Label ?? "";
scene.ApprovedPresentationSubjectId = scene.SubjectId;
scene.ApprovedPresentationRelatedId = scene.RelatedId;
scene.ApprovedPresentationLeadKind = scene.LeadKind;
scene.HasApprovedPresentation = true;
}
public static bool IsApproved(InterestCandidate scene) =>
scene != null
&& scene.HasApprovedPresentation
&& scene.ApprovedPresentationSubjectId == scene.SubjectId
&& scene.ApprovedPresentationRelatedId == scene.RelatedId
&& scene.ApprovedPresentationLeadKind == scene.LeadKind
&& string.Equals(
scene.ApprovedPresentationLabel,
scene.Label ?? "",
StringComparison.Ordinal);
private static void TrimCache()
{
// Happiness bursts can publish hundreds of distinct keys. Scanning the whole
// dictionary to find the oldest entry for every insertion made cache maintenance
// quadratic. Tickets make eviction constant-time and safely ignore overwritten keys.
while (Cache.Count > CacheLimit && CacheOrder.Count > 0)
{
CacheTicket ticket = CacheOrder.Dequeue();
if (ticket != null
&& !string.IsNullOrEmpty(ticket.Key)
&& Cache.TryGetValue(ticket.Key, out CacheEntry current)
&& ReferenceEquals(current, ticket.Entry))
{
Cache.Remove(ticket.Key);
}
}
}
/// <summary>
/// Shown orange reason, or empty when blanked by identity / ownership rules.
/// </summary>
public static string ShownReason(InterestCandidate scene, bool recordDrops = true)
{
return UnitDossier.EvaluateSceneLabel(scene, recordDrops);
}
/// <summary>
/// Harness synthetic tips ("HoldAction") must be subject-led EventReason shape
/// so the presentability gate matches production dossier rules.
/// </summary>
public static string EnsureSubjectLedLabel(Actor subject, string label)
{
if (string.IsNullOrEmpty(label))
{
return label ?? "";
}
string name = EventFeedUtil.SafeName(subject);
if (string.IsNullOrEmpty(name))
{
return label;
}
if (label.StartsWith(name, System.StringComparison.OrdinalIgnoreCase))
{
return label;
}
// "Name is in scene: HoldAction" → EventSentence + subject-led first token.
return name + " is in scene: " + label;
}
}