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, NarrativePresentationModel presentation = null) { 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, NarrativeDevelopmentId = presentation?.DevelopmentId ?? "", NarrativePresentation = presentation?.Clone(), 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 (candidate.NarrativePresentation == null) { candidate.NarrativePresentation = NarrativePresentationFactory.Candidate( key, source, assetId, candidate.Label, subject, relatedAlive); } NarrativeFunctionCatalog.Stamp(candidate); NarrativePresentationCoverage.NoteIntake(candidate); if (!EventPresentability.WouldShow(candidate)) { InterestDropLog.Record("unpresentable", candidate.Label ?? key); return null; } EventPresentability.MarkApproved(candidate); InterestScoring.ScoreCheap(candidate); InterestCandidate registered = InterestRegistry.Upsert(candidate); EventPresentability.MarkApproved(registered); return registered; } /// /// 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); } if (candidate.NarrativePresentation == null) { candidate.NarrativePresentation = NarrativePresentationFactory.Candidate( candidate.Key, candidate.Source, candidate.AssetId, candidate.Label, candidate.FollowUnit, candidate.RelatedUnit); } NarrativeFunctionCatalog.Stamp(candidate); NarrativePresentationCoverage.NoteIntake(candidate); // 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; } if (!IsHarnessSource(candidate.Source)) { EventPresentability.MarkApproved(candidate); } InterestScoring.ScoreCheap(candidate); InterestCandidate registered = InterestRegistry.Upsert(candidate); if (!IsHarnessSource(candidate.Source)) { EventPresentability.MarkApproved(registered); } return registered; } private static bool IsHarnessSource(string source) => string.Equals(source, "harness", StringComparison.OrdinalIgnoreCase); public static bool HarnessProbePresentationCoverage(Actor subject, out string detail) { NarrativePresentationCoverage.Clear(); if (subject == null || !subject.isAlive()) { detail = "missing living subject"; return false; } float now = Time.unscaledTime; string label = SafeName(subject) + " rests after the harvest"; var candidate = new InterestCandidate { Key = "harness:narrative:presentation-coverage:" + SafeId(subject), LeadKind = InterestLeadKind.EventLed, Category = "Life", Source = "harness", EventStrength = 30f, VisualConfidence = 1f, Position = subject.current_position, FollowUnit = subject, SubjectId = SafeId(subject), Label = label, AssetId = "harness_presentation_coverage", CreatedAt = now, LastSeenAt = now, ExpiresAt = now + 15f, MinWatch = 1f, MaxWatch = 4f, Completion = InterestCompletionKind.FixedDwell }; string renderedBeat = ""; InterestCandidate registered = RegisterCandidate(candidate); bool rendered = registered != null && NarrativeProse.TryBuildBeat( subject, registered, out _, out renderedBeat, out _); bool ok = registered?.NarrativePresentation != null && rendered && !string.IsNullOrEmpty(renderedBeat) && NarrativePresentationCoverage.IntakeStructuredCount == 1 && NarrativePresentationCoverage.IntakeCompatibilityCount == 0 && NarrativePresentationCoverage.CompatibilityCount == 0 && NarrativePresentationCoverage.RedundancyCount == 0; detail = "registered=" + (registered != null) + " typed=" + (registered?.NarrativePresentation != null) + " beat='" + renderedBeat + "' " + NarrativePresentationCoverage.Summary + " pass=" + ok; return ok; } 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); } }