using System.Collections.Generic; using NeoModLoader.services; using UnityEngine; namespace IdleSpectator; /// /// Event-centered scene director: registry intake, protected sessions, Epic-only preemption /// for Action+ scenes, soft 70/30 variety, completion via . /// public static class InterestDirector { public const float MinDwellSeconds = 10f; public const float CuriosityDwellSeconds = 6f; public const float CuriosityRotateSeconds = 10f; public const float SwitchCooldownSeconds = 5f; public const float HighTierInterruptAfterSeconds = 3f; public const float QuietWorldAmbientAfterSeconds = 8f; public const float AmbientRotateSeconds = 20f; public const float ActionPollSeconds = 0.75f; public const float InputExitGraceSeconds = 2.5f; public const float QuietGraceSeconds = 1.2f; public const float CameraSettleGraceSeconds = 3f; private static float _minDwell = MinDwellSeconds; private static float _curiosityDwell = CuriosityDwellSeconds; private static float _curiosityRotate = CuriosityRotateSeconds; private static float _switchCooldown = SwitchCooldownSeconds; private static float _settleGrace = CameraSettleGraceSeconds; private static float _quietAmbientAfter = QuietWorldAmbientAfterSeconds; private static float _ambientRotate = AmbientRotateSeconds; private static float _feedsTick = ActionPollSeconds; private static float _inputExitGrace = InputExitGraceSeconds; private static float _quietGrace = QuietGraceSeconds; private static InterestCandidate _current; private static InterestCandidate _interrupted; private static float _currentStartedAt = -999f; private static float _lastSwitchAt = -999f; private static float _lastInterestingAt = -999f; private static float _lastAmbientAt = -999f; private static float _lastFeedsAt = -999f; private static float _enabledAt = -999f; private static float _inactiveSince = -999f; private static readonly List PendingScratch = new List(96); public static string CurrentTierName => _current == null ? "none" : _current.Urgency.ToString(); public static string CurrentLabel => _current == null ? "" : (_current.Label ?? ""); public static string CurrentKey => _current == null ? "" : (_current.Key ?? ""); public static bool CurrentIsActive { get { if (_current == null) { return false; } return InterestCompletion.IsActive(_current, _currentStartedAt, Time.unscaledTime); } } public static InterestCandidate CurrentCandidate => _current; public static string InterruptedKey => _interrupted == null ? "" : (_interrupted.Key ?? ""); public static InterestLeadKind? CurrentLeadKind => _current == null ? (InterestLeadKind?)null : _current.LeadKind; public static bool InInputGrace => SpectatorMode.Active && Time.unscaledTime - _enabledAt < _inputExitGrace; public static void SetHarnessFastTiming(bool enabled) { if (enabled) { _minDwell = 1.5f; _curiosityDwell = 1.2f; _curiosityRotate = 1.5f; _switchCooldown = 0.75f; _settleGrace = 0.75f; _quietAmbientAfter = 1.5f; _ambientRotate = 2.5f; _feedsTick = 0.35f; _inputExitGrace = 0.6f; _quietGrace = 0.35f; } else { _minDwell = MinDwellSeconds; _curiosityDwell = CuriosityDwellSeconds; _curiosityRotate = CuriosityRotateSeconds; _switchCooldown = SwitchCooldownSeconds; _settleGrace = CameraSettleGraceSeconds; _quietAmbientAfter = QuietWorldAmbientAfterSeconds; _ambientRotate = AmbientRotateSeconds; _feedsTick = ActionPollSeconds; _inputExitGrace = InputExitGraceSeconds; _quietGrace = QuietGraceSeconds; } } public static void HarnessAgeCurrent(float seconds) { float age = Mathf.Max(0f, seconds); float now = Time.unscaledTime; _currentStartedAt = now - age; _lastSwitchAt = now - age; } public static void HarnessExpireInputGrace() { _enabledAt = Time.unscaledTime - _inputExitGrace - 0.05f; } /// Harness: force the given candidate as the active session. public static bool HarnessForceSession(InterestCandidate candidate) { if (candidate == null || !candidate.HasValidPosition) { return false; } InterestRegistry.Upsert(candidate); SwitchTo(candidate, Time.unscaledTime, resumableInterrupt: false); return true; } /// Harness: end the active scene (may resume an Epic-interrupted one). public static void HarnessEndCurrent(string reason = "harness") { EndCurrent(Time.unscaledTime, reason); } /// Harness: drop ForceActive so FixedDwell / quiet grace can finish the scene. public static void HarnessReleaseForceActive() { if (_current != null) { _current.ForceActive = false; } } /// /// Harness: mark the current scene inactive for /// so quiet-grace completion can be asserted. /// public static void HarnessMarkInactive(float inactiveSeconds) { if (_current == null) { return; } _current.ForceActive = false; if (_current.Completion == InterestCompletionKind.Manual) { _current.Completion = InterestCompletionKind.FixedDwell; } float now = Time.unscaledTime; float pastActive = Mathf.Max(_current.MinWatch, _current.MaxWatch * 0.35f) + 0.5f; _currentStartedAt = now - pastActive; _lastSwitchAt = _currentStartedAt; _inactiveSince = now - Mathf.Max(0f, inactiveSeconds); } /// Harness: clear FollowUnit while keeping RelatedUnit for death handoff. public static bool HarnessClearFollowForHandoff(Actor related) { if (_current == null) { return false; } if (related != null && related.isAlive()) { _current.RelatedUnit = related; try { _current.RelatedId = related.getID(); } catch { _current.RelatedId = 0; } } if (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()) { return false; } _current.FollowUnit = null; return true; } /// Harness: run one SelectNext without switching (lead-kind probe). public static InterestCandidate HarnessPeekNext() { return SelectNext(Time.unscaledTime); } public static void OnSpectatorEnabled() { InterestRegistry.Clear(); InterestVariety.Clear(); InterestScoring.ClearCache(); InterestFeeds.Reset(); _current = null; _interrupted = null; float now = Time.unscaledTime; _currentStartedAt = now; _lastSwitchAt = now; _lastInterestingAt = now; _lastAmbientAt = -999f; _lastFeedsAt = -999f; _enabledAt = now; _inactiveSince = -999f; // Harness scenarios inject their own candidates after enable/focus. if (!AgentHarness.Busy) { TryCharacterFill(force: true); } } public static void OnSpectatorDisabled() { _current = null; _interrupted = null; InterestRegistry.Clear(); } public static void PauseForBrowsing(string banner = "Paused (viewing history)") { if (SpectatorMode.Active) { SpectatorMode.SetActive(false, quiet: true); } WatchCaption.PinWhilePaused(); if (!string.IsNullOrEmpty(banner)) { WatchCaption.ShowStatusBanner(banner); } LogService.LogInfo("[IdleSpectator] Spectator paused (viewing history)"); } public static void Update() { if (!SpectatorMode.Active || !Config.game_loaded || World.world == null) { return; } WatchCaption.ClearPausePin(); if (AgentHarness.FreezeDirector) { return; } if (Time.unscaledTime - _enabledAt >= _inputExitGrace && PlayerTookManualControl()) { ExitFromManualInput(); return; } float now = Time.unscaledTime; if (now - _lastFeedsAt >= _feedsTick) { _lastFeedsAt = now; InterestFeeds.Tick(); InterestRegistry.ExpireStale(now); } float onCurrent = now - _currentStartedAt; float sinceSwitch = now - _lastSwitchAt; UpdateSessionLiveness(now, onCurrent); InterestCandidate next = SelectNext(now); if (next != null && CanSwitchTo(next, onCurrent, sinceSwitch)) { if (next.Urgency >= InterestTier.Action) { _lastInterestingAt = now; } SwitchTo(next, now, resumableInterrupt: false); onCurrent = 0f; sinceSwitch = 0f; } // Queued discovery tips beat ambient fill, but never stall under Action/Story. if (InterestRegistry.HasPendingAtLeast(InterestTier.Curiosity) && (_current == null || _current.Urgency <= InterestTier.Curiosity)) { return; } bool quiet = now - _lastInterestingAt >= _quietAmbientAfter; bool sceneDone = _current == null || !SessionProtected(now, onCurrent); bool ambientDue = now - _lastAmbientAt >= _ambientRotate; if (_current == null && sinceSwitch >= 0.5f) { TryCharacterFill(force: true); } else if (sceneDone && quiet && ambientDue && sinceSwitch >= _switchCooldown) { TryCharacterFill(force: false); } } private static void UpdateSessionLiveness(float now, float onCurrent) { if (_current == null) { _inactiveSince = -999f; return; } // Max cap ends the scene even if still "active". if (onCurrent >= MaxWatchFor(_current)) { EndCurrent(now, reason: "max_cap"); return; } bool active = InterestCompletion.IsActive(_current, _currentStartedAt, now); if (active) { _inactiveSince = -999f; TryHandoffFollow(); return; } if (_inactiveSince < 0f) { _inactiveSince = now; } if (now - _inactiveSince >= _quietGrace) { EndCurrent(now, reason: "quiet_grace"); } } private static void TryHandoffFollow() { if (_current == null) { return; } if (_current.HasFollowUnit) { return; } // Prefer related (grief survivor is already FollowUnit); try related then participants. if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive()) { _current.FollowUnit = _current.RelatedUnit; CameraDirector.Watch(_current.ToInterestEvent()); return; } Actor near = WorldActivityScanner.FindNearestAliveUnit(_current.Position, 18f); if (near != null) { _current.FollowUnit = near; CameraDirector.Watch(_current.ToInterestEvent()); } } private static void EndCurrent(float now, string reason) { if (_current != null) { InterestRegistry.Remove(_current.Key); LogService.LogInfo("[IdleSpectator] Scene end (" + reason + "): " + _current.Label); } // Resume interrupted Epic-preempted scene if still valid. if (_interrupted != null && _interrupted.HasValidPosition && InterestCompletion.IsActive(_interrupted, now - _interrupted.MinWatch, now)) { InterestCandidate resume = _interrupted; _interrupted = null; SwitchTo(resume, now, resumableInterrupt: false); return; } _interrupted = null; _current = null; _inactiveSince = -999f; } private static InterestCandidate SelectNext(float now) { InterestRegistry.CopyPending(PendingScratch); if (PendingScratch.Count == 0) { return null; } float onCurrent = now - _currentStartedAt; float sinceSwitch = now - _lastSwitchAt; for (int i = PendingScratch.Count - 1; i >= 0; i--) { InterestCandidate c = PendingScratch[i]; if (c == null || !CanSwitchTo(c, onCurrent, sinceSwitch)) { PendingScratch.RemoveAt(i); } } if (PendingScratch.Count == 0) { return null; } InterestScoring.EnrichTopK(PendingScratch); return InterestVariety.Pick(PendingScratch, _current); } /// /// True when a drained New species tip would be allowed to take the camera. /// public static bool WouldAcceptCuriosity() { if (_current == null) { return true; } float onCurrent = Time.unscaledTime - _currentStartedAt; float sinceSwitch = Time.unscaledTime - _lastSwitchAt; return CanSwitchTo( new InterestCandidate { Urgency = InterestTier.Curiosity, Key = "probe:curiosity" }, onCurrent, sinceSwitch); } public static bool SimulateManualInputExit(bool honorGrace = false) { if (!SpectatorMode.Active) { return false; } MoveCamera.camera_drag_run = true; bool detected = PlayerTookManualControl(); if (!detected) { MoveCamera.camera_drag_run = false; return false; } if (honorGrace && Time.unscaledTime - _enabledAt < _inputExitGrace) { MoveCamera.camera_drag_run = false; return false; } ExitFromManualInput(); MoveCamera.camera_drag_run = false; return !SpectatorMode.Active; } private static void ExitFromManualInput() { SpectatorMode.SetActive(false, quiet: true); WatchCaption.ShowStatusBanner("Paused (manual input)"); LogService.LogInfo("[IdleSpectator] Spectator paused (manual input)"); } private static bool PlayerTookManualControl() { if (MoveCamera.camera_drag_run) { return true; } if (Mathf.Abs(Input.mouseScrollDelta.y) > 0.01f) { return true; } if (HotkeyLibrary.left != null && (HotkeyLibrary.left.isHolding() || HotkeyLibrary.right.isHolding() || HotkeyLibrary.up.isHolding() || HotkeyLibrary.down.isHolding())) { return true; } if (HotkeyLibrary.zoom_in != null && (HotkeyLibrary.zoom_in.isHolding() || HotkeyLibrary.zoom_out.isHolding())) { return true; } if (!World.world.isOverUI() && !WatchCaption.IsPointerOverInteractive() && !ChronicleHud.IsPointerOverPanel() && (InputHelpers.GetMouseButtonDown(0) || InputHelpers.GetMouseButtonDown(1))) { return true; } return false; } private static float MinDwellFor(InterestCandidate current) { if (current == null) { return _minDwell; } if (current.MinWatch > 0f) { return current.Urgency <= InterestTier.Curiosity ? Mathf.Min(current.MinWatch, _curiosityDwell) : Mathf.Max(current.MinWatch, _minDwell * 0.15f); } return current.Urgency <= InterestTier.Curiosity ? _curiosityDwell : _minDwell; } private static float MaxWatchFor(InterestCandidate current) { if (current == null) { return 45f; } float cap = current.MaxWatch > 0f ? current.MaxWatch : 45f; // Harness fast timing compresses caps. if (_minDwell < MinDwellSeconds * 0.5f) { cap = Mathf.Min(cap, 8f); } return cap; } /// /// Protected Action+ scenes: only Epic may preempt after settle grace. /// Ambient/Curiosity may be replaced by higher urgency after settle. /// private static bool SessionProtected(float now, float onCurrent) { if (_current == null) { return false; } if (onCurrent >= MaxWatchFor(_current)) { return false; } bool active = InterestCompletion.IsActive(_current, _currentStartedAt, now); if (!active) { if (_inactiveSince >= 0f && now - _inactiveSince >= _quietGrace) { return false; } // Still in quiet grace - treat as protected briefly. return _inactiveSince < 0f || now - _inactiveSince < _quietGrace; } return true; } private static bool CanSwitchTo(InterestCandidate candidate, float onCurrent, float sinceSwitch) { if (candidate == null) { return false; } if (_current == null) { return true; } float now = Time.unscaledTime; bool protectedScene = SessionProtected(now, onCurrent); InterestTier cur = _current.Urgency; InterestTier next = candidate.Urgency; // Curiosity never interrupts Action+. if (next == InterestTier.Curiosity && cur >= InterestTier.Action) { return false; } if (next == InterestTier.Curiosity && cur == InterestTier.Curiosity && onCurrent < _curiosityRotate) { return false; } if (protectedScene && cur >= InterestTier.Action) { // Action/Story protected: only Epic after settle grace. if (next >= InterestTier.Epic && onCurrent >= _settleGrace) { return true; } return false; } // Ambient/Curiosity (or unprotected): higher urgency after settle; else dwell+cooldown. if (next > cur && onCurrent >= _settleGrace) { return true; } if (!protectedScene && onCurrent >= MinDwellFor(_current) && sinceSwitch >= _switchCooldown) { return true; } return onCurrent >= MinDwellFor(_current) && sinceSwitch >= _switchCooldown && next >= cur; } private static void SwitchTo(InterestCandidate next, float now, bool resumableInterrupt) { if (next == null) { return; } if (_current != null && next.Urgency >= InterestTier.Epic && _current.Urgency < InterestTier.Epic && _current.Resumable) { _interrupted = _current.CloneShallow(); } _current = next; _currentStartedAt = now; _lastSwitchAt = now; _inactiveSince = -999f; if (next.Urgency >= InterestTier.Action) { _lastInterestingAt = now; } InterestRegistry.MarkSelected(next.Key); InterestVariety.NoteSelection(next); CameraDirector.Watch(next.ToInterestEvent()); } private static void TryCharacterFill(bool force) { float now = Time.unscaledTime; _lastAmbientAt = now; if (!force && _current != null && SessionProtected(now, now - _currentStartedAt) && _current.Urgency >= InterestTier.Story) { return; } if (!force && _current != null && _current.Urgency == InterestTier.Curiosity && now - _currentStartedAt < _ambientRotate) { return; } if (!force && now - _lastSwitchAt < _switchCooldown && _current != null) { return; } InterestEvent ambient = WorldActivityScanner.FindBestLiveTarget(); if (ambient == null) { return; } // Harness batches inject their own candidates; never let a live Action/Story // ambient fill steal the camera before trigger_interest / discovery steps. if (AgentHarness.Busy && ambient.Tier >= InterestTier.Action) { ambient.Tier = InterestTier.Ambient; if (string.IsNullOrEmpty(ambient.Label)) { ambient.Label = "Ambient"; } } InterestCandidate candidate = InterestFeeds.RegisterDirect(ambient); if (candidate == null) { return; } candidate.LeadKind = InterestLeadKind.CharacterLed; candidate.Category = "CharacterVignette"; candidate.Completion = InterestCompletionKind.CharacterVignette; if (AgentHarness.Busy) { candidate.Urgency = InterestTier.Ambient; candidate.ForceActive = false; InterestScoring.ScoreCheap(candidate); } if (!force && _current != null && _current.FollowUnit != null && candidate.FollowUnit != null && _current.FollowUnit == candidate.FollowUnit && candidate.Urgency <= InterestTier.Ambient) { return; } if (!force && !CanSwitchTo(candidate, now - _currentStartedAt, now - _lastSwitchAt)) { return; } SwitchTo(candidate, now, resumableInterrupt: false); LogService.LogInfo("[IdleSpectator] Ambient: " + candidate.Label); } }