using System; using System.Collections.Generic; using UnityEngine; namespace IdleSpectator; /// /// Single source of truth: would the orange dossier show this EventLed Label? /// Selection must equal presentation - unpresentable beats must not win Watching. /// 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 Cache = new Dictionary(CacheLimit, StringComparer.Ordinal); private static readonly Queue CacheOrder = new Queue(CacheLimit + 1); public static void ClearCache() { Cache.Clear(); CacheOrder.Clear(); } /// /// True when the candidate may own Layer A camera. /// Empty Label is only allowed for CharacterLed fill (task chip, no orange reason). /// 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; } /// /// 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. /// 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); } } } /// /// Shown orange reason, or empty when blanked by identity / ownership rules. /// public static string ShownReason(InterestCandidate scene, bool recordDrops = true) { return UnitDossier.EvaluateSceneLabel(scene, recordDrops); } /// /// Harness synthetic tips ("HoldAction") must be subject-led EventReason shape /// so the presentability gate matches production dossier rules. /// 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; } }