using System; using System.Collections.Generic; using NeoModLoader.services; using UnityEngine; namespace IdleSpectator; /// /// Event-centered scene director: registry intake, protected sessions, score-margin /// preemption with typed combat/vignette rules, soft 70/30 variety, completion via /// . /// public static class InterestDirector { public const float MinDwellSeconds = 15f; 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 = 6f; 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 float _lastCombatFocusAt = -999f; private const float CombatFocusThrottleSeconds = 0.4f; private static readonly List PendingScratch = new List(96); public static string CurrentTierName => CurrentScoreLabel; public static string CurrentScoreLabel => _current == null ? "none" : _current.TotalScore.ToString("0.#"); public static float CurrentScore => _current == null ? 0f : _current.TotalScore; 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; /// /// True while the current scene is past completion but still held through quiet_grace /// (camera may remain; orange reason must be empty). /// public static bool InQuietGrace { get { if (_current == null || CurrentIsActive) { return false; } return _inactiveSince >= 0f; } } /// /// Candidate that may drive the orange dossier reason: owns /// while the director still holds that scene (not quiet_grace). /// Sticky combat may briefly report !IsActive between swings - keep the reason up. /// public static InterestCandidate TryGetOwnedReasonCandidate(Actor unit) { if (unit == null || _current == null || InQuietGrace) { return null; } long unitId = 0; try { unitId = unit.getID(); } catch { unitId = 0; } if (_current.FollowUnit == unit) { return _current; } if (_current.SubjectId != 0 && unitId != 0 && _current.SubjectId == unitId) { return _current; } if (_current.RelatedUnit == unit) { return _current; } if (_current.RelatedId != 0 && unitId != 0 && _current.RelatedId == unitId) { return _current; } return null; } 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; } EventFeedUtil.RegisterCandidate(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; // Age past FixedDwell / Manual MaxWatch so IsActive is false. float pastActive = Mathf.Max(_current.MinWatch, _current.MaxWatch) + 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); MaintainCombatFocus(now, force: false); InterestCandidate next = SelectNext(now); if (next != null && CanSwitchTo(next, onCurrent, sinceSwitch)) { if (!InterestScoring.IsFillScore(next.TotalScore)) { _lastInterestingAt = now; } SwitchTo(next, now, resumableInterrupt: false); onCurrent = 0f; sinceSwitch = 0f; } // Pending events own the camera - never Character-fill over them. if (InterestRegistry.HasPendingEvent()) { // Still repair a missing follow so death mid-scene does not flash the power bar. MaintainCameraFocus(now); 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); } MaintainCameraFocus(now); } 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; } // No living subject left: do not sit in quiet_grace with an empty camera. if (!_current.HasFollowUnit && (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive())) { EndCurrent(now, reason: "follow_lost"); return; } // Ambient: end promptly when the vignette goes cold - do not hold for minCameraDwell. if (IsAmbientShot(_current)) { if (_inactiveSince < 0f) { _inactiveSince = now; } if (now - _inactiveSince >= _quietGrace) { EndCurrent(now, reason: "quiet_grace"); } return; } // Condition ended early: still hold the camera until min dwell, then grace. if (onCurrent < MinDwellFor(_current)) { _inactiveSince = -999f; 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.Completion == InterestCompletionKind.CombatActive) { MaintainCombatFocus(Time.unscaledTime, force: true); if (!_current.HasFollowUnit && (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive())) { EndCurrent(Time.unscaledTime, reason: "follow_lost"); } return; } if (_current.HasFollowUnit) { // Living subject still owned - reattach if vanilla cleared focus mid-scene. if (!HasLivingCameraFocus() || MoveCamera._focus_unit != _current.FollowUnit) { CameraDirector.Watch(_current.ToInterestEvent()); } return; } // Ownership-preserving handoff only: related survivor. Never invent a stranger. if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive()) { _current.FollowUnit = _current.RelatedUnit; _current.SubjectId = EventFeedUtil.SafeId(_current.RelatedUnit); CameraDirector.Watch(_current.ToInterestEvent()); return; } // No owned related: end the scene rather than attach a nearby stranger. EndCurrent(Time.unscaledTime, reason: "follow_lost"); } /// /// Combat scenes: keep camera on the highest-scored living participant and rewrite the tip /// so the subject always matches focus. Clears fight Labels once combat goes cold. /// private static void MaintainCombatFocus(float now, bool force) { if (_current == null || _current.Completion != InterestCompletionKind.CombatActive) { return; } bool combatHot = InterestCompletion.IsActive(_current, _currentStartedAt, now); if (!combatHot) { ClearStaleCombatLabel(now); return; } if (!force && now - _lastCombatFocusAt < CombatFocusThrottleSeconds) { return; } _lastCombatFocusAt = now; if (!WorldActivityScanner.TryPickBestCombatFocus( _current, out Actor best, out Actor foe, out int fighters)) { return; } bool mass = string.Equals(_current.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase) || fighters > 2 || _current.ParticipantCount > 2; string label = mass ? EventReason.Battle(best, fighters) : EventReason.Fight(best, foe); bool followChanged = _current.FollowUnit != best; bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal); _current.FollowUnit = best; _current.SubjectId = EventFeedUtil.SafeId(best); _current.RelatedUnit = foe; _current.RelatedId = foe != null ? EventFeedUtil.SafeId(foe) : 0; _current.Label = label; try { _current.Position = best.current_position; } catch { // keep prior position } if (followChanged || labelChanged || !HasLivingCameraFocus() || MoveCamera._focus_unit != best) { CameraDirector.Watch(_current.ToInterestEvent()); } } private static void ClearStaleCombatLabel(float now) { if (_current == null || string.IsNullOrEmpty(_current.Label)) { return; } if (_current.Label.IndexOf("fighting", StringComparison.OrdinalIgnoreCase) < 0 && _current.Label.IndexOf("fight of", StringComparison.OrdinalIgnoreCase) < 0) { return; } _current.Label = ""; _lastCombatFocusAt = now; CameraDirector.Watch(_current.ToInterestEvent()); } /// Harness: force one combat focus maintenance pass. public static void HarnessMaintainCombatFocus() { MaintainCombatFocus(Time.unscaledTime, force: true); } /// /// Keep a living focus unit whenever Idle Spectator is on. /// Covers death mid-combat and quiet_grace windows where vanilla cleared follow. /// private static void MaintainCameraFocus(float now) { if (!SpectatorMode.Active) { return; } if (HasLivingCameraFocus()) { return; } if (_current != null) { if (_current.Completion == InterestCompletionKind.CombatActive) { MaintainCombatFocus(now, force: true); if (HasLivingCameraFocus()) { return; } } if (_current.HasFollowUnit) { CameraDirector.Watch(_current.ToInterestEvent()); if (HasLivingCameraFocus()) { return; } } if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive()) { // Non-combat ownership handoff; combat already tried picker above. if (_current.Completion != InterestCompletionKind.CombatActive) { _current.FollowUnit = _current.RelatedUnit; _current.SubjectId = EventFeedUtil.SafeId(_current.RelatedUnit); CameraDirector.Watch(_current.ToInterestEvent()); if (HasLivingCameraFocus()) { return; } } } // Orphan scene (no living follow/related) - end and refill. EndCurrent(now, reason: "follow_lost"); return; } EnsureIdleFocus(now); } private static bool HasLivingCameraFocus() { return MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null && MoveCamera._focus_unit.isAlive(); } 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; EnsureIdleFocus(now); } /// /// After scene end, keep a living focus unit so idle never flashes the power bar. /// private static void EnsureIdleFocus(float now) { if (!SpectatorMode.Active) { return; } if (HasLivingCameraFocus()) { return; } TryCharacterFill(force: true); if (HasLivingCameraFocus()) { return; } Actor any = EventFeedUtil.AnyAliveUnit(); if (any != null) { CameraDirector.FocusUnit(any); } _ = now; } 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); continue; } // Drop stale / not-worth-watching / unpresentable shots from the pending set. // Harness synthetic labels may be unpresentable (dossier blanks); keep them for tip tests. bool harness = string.Equals(c.Source, "harness", StringComparison.OrdinalIgnoreCase); bool presentable = harness || EventPresentability.WouldShow(c); if (!IsWorthWatchingNow(c, now, selectingDuringGrace: InQuietGrace) || IsStaleFreshLifeMoment(c, now) || !presentable) { if (!presentable) { InterestDropLog.Record("unpresentable", c.Label ?? c.Key); } InterestRegistry.Remove(c.Key); PendingScratch.RemoveAt(i); } } if (PendingScratch.Count == 0) { return null; } InterestScoring.EnrichTopK(PendingScratch); return InterestVariety.Pick(PendingScratch, _current); } private static bool IsStaleFreshLifeMoment(InterestCandidate c, float now) { if (c == null || string.IsNullOrEmpty(c.HappinessEffectId)) { return false; } string id = c.HappinessEffectId; bool hatch = string.Equals(id, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase) || string.Equals(id, "just_hatched", StringComparison.OrdinalIgnoreCase); bool spawn = string.Equals(id, "just_born", StringComparison.OrdinalIgnoreCase); if (!hatch && !spawn) { return false; } // Queued too long - miss the beat. if (now - c.CreatedAt > 5.5f) { InterestDropLog.Record("stale", c.HappinessEffectId + " queue"); return true; } Actor unit = c.FollowUnit; if (unit == null || !unit.isAlive()) { InterestDropLog.Record("stale", c.HappinessEffectId + " dead"); return true; } // Hatch: egg-loss / happiness may land after the unit is already past baby form. // Only drop on age for spawn (just_born), not hatch. if (spawn) { try { if (!unit.isBaby() && unit.getAge() > 1) { InterestDropLog.Record("stale", "just_born grown"); return true; } } catch { // ignore age API mismatches } } return 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 InterestCandidate { Key = "probe:curiosity", EventStrength = 40f, LeadKind = InterestLeadKind.CharacterLed, Completion = InterestCompletionKind.CharacterVignette, TotalScore = 45f }, 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) { ScoringWeights w = InterestScoringConfig.W; float floor = w.minCameraDwell > 0f ? w.minCameraDwell : MinDwellSeconds; if (current == null) { return _minDwell < MinDwellSeconds * 0.5f ? _minDwell : floor; } // Ambient / Character fill: no event floor - real events may take the camera anytime. if (IsAmbientShot(current)) { if (_minDwell < MinDwellSeconds * 0.5f) { return InterestScoring.IsFillScore(current.TotalScore) ? _curiosityDwell : _minDwell; } return w.dwellFill > 0f ? w.dwellFill : 6f; } float dwell = InterestScoring.IsHotScore(current.TotalScore) ? w.dwellHot : w.dwellNormal; if (_minDwell < MinDwellSeconds * 0.5f) { // Harness fast timing: keep compressed dwells. dwell = _minDwell; return current.MinWatch > 0f ? Mathf.Max(current.MinWatch, dwell) : dwell; } // Live EventLed: never peer-rotate / soft-cut before the camera floor. // Score-margin interrupts bypass this via CanSwitchTo. float minWatch = current.MinWatch > 0f ? current.MinWatch : 0f; return Mathf.Max(floor, dwell, minWatch); } /// Character fill / low-score stroll - events should cut in freely. private static bool IsAmbientShot(InterestCandidate c) { if (c == null) { return true; } if (c.LeadKind == InterestLeadKind.CharacterLed || c.Completion == InterestCompletionKind.CharacterVignette) { return true; } // Sticky event holds are never ambient even if score is temporarily fill-band. if (c.Completion == InterestCompletionKind.CombatActive || c.Completion == InterestCompletionKind.StatusPhase || c.Completion == InterestCompletionKind.HappinessGrief || c.Completion == InterestCompletionKind.ActivityActive) { return false; } return InterestScoring.IsFillScore(c.TotalScore); } private static float MaxWatchFor(InterestCandidate current) { if (current == null) { return InterestScoringConfig.W.maxWatchMoment; } ScoringWeights w = InterestScoringConfig.W; float classCap = w.maxWatchMoment; switch (current.Completion) { case InterestCompletionKind.CombatActive: classCap = w.maxWatchCombat; break; case InterestCompletionKind.StatusPhase: classCap = w.maxWatchStatus; break; case InterestCompletionKind.HappinessGrief: classCap = w.maxWatchGrief; break; case InterestCompletionKind.FixedDwell: case InterestCompletionKind.Manual: classCap = current.EventStrength >= w.hotScoreMin ? w.maxWatchEpic : w.maxWatchMoment; break; default: if (current.EventStrength >= w.hotScoreMin) { classCap = w.maxWatchEpic; } break; } float cap = classCap; if (current.MaxWatch > 0f) { // Sticky live holds use the class MaxWatch (e.g. combat 60s) - do not let a // short candidate MaxWatch max_cap the fight while it is still active. if (current.Completion == InterestCompletionKind.CombatActive || current.Completion == InterestCompletionKind.StatusPhase || current.Completion == InterestCompletionKind.HappinessGrief || current.Completion == InterestCompletionKind.ActivityActive) { cap = Mathf.Max(current.MaxWatch, classCap); } else { cap = Mathf.Min(current.MaxWatch, classCap); } } // Moment / FixedDwell shots must last at least the camera floor unless interrupted. if (current.Completion == InterestCompletionKind.FixedDwell || current.Completion == InterestCompletionKind.Manual) { float floor = w.minCameraDwell > 0f ? w.minCameraDwell : MinDwellSeconds; cap = Mathf.Max(cap, floor); } // Ambient fill: no minCameraDwell floor - short vignettes are fine. if (IsAmbientShot(current)) { float fillCap = w.dwellFill > 0f ? w.dwellFill * 2f : 20f; if (current.MaxWatch > 0f) { fillCap = Mathf.Min(fillCap, Mathf.Max(current.MaxWatch, 8f)); } cap = fillCap; } // Harness fast timing compresses caps. if (_minDwell < MinDwellSeconds * 0.5f) { cap = Mathf.Min(cap, 8f); } return cap; } /// Hold while live and under MaxWatch; quiet grace still counts as briefly protected. 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) { // Ambient: no minCameraDwell hold after the vignette goes cold. if (!IsAmbientShot(_current) && onCurrent < MinDwellFor(_current)) { return true; } 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 IsWorthWatchingNow(candidate, Time.unscaledTime, selectingDuringGrace: false); } float now = Time.unscaledTime; bool inGrace = InQuietGrace; if (inGrace) { // Quiet grace after a sticky hold: only margin-worthy events may cut in. // Prevents family-join / love status from stealing a fight between swings. if (_current != null && (_current.Completion == InterestCompletionKind.CombatActive || _current.Completion == InterestCompletionKind.StatusPhase || _current.Completion == InterestCompletionKind.HappinessGrief || IsStickyStoryScene(_current))) { float graceCur = _current.TotalScore; float graceNext = candidate.TotalScore; float graceMargin = Mathf.Max( InterestScoringConfig.W.cutInMargin, InterestScoringConfig.W.stickyCutInMargin > 0f ? InterestScoringConfig.W.stickyCutInMargin : 50f); return graceNext >= graceCur + graceMargin && IsWorthWatchingNow(candidate, now, selectingDuringGrace: true); } return IsWorthWatchingNow(candidate, now, selectingDuringGrace: true); } if (!IsWorthWatchingNow(candidate, now, selectingDuringGrace: false)) { return false; } bool protectedScene = SessionProtected(now, onCurrent); ScoringWeights w = InterestScoringConfig.W; float curScore = _current.TotalScore; float nextScore = candidate.TotalScore; bool nextFill = InterestScoring.IsFillScore(nextScore); bool curAmbient = IsAmbientShot(_current); bool curFill = curAmbient || InterestScoring.IsFillScore(curScore); bool sticky = IsStickyStoryScene(_current); float cutMargin = sticky ? Mathf.Max(w.cutInMargin, w.stickyCutInMargin > 0f ? w.stickyCutInMargin : 50f) : w.cutInMargin; // Ambient fill: any real event may take the camera immediately (no min dwell). if (curAmbient && !nextFill && !IsAmbientShot(candidate)) { return true; } // Same-arc refresh (combat/hatch/grief keys) may replace without full margin. if (sticky && IsSameStoryArc(_current, candidate) && nextScore >= curScore - w.rotateSlack) { return true; } // Instant score-margin cut - no settle grace, no MinDwell block. if (nextScore >= curScore + cutMargin) { return true; } if (sticky && nextScore < curScore + cutMargin) { InterestDropLog.Record( "below_margin", $"sticky cur={curScore:0.#} next={nextScore:0.#} need={cutMargin:0.#}"); } // Fill never cuts a protected non-fill session without margin (already failed above). if (nextFill && !curFill && protectedScene) { return false; } float fillRotate = _minDwell < MinDwellSeconds * 0.5f ? _curiosityRotate : w.fillRotateSeconds; if (nextFill && curFill && onCurrent < fillRotate) { return false; } // Protected / sticky EventLed hold: only margin or same-arc (handled above). if ((protectedScene || sticky) && !curAmbient) { return false; } // Peer rotate / stronger score after MinDwell when unprotected or on fill. if (nextScore > curScore && onCurrent >= MinDwellFor(_current) && sinceSwitch >= _switchCooldown) { return true; } return onCurrent >= MinDwellFor(_current) && sinceSwitch >= _switchCooldown && nextScore >= curScore - w.rotateSlack; } private static bool IsStickyStoryScene(InterestCandidate c) { if (c == null) { return false; } if (c.Completion == InterestCompletionKind.CombatActive || c.Completion == InterestCompletionKind.StatusPhase || c.Completion == InterestCompletionKind.HappinessGrief) { return true; } // Hatch FixedDwell is a story beat - hold against weak peers. if (!string.IsNullOrEmpty(c.HappinessEffectId) && (string.Equals(c.HappinessEffectId, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase) || string.Equals(c.HappinessEffectId, "just_hatched", StringComparison.OrdinalIgnoreCase))) { return true; } if (!string.IsNullOrEmpty(c.Key) && (c.Key.StartsWith("combat:", StringComparison.Ordinal) || c.Key.StartsWith("hatch:", StringComparison.Ordinal))) { return true; } return false; } private static bool IsSameStoryArc(InterestCandidate current, InterestCandidate next) { if (current == null || next == null) { return false; } if (!string.IsNullOrEmpty(current.Key) && !string.IsNullOrEmpty(next.Key) && string.Equals(current.Key, next.Key, StringComparison.Ordinal)) { return true; } // Combat / hatch share key prefixes per subject. if (!string.IsNullOrEmpty(current.Key) && !string.IsNullOrEmpty(next.Key)) { if (SameKeyPrefix(current.Key, next.Key, "combat:") || SameKeyPrefix(current.Key, next.Key, "hatch:")) { return true; } } if (current.Completion == InterestCompletionKind.HappinessGrief && next.Completion == InterestCompletionKind.HappinessGrief && current.SubjectId != 0 && current.SubjectId == next.SubjectId) { return true; } return false; } private static bool SameKeyPrefix(string a, string b, string prefix) { if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b) || string.IsNullOrEmpty(prefix)) { return false; } if (!a.StartsWith(prefix, StringComparison.Ordinal) || !b.StartsWith(prefix, StringComparison.Ordinal)) { return false; } return string.Equals(a, b, StringComparison.Ordinal); } /// /// Still-worth-watching filter: drop stale queued moments; keep live sticky conditions /// and fresh FixedDwell; during grace, brand-new fires are always eligible. /// private static bool IsWorthWatchingNow(InterestCandidate c, float now, bool selectingDuringGrace) { if (c == null || !c.HasValidPosition) { return false; } if (selectingDuringGrace && c.CreatedAt >= _inactiveSince && _inactiveSince >= 0f) { return true; } // Purge moments that fired before the current scene started (stale queue). if (_current != null && c.CreatedAt < _currentStartedAt - 0.05f) { if (c.Completion == InterestCompletionKind.FixedDwell || c.Completion == InterestCompletionKind.Manual) { return false; } // Sticky: only if still live. return InterestCompletion.IsActive(c, c.CreatedAt, now); } if (IsStaleMomentCandidate(c, now)) { return false; } switch (c.Completion) { case InterestCompletionKind.CombatActive: case InterestCompletionKind.StatusPhase: case InterestCompletionKind.HappinessGrief: case InterestCompletionKind.ActivityActive: return InterestCompletion.IsActive(c, c.CreatedAt, now) || now - c.LastSeenAt < 2f; case InterestCompletionKind.FixedDwell: case InterestCompletionKind.Manual: // Fresh moment: age under ~4s or more than half MaxWatch remaining. float age = now - c.CreatedAt; if (age < 4f) { return true; } float maxW = c.MaxWatch > 0f ? c.MaxWatch : InterestScoringConfig.W.maxWatchMoment; return age < maxW * 0.5f; default: return true; } } private static bool IsStaleMomentCandidate(InterestCandidate c, float now) { if (IsStaleFreshLifeMoment(c, now)) { return true; } if (c.ExpiresAt > 0f && now > c.ExpiresAt) { return true; } // Queued under an active hold: short TTL for FixedDwell moments. if (_current != null && CurrentIsActive && (c.Completion == InterestCompletionKind.FixedDwell || c.Completion == InterestCompletionKind.Manual) && now - c.CreatedAt > 8f) { return true; } return false; } private static void SwitchTo(InterestCandidate next, float now, bool resumableInterrupt) { if (next == null) { return; } if (!EventPresentability.WouldShow(next) && !string.Equals(next.Source, "harness", StringComparison.OrdinalIgnoreCase)) { InterestDropLog.Record("unpresentable", next.Label ?? next.Key); InterestRegistry.Remove(next.Key); return; } if (_current != null && _current.Resumable && next.TotalScore >= _current.TotalScore + InterestScoringConfig.W.cutInMargin) { _interrupted = _current.CloneShallow(); } _current = next; _currentStartedAt = now; _lastSwitchAt = now; _inactiveSince = -999f; if (!InterestScoring.IsFillScore(next.TotalScore)) { _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) && InterestScoring.IsHotScore(_current.TotalScore)) { return; } if (!force && _current != null && InterestScoring.IsFillScore(_current.TotalScore) && now - _currentStartedAt < _ambientRotate) { return; } if (!force && now - _lastSwitchAt < _switchCooldown && _current != null) { return; } InterestEvent ambient = WorldActivityScanner.FindBestLiveTarget(); if (ambient == null) { return; } // Character fill never owns an orange reason - empty Label + CharacterLed. // Force fill score so RegisterDirect does not publish as EventLed. ambient.Label = ""; float fillCap = InterestScoringConfig.W.fillScoreMax > 0f ? InterestScoringConfig.W.fillScoreMax * 0.5f : 20f; if (ambient.Score > fillCap || AgentHarness.Busy) { ambient.Score = fillCap; } InterestCandidate candidate = InterestFeeds.RegisterDirect(ambient); if (candidate == null) { return; } candidate.LeadKind = InterestLeadKind.CharacterLed; candidate.Category = "CharacterVignette"; candidate.Completion = InterestCompletionKind.CharacterVignette; // Fill is not an event - empty orange reason; task chip shows live job. candidate.Label = ""; if (AgentHarness.Busy) { candidate.ForceActive = false; candidate.EventStrength = Mathf.Min(candidate.EventStrength, 25f); InterestScoring.ScoreCheap(candidate); } if (!force && _current != null && _current.FollowUnit != null && candidate.FollowUnit != null && _current.FollowUnit == candidate.FollowUnit && InterestScoring.IsFillScore(candidate.TotalScore)) { return; } if (!force && !CanSwitchTo(candidate, now - _currentStartedAt, now - _lastSwitchAt)) { return; } SwitchTo(candidate, now, resumableInterrupt: false); LogService.LogInfo("[IdleSpectator] Ambient: " + candidate.Label); } }