using NeoModLoader.services; using UnityEngine; namespace IdleSpectator; /// /// Picks the next interesting target using dwell, cooldown, and tier interrupts. /// 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; // Production defaults (copied when leaving harness fast timing). private static float _minDwell = MinDwellSeconds; private static float _curiosityDwell = CuriosityDwellSeconds; private static float _curiosityRotate = CuriosityRotateSeconds; private static float _switchCooldown = SwitchCooldownSeconds; private static float _highTierInterrupt = HighTierInterruptAfterSeconds; private static float _quietAmbientAfter = QuietWorldAmbientAfterSeconds; private static float _ambientRotate = AmbientRotateSeconds; private static float _actionPoll = ActionPollSeconds; private static float _inputExitGrace = InputExitGraceSeconds; private static InterestEvent _current; private static float _currentStartedAt = -999f; private static float _lastSwitchAt = -999f; private static float _lastInterestingAt = -999f; private static float _lastAmbientAt = -999f; private static float _lastActionPollAt = -999f; private static float _enabledAt = -999f; public static string CurrentTierName => _current == null ? "none" : _current.Tier.ToString(); public static string CurrentLabel => _current == null ? "" : (_current.Label ?? ""); public static bool InInputGrace => SpectatorMode.Active && Time.unscaledTime - _enabledAt < _inputExitGrace; /// /// Harness-only: shorten dwell/rotate so director tests finish in seconds. /// Game 5x speed does not help - this code uses . /// public static void SetHarnessFastTiming(bool enabled) { if (enabled) { _minDwell = 1.5f; _curiosityDwell = 1.2f; _curiosityRotate = 1.5f; _switchCooldown = 0.75f; _highTierInterrupt = 0.75f; _quietAmbientAfter = 1.5f; _ambientRotate = 2.5f; _actionPoll = 0.35f; _inputExitGrace = 0.6f; } else { _minDwell = MinDwellSeconds; _curiosityDwell = CuriosityDwellSeconds; _curiosityRotate = CuriosityRotateSeconds; _switchCooldown = SwitchCooldownSeconds; _highTierInterrupt = HighTierInterruptAfterSeconds; _quietAmbientAfter = QuietWorldAmbientAfterSeconds; _ambientRotate = AmbientRotateSeconds; _actionPoll = ActionPollSeconds; _inputExitGrace = InputExitGraceSeconds; } } /// Harness: pretend the current interest started ago. public static void HarnessAgeCurrent(float seconds) { float age = Mathf.Max(0f, seconds); float now = Time.unscaledTime; _currentStartedAt = now - age; _lastSwitchAt = now - age; } /// Harness: expire input-exit grace immediately. public static void HarnessExpireInputGrace() { _enabledAt = Time.unscaledTime - _inputExitGrace - 0.05f; } public static void OnSpectatorEnabled() { InterestCollector.Clear(); _current = null; float now = Time.unscaledTime; _currentStartedAt = now; _lastSwitchAt = now; _lastInterestingAt = now; _lastAmbientAt = -999f; _lastActionPollAt = -999f; _enabledAt = now; TryAmbient(force: true); } public static void OnSpectatorDisabled() { _current = null; InterestCollector.Clear(); } public static void Update() { if (!SpectatorMode.Active || !Config.game_loaded || World.world == null) { return; } // During harness batches, freeze unless director_run explicitly unfreezes. if (AgentHarness.FreezeDirector) { return; } if (Time.unscaledTime - _enabledAt >= _inputExitGrace && PlayerTookManualControl()) { ExitFromManualInput(); return; } float now = Time.unscaledTime; float onCurrent = now - _currentStartedAt; float sinceSwitch = now - _lastSwitchAt; // Periodically surface live battles into the queue (Action tier). if (now - _lastActionPollAt >= _actionPoll) { _lastActionPollAt = now; InterestEvent battle = WorldActivityScanner.FindHottestBattle(); if (battle != null) { InterestCollector.EnqueueDirect(battle); } } if (InterestCollector.TryGetBest(out InterestEvent candidate)) { if (candidate.HasValidPosition && CanSwitchTo(candidate, onCurrent, sinceSwitch)) { InterestCollector.Remove(candidate); if (candidate.Tier >= InterestTier.Action) { _lastInterestingAt = now; } SwitchTo(candidate, now); onCurrent = 0f; sinceSwitch = 0f; } else if (!candidate.HasValidPosition) { InterestCollector.Remove(candidate); } else if (candidate.Tier >= InterestTier.Action) { _lastInterestingAt = now; } } // Queued discovery tips beat ambient, but never stall ambient under Action/Story. if (InterestCollector.HasPendingAtLeast(InterestTier.Curiosity) && (_current == null || _current.Tier <= InterestTier.Curiosity)) { return; } bool quiet = now - _lastInterestingAt >= _quietAmbientAfter; bool dwellDone = _current == null || onCurrent >= DwellFor(_current); bool ambientDue = now - _lastAmbientAt >= _ambientRotate; if (_current == null && sinceSwitch >= 0.5f) { TryAmbient(force: true); } else if (quiet && dwellDone && ambientDue && sinceSwitch >= _switchCooldown) { TryAmbient(force: false); } } /// /// 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 InterestEvent { Tier = InterestTier.Curiosity }, onCurrent, sinceSwitch); } /// /// Harness: pulse camera-drag (or honorGrace path) and take the real manual-exit path. /// When is true, input during the post-enable grace does not exit. /// 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() { // Rising-edge style checks only - avoid sticky flags like already_used_power // that can trip when the power bar flashes or a tip appears. 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 DwellFor(InterestEvent current) { if (current == null) { return MinDwellSeconds; } return current.Tier <= InterestTier.Curiosity ? _curiosityDwell : _minDwell; } private static bool CanSwitchTo(InterestEvent candidate, float onCurrent, float sinceSwitch) { if (_current == null) { return true; } // Curiosity is a footnote: never interrupt Action/Story/Epic. // Curiosity→curiosity uses a shorter rotate so discovery drains can surface. if (candidate.Tier == InterestTier.Curiosity) { if (_current.Tier >= InterestTier.Action) { return false; } if (_current.Tier == InterestTier.Curiosity && onCurrent < _curiosityRotate) { return false; } if (_current.Tier == InterestTier.Story) { return false; } } if (candidate.Tier > _current.Tier && onCurrent >= _highTierInterrupt) { return true; } return onCurrent >= DwellFor(_current) && sinceSwitch >= _switchCooldown; } private static void SwitchTo(InterestEvent next, float now) { _current = next; _currentStartedAt = now; _lastSwitchAt = now; if (next.Tier >= InterestTier.Action) { _lastInterestingAt = now; } CameraDirector.Watch(next); } private static void TryAmbient(bool force) { float now = Time.unscaledTime; _lastAmbientAt = now; InterestEvent ambient = WorldActivityScanner.FindBestLiveTarget(); if (ambient == null) { return; } if (!force && _current != null && _current.Tier >= InterestTier.Story) { return; } // Don't let ambient yank the camera off a curiosity tip after a few seconds. if (!force && _current != null && _current.Tier == InterestTier.Curiosity && now - _currentStartedAt < _ambientRotate) { return; } if (!force && now - _lastSwitchAt < _switchCooldown && _current != null) { return; } // Avoid re-following the exact same unit every ambient tick. if (!force && _current != null && _current.FollowUnit != null && ambient.FollowUnit != null && _current.FollowUnit == ambient.FollowUnit && ambient.Tier <= InterestTier.Ambient) { return; } SwitchTo(ambient, now); LogService.LogInfo("[IdleSpectator] Ambient: " + ambient.Label); } }