worldbox-observer-mod/IdleSpectator/Events/EventPresentability.cs
DazedAnon 2293274d79 feat(event): enhance event handling and narrative presentation probes
- Introduce new probes for framing approval and family coalescing
- Implement exact presentation replay checks to prevent duplicate events
- Refactor InterestVariety to manage exact replay cooldowns effectively
- Update EventPresentability to improve candidate approval logic
- Enhance InterestFeeds to streamline event registration and labeling
2026-07-23 19:43:29 -05:00

231 lines
7.7 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. Ownership changes
/// invalidate it; explicitly recognized live framing updates retain approval.
/// </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)
{
if (scene == null
|| !scene.HasApprovedPresentation
|| scene.ApprovedPresentationSubjectId != scene.SubjectId
|| scene.ApprovedPresentationRelatedId != scene.RelatedId
|| scene.ApprovedPresentationLeadKind != scene.LeadKind)
{
return false;
}
string approved = scene.ApprovedPresentationLabel ?? "";
string current = scene.Label ?? "";
if (string.Equals(approved, current, StringComparison.Ordinal))
{
return true;
}
// Sticky scene maintenance may refresh scale/tier without changing ownership.
// Re-running UnitDossier's stranger scan across a developed world for every
// headcount tick made the scheduler proposal stage hitch badly.
bool liveFraming =
scene.Completion == InterestCompletionKind.CombatActive
|| scene.Completion == InterestCompletionKind.WarFront
|| scene.Completion == InterestCompletionKind.StatusOutbreak
|| scene.Completion == InterestCompletionKind.FamilyPack;
if (!liveFraming)
{
return false;
}
if (EventReason.IsHeadcountOnlyChange(approved, current))
{
return true;
}
return scene.Completion == InterestCompletionKind.CombatActive
&& EventReason.IsCombatFramingOnlyChange(approved, current);
}
public static bool HarnessProbeFramingApproval(out string detail)
{
var scene = new InterestCandidate
{
SubjectId = 41,
RelatedId = 42,
LeadKind = InterestLeadKind.EventLed,
Completion = InterestCompletionKind.CombatActive,
Label = "Battle - Alpha (8) vs Beta (4)"
};
MarkApproved(scene);
scene.Label = "Mass - Beta (3) vs Alpha (12)";
bool framingKept = IsApproved(scene);
scene.Label = "Mass - Gamma (3) vs Alpha (12)";
bool foreignCampRejected = !IsApproved(scene);
scene.Label = "Mass - Beta (3) vs Alpha (12)";
scene.SubjectId = 99;
bool ownerMutationRejected = !IsApproved(scene);
bool ok = framingKept && foreignCampRejected && ownerMutationRejected;
detail = "framing=" + framingKept
+ " foreignCamp=" + foreignCampRejected
+ " ownerMutation=" + ownerMutationRejected
+ " pass=" + ok;
return ok;
}
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;
}
}