using UnityEngine; namespace IdleSpectator; /// Whether an interest candidate may steal the idle camera. public enum InterestOwnership { /// May cut in / own EventLed camera focus. Signal, /// Tracked for fill/enrichment; fill-capped so it does not preempt Signal drama. Ambient } /// /// Sole unit-led publish API for the idle director. /// Ownership rule: 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, InterestOwnership ownership, 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; } } bool ambient = ownership == InterestOwnership.Ambient; float strength = eventStrength; if (ambient) { strength = Mathf.Min(strength, InterestScoringConfig.W.fillScoreMax - 1f); if (strength < 8f) { strength = 8f; } } var candidate = new InterestCandidate { Key = key, LeadKind = ambient ? InterestLeadKind.CharacterLed : InterestLeadKind.EventLed, Category = string.IsNullOrEmpty(category) ? "Event" : category, Source = source ?? "event", EventStrength = strength, CharacterSignificance = ambient ? strength * 0.5f : 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 : "", CreatedAt = Time.unscaledTime, LastSeenAt = Time.unscaledTime, ExpiresAt = Time.unscaledTime + (ambient ? 18f : 35f), MinWatch = minWatch, MaxWatch = maxWatch, Completion = completion }; 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); } InterestScoring.ScoreCheap(candidate); return InterestRegistry.Upsert(candidate); } public static long SafeId(Actor actor) { if (actor == null) { return 0; } try { return actor.getID(); } catch { return 0; } } 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 Signal 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); } }