using System; using UnityEngine; namespace IdleSpectator; /// /// Sole unit-led publish API for the idle director. /// Every registration is EventLed and may own the camera (score ranks). /// FollowUnit owns the reason; RelatedUnit required when the label claims a second party; /// location-only civic events may omit FollowUnit but never invent a stranger subject. /// public static class EventFeedUtil { public static InterestCandidate Register( string key, string source, string category, string label, string assetId, float eventStrength, Vector3 position, Actor follow, Actor related = null, bool locationOnly = false, float minWatch = 4f, float maxWatch = 22f, InterestCompletionKind completion = InterestCompletionKind.FixedDwell) { if (string.IsNullOrEmpty(key)) { return null; } Actor subject = follow; if (subject != null && !subject.isAlive()) { subject = null; } Actor relatedAlive = related; if (relatedAlive != null && !relatedAlive.isAlive()) { relatedAlive = null; } Vector3 pos = position; if (subject != null) { pos = subject.current_position; } if (locationOnly) { if (pos == Vector3.zero || float.IsNaN(pos.x)) { return null; } } else { // Unit-led: require a living subject. Never invent a stranger. if (subject == null) { return null; } if (pos == Vector3.zero || float.IsNaN(pos.x)) { return null; } } float strength = eventStrength; if (strength < 8f) { strength = 8f; } var candidate = new InterestCandidate { Key = key, LeadKind = InterestLeadKind.EventLed, Category = string.IsNullOrEmpty(category) ? "Event" : category, Source = source ?? "event", EventStrength = strength, CharacterSignificance = 0f, VisualConfidence = subject != null ? 0.7f : 0.35f, Position = pos, FollowUnit = subject, RelatedUnit = relatedAlive, SubjectId = subject != null ? SafeId(subject) : 0, RelatedId = relatedAlive != null ? SafeId(relatedAlive) : 0, Label = label ?? assetId ?? key, AssetId = assetId ?? "", SpeciesId = subject?.asset != null ? subject.asset.id : "", // StatusPhase publishes the status id as AssetId - stamp StatusId before Upsert // so NoteSelection / beat cools see the class on first SwitchTo. StatusId = completion == InterestCompletionKind.StatusPhase ? (assetId ?? "") : "", CreatedAt = Time.unscaledTime, LastSeenAt = Time.unscaledTime, ExpiresAt = Time.unscaledTime + 35f, MinWatch = minWatch, MaxWatch = maxWatch, Completion = completion }; if (!EventPresentability.WouldShow(candidate)) { InterestDropLog.Record("unpresentable", candidate.Label ?? key); return null; } InterestScoring.ScoreCheap(candidate); return InterestRegistry.Upsert(candidate); } /// /// Publish a fully built candidate through the registry (still the sole intake). /// Prefer the typed Register overload for new feeds. /// public static InterestCandidate RegisterCandidate(InterestCandidate candidate) { if (candidate == null || string.IsNullOrEmpty(candidate.Key) || !candidate.HasValidPosition) { return null; } // Reject stranger subjects: dead follow with no position is already invalid via HasValidPosition. if (candidate.FollowUnit != null && !candidate.FollowUnit.isAlive()) { candidate.FollowUnit = null; candidate.SubjectId = 0; if (!candidate.HasValidPosition) { return null; } } if (candidate.RelatedUnit != null && !candidate.RelatedUnit.isAlive()) { candidate.RelatedUnit = null; candidate.RelatedId = 0; } if (candidate.FollowUnit != null && candidate.SubjectId == 0) { candidate.SubjectId = SafeId(candidate.FollowUnit); } if (candidate.RelatedUnit != null && candidate.RelatedId == 0) { candidate.RelatedId = SafeId(candidate.RelatedUnit); } // Harness injects synthetic tip labels for director tests; dossier still blanks them. // Production feeds (Source != harness) must be presentable to enter the registry. if (!IsHarnessSource(candidate.Source) && !EventPresentability.WouldShow(candidate)) { InterestDropLog.Record("unpresentable", candidate.Label ?? candidate.Key); return null; } InterestScoring.ScoreCheap(candidate); return InterestRegistry.Upsert(candidate); } private static bool IsHarnessSource(string source) => string.Equals(source, "harness", StringComparison.OrdinalIgnoreCase); public static long SafeId(Actor actor) { if (actor == null) { return 0; } try { return actor.getID(); } catch { return 0; } } /// /// O(1) living actor lookup via ActorManager.get. Prefer this over scanning /// . /// public static Actor FindAliveById(long id) { Actor actor = FindUnitById(id); if (actor == null) { return null; } try { return actor.isAlive() ? actor : null; } catch { return null; } } /// /// O(1) actor lookup including dead units (naming fallen duel partners, etc.). /// public static Actor FindUnitById(long id) { if (id == 0 || World.world?.units == null) { return null; } try { return World.world.units.get(id); } catch { return null; } } public static string SafeName(Actor actor) { if (actor == null) { return ""; } try { return actor.getName() ?? ""; } catch { return ""; } } public static Actor NearestUnit(Vector3 near, float radius = 200f) { try { return WorldActivityScanner.FindNearestAliveUnit(near, radius); } catch { return null; } } /// /// Harness / diagnostics only. Must not be used as a silent camera subject for EventLed events. /// public static Actor AnyAliveUnit() { try { if (World.world?.units == null) { return null; } foreach (Actor actor in World.world.units) { if (actor != null && actor.isAlive()) { return actor; } } } catch { // ignore } return null; } /// /// Resolve a unit-led anchor without stranger fallback. /// Preferred unit if alive; else nearest at position; else fail (caller may location-only). /// public static bool TryResolveOwnedAnchor( Actor preferred, Vector3 position, out Actor follow, out Vector3 pos, bool allowNearestAtPosition = true) { follow = null; pos = position; if (preferred != null && preferred.isAlive()) { follow = preferred; pos = preferred.current_position; return pos != Vector3.zero && !float.IsNaN(pos.x); } if (allowNearestAtPosition && position != Vector3.zero && !float.IsNaN(position.x)) { follow = NearestUnit(position, 48f); if (follow != null && follow.isAlive()) { pos = follow.current_position; return true; } } follow = null; pos = position; return false; } /// Obsolete stranger path. Prefer TryResolveOwnedAnchor or location-only Register. [System.Obsolete("Use TryResolveOwnedAnchor or location-only Register. Do not invent subjects.")] public static bool TryResolveAnchor(Actor preferred, Vector3 position, out Actor follow, out Vector3 pos) { return TryResolveOwnedAnchor(preferred, position, out follow, out pos, allowNearestAtPosition: true); } }