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 partial 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; public const float SelectionPollSeconds = 0.12f; 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 _nextSelectAt = -999f; private static float _enabledAt = -999f; private static float _inactiveSince = -999f; private static float _lastCombatFocusAt = -999f; /// One Story/lover/discovery cut per sticky combat/war hold via the variety valve. private static bool _varietyValveUsed; /// /// Harness-only: treat the current CombatActive scene as cold so peers can cut after a fight /// without waiting for WorldBox to drop attack_target / combat tasks. /// private static bool _harnessCombatForcedCold; private const float CombatFocusThrottleSeconds = 1.25f; /// Large Mass sticky scenes can maintain less often - roster work is heavier. private const float CombatFocusThrottleLargeSeconds = 2.0f; private const int CombatFocusLargeParticipantThreshold = 16; /// Require this much extra focus weight before stealing the camera mid-fight. private const float CombatFocusSwitchMargin = 12f; /// /// Collective Mass/Battle: hold theater lead through brief attack_target gaps /// so maintain ticks cannot hop across equally-scored mob fighters. /// private const float CombatTheaterLeadGraceSeconds = 4f; /// Only a clearly hotter fighter may steal theater lead mid-hold. private const float CombatTheaterLeadSwitchMargin = 40f; /// Same sticky side: never hop across a mob for a near-tie. private const float CombatTheaterLeadSameSideSwitchMargin = 90f; /// Boost the thinner sticky side's best (1-vs-mob spectacles). private const float CombatTheaterLeadOutnumberedBonus = 28f; private const float CombatTheaterLeadNearRadius = 20f; private static readonly List PendingScratch = new List(96); public static float LastSelectCopyMs { get; private set; } public static float LastSelectFilterMs { get; private set; } public static float LastSelectStoryMs { get; private set; } public static float LastSelectVarietyMs { get; private set; } 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). /// Unit must be a tip principal (not a sticky bystander under a named duel). /// public static InterestCandidate TryGetOwnedReasonCandidate(Actor unit) { if (unit == null || _current == null || InQuietGrace) { return null; } // Tip principals own the reason even while the camera mid-pans on a Love/life // handoff (FocusUnit can briefly disagree with the dossier subject for a frame). return UnitIsReasonPrincipal(unit, _current) ? _current : null; } /// /// True when may show 's Label as orange reason. /// Pair-locked duel tips only name PairOwner/PairPartner - sticky Follow alone is not enough /// (avoids bystander dossier + "Duel - A vs B" for unrelated fighters). /// public static bool UnitIsReasonPrincipal(Actor unit, InterestCandidate scene) { if (unit == null || scene == null) { return false; } // Quiet character grounding never owns an orange Beat. A transient live attack target // must not promote its generated species presentation into an event; the combat feed // will publish a separate EventLed candidate when there is an actual fight to show. if (BeatSignificance.SuppressBeat(scene)) { return false; } long unitId = EventFeedUtil.SafeId(unit); if (unitId == 0) { return false; } // Solo status tips: only the live carrier owns the orange reason. // Stops Prefer/MC soft-follow from showing "Becomes pregnant" on a king who lacks it. if (scene.Completion == InterestCompletionKind.StatusPhase && !string.IsNullOrEmpty(scene.StatusId) && !StatusGameApi.HasStatus(unit, scene.StatusId)) { return false; } bool ensemble = scene.Completion == InterestCompletionKind.CombatActive || scene.Completion == InterestCompletionKind.WarFront || scene.Completion == InterestCompletionKind.PlotActive || scene.Completion == InterestCompletionKind.FamilyPack || scene.Completion == InterestCompletionKind.StatusOutbreak || InterestScoring.IsCombatAction(scene); if (ensemble) { bool massTheater = scene.Sticky != null && scene.Sticky.HasPresentedScale && scene.Sticky.PresentedScale >= EnsembleScale.Skirmish; if (scene.HasCombatPairLock && !massTheater) { // Named-pair duel: only the locked duelists own the reason. return unitId == scene.PairOwnerId || unitId == scene.PairPartnerId; } if (unitId == scene.SubjectId || scene.FollowUnit == unit || unitId == scene.PairOwnerId || unitId == scene.PairPartnerId || unitId == scene.TheaterLeadId) { return true; } if (StickyRosterContains(scene, unitId)) { return true; } // Related alone is not enough for ensemble theaters (crowd / wrong camp). return false; } // Life / discrete tips: Subject + Related are authoritative. // Follow alone is not enough after a camera handoff left SubjectId on someone else // (avoids Hillih dossier + "Iksssssa shares intimacy…" stale Label). if (scene.SubjectId != 0 && unitId == scene.SubjectId) { return true; } if (scene.RelatedId != 0 && unitId == scene.RelatedId) { return true; } if (scene.RelatedUnit == unit) { return true; } if (scene.FollowUnit == unit && (scene.SubjectId == 0 || unitId == scene.SubjectId)) { return true; } return false; } private static bool StickyRosterContains(InterestCandidate scene, long unitId) { if (scene?.Sticky == null || unitId == 0) { return false; } LiveSceneStickyState sticky = scene.Sticky; for (int i = 0; i < sticky.SideAIds.Count; i++) { if (sticky.SideAIds[i] == unitId) { return true; } } for (int i = 0; i < sticky.SideBIds.Count; i++) { if (sticky.SideBIds[i] == unitId) { return true; } } return false; } 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; } if (candidate.Completion == InterestCompletionKind.CombatActive) { candidate.ClearCombatSticky(); } 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: force the current combat scene cold (fight over) without ending the session, /// so quiet-grace / variety-valve peers may cut after a live hold. /// /// Harness: current CombatActive is forced cold until the next scene switch. public static bool HarnessCombatForcedCold => _harnessCombatForcedCold; public static void HarnessMarkCombatCold() { if (_current == null || _current.Completion != InterestCompletionKind.CombatActive) { return; } float now = Time.unscaledTime; _harnessCombatForcedCold = true; _current.ForceActive = false; _current.LastSeenAt = now - 60f; // Skip HasLiveCombatNear sticky path while proving post-fight cuts. if (string.Equals(_current.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)) { _current.AssetId = "live_combat"; } _inactiveSince = now; StoryPlanner.OnClimaxCold(_current); } /// /// Harness: force WarFront / PlotActive / CombatActive cold so story aftermath can inject. /// public static void HarnessMarkStickySceneCold() { if (_current == null) { return; } float now = Time.unscaledTime; if (_current.Completion == InterestCompletionKind.CombatActive) { HarnessMarkCombatCold(); return; } _current.ForceActive = false; _current.LastSeenAt = now - 60f; // Break sticky liveness predicates (sides / plotters). _current.Sticky.Clear(); _current.ParticipantCount = 0; // Age past MaxWatch so IsActive is false even if a path still resolves. float past = Mathf.Max(_current.MinWatch, _current.MaxWatch) + 1f; _currentStartedAt = now - past; _lastSwitchAt = _currentStartedAt; _inactiveSince = now; StoryPlanner.OnClimaxCold(_current); } /// 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; } // Transfer durable pair ownership to the handoff unit so maintain does not restore the old Follow. long newOwner = EventFeedUtil.SafeId(_current.RelatedUnit); long oldOwner = _current.PairOwnerId; _current.PairOwnerId = newOwner; _current.PairPartnerId = oldOwner != 0 && oldOwner != newOwner ? oldOwner : 0; _current.FollowUnit = null; _current.SubjectId = 0; return true; } /// Harness: run one SelectNext without switching (lead-kind probe). public static InterestCandidate HarnessPeekNext() { return SelectNext(Time.unscaledTime); } private static InterestCandidate _browseFrozenCurrent; private static InterestCandidate _browseFrozenInterrupted; private static float _browseFrozenStartedAt = -999f; private static float _browseFrozenLastSwitchAt = -999f; private static float _browseFrozenLastInterestingAt = -999f; /// Camera read-pause lease owners (hover preview). Feeds/story continue; switching deferred. private static readonly HashSet ReadPauseOwners = new HashSet(); private static bool _readPauseNeedsReconcile; public static bool ReadPauseActive => ReadPauseOwners.Count > 0; public static bool AcquireReadPause(string owner) { if (string.IsNullOrEmpty(owner)) { return false; } bool added = ReadPauseOwners.Add(owner); return added || ReadPauseOwners.Contains(owner); } public static void ReleaseReadPause(string owner) { if (string.IsNullOrEmpty(owner)) { return; } if (ReadPauseOwners.Remove(owner) && ReadPauseOwners.Count == 0) { _readPauseNeedsReconcile = true; } } public static void ClearReadPause() { if (ReadPauseOwners.Count == 0) { return; } ReadPauseOwners.Clear(); _readPauseNeedsReconcile = true; } public static void OnSpectatorEnabled() { ClearBrowseFreeze(); ClearReadPause(); InterestRegistry.Clear(); InterestVariety.Clear(); InterestScoring.ClearCache(); InterestFeeds.Reset(); StoryPlanner.Clear(); _current = null; _interrupted = null; float now = Time.unscaledTime; _currentStartedAt = now; _lastSwitchAt = now; _lastInterestingAt = now; _lastAmbientAt = -999f; _lastFeedsAt = -999f; _nextSelectAt = now; _enabledAt = now; _inactiveSince = -999f; // Harness scenarios inject their own candidates after enable/focus. if (!AgentHarness.Busy) { TryCharacterFill(force: true); } } public static void OnSpectatorDisabled() { ClearBrowseFreeze(); ClearReadPause(); _current = null; _interrupted = null; InterestRegistry.Clear(); // Drop harness/live crisis overlays so a war_front_sticky leftover cannot // fire epilogue_crisis into the next idle soak. StoryPlanner.Clear(); } /// /// Lore/browse freeze: keep StoryPlanner, registry, and session candidates; stop directing. /// public static void OnSpectatorBrowsePaused() { _browseFrozenCurrent = _current; _browseFrozenInterrupted = _interrupted; _browseFrozenStartedAt = _currentStartedAt; _browseFrozenLastSwitchAt = _lastSwitchAt; _browseFrozenLastInterestingAt = _lastInterestingAt; _current = null; _interrupted = null; // Do not Clear StoryPlanner / InterestRegistry / variety - resume continues the story. } /// /// Resume after without wiping arcs/crisis/ledger. /// public static void OnSpectatorBrowseResumed() { _current = _browseFrozenCurrent; _interrupted = _browseFrozenInterrupted; _currentStartedAt = _browseFrozenStartedAt; _lastSwitchAt = _browseFrozenLastSwitchAt; _lastInterestingAt = _browseFrozenLastInterestingAt; ClearBrowseFreeze(); float now = Time.unscaledTime; _enabledAt = now; _inactiveSince = -999f; _lastAmbientAt = -999f; _lastFeedsAt = -999f; _nextSelectAt = now; // Re-bind camera to the frozen story beat when still watchable. if (_current != null) { CameraDirector.Watch(_current.ToInterestEvent()); _lastInterestingAt = now; } else if (!AgentHarness.Busy) { TryCharacterFill(force: true); } } private static void ClearBrowseFreeze() { _browseFrozenCurrent = null; _browseFrozenInterrupted = null; _browseFrozenStartedAt = -999f; _browseFrozenLastSwitchAt = -999f; _browseFrozenLastInterestingAt = -999f; } public static void PauseForBrowsing(string banner = "Paused (viewing history)") { if (SpectatorMode.Active) { SpectatorMode.SetActive(false, quiet: true, browsePause: 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; IdleHitchProbe.Mark("feeds"); InterestFeeds.Tick(); InterestRegistry.ExpireStale(now); IdleHitchProbe.Mark("feeds_done"); } float onCurrent = now - _currentStartedAt; float sinceSwitch = now - _lastSwitchAt; IdleHitchProbe.Mark("story"); IdleHitchProbe.Mark("planner"); StoryPlanner.Tick(now); IdleHitchProbe.Mark("planner_done"); LifeSagaRoster.Tick(now); IdleHitchProbe.Mark("roster_done"); LifeSagaSession.EnsureBound(); IdleHitchProbe.Mark("session_done"); StoryScheduler.Tick(now); IdleHitchProbe.Mark("scheduler_done"); IdleHitchProbe.Mark("story_done"); if (ReadPauseActive) { // Keep feeds/story/roster alive; freeze every camera-mutating path. return; } if (_readPauseNeedsReconcile) { _readPauseNeedsReconcile = false; MaintainCameraFocus(now); } UpdateSessionLiveness(now, onCurrent); MaintainCombatFocus(now, force: false); MaintainWarFront(now, force: false); MaintainPlotCell(now, force: false); MaintainFamilyPack(now, force: false); MaintainStatusOutbreak(now, force: false); if (now >= _nextSelectAt) { _nextSelectAt = now + SelectionPollSeconds; IdleHitchProbe.Mark("select"); InterestCandidate next = SelectNext(now); IdleHitchProbe.Mark("select_done"); 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" - except live combat, which // holds until the scrap goes cold so the viewer can see who wins. if (onCurrent >= MaxWatchFor(_current) && !CombatFightIsHot(_current, now)) { EndCurrent(now, reason: "max_cap"); return; } bool active = InterestCompletion.IsActive(_current, _currentStartedAt, now); if (active) { _inactiveSince = -999f; TryHandoffFollow(); return; } // Climax went cold: inject / claim aftermath (idempotent per climax key). StoryPlanner.OnClimaxCold(_current); // No living subject left: prefer sticky combat roster handoff before ending. if (!_current.HasFollowUnit && (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive())) { if (_current.Completion == InterestCompletionKind.CombatActive && StickyScoreboard.TryPromoteFollow(_current)) { MaintainCombatFocus(now, force: true); return; } if (_current.Completion == InterestCompletionKind.WarFront && StickyScoreboard.TryPromoteFollow(_current)) { MaintainWarFront(now, force: true); return; } if (_current.Completion == InterestCompletionKind.PlotActive && StickyScoreboard.TryPromoteFollow(_current)) { MaintainPlotCell(now, force: true); return; } if (_current.Completion == InterestCompletionKind.FamilyPack && StickyScoreboard.TryPromoteFollow(_current)) { MaintainFamilyPack(now, force: true); return; } if (_current.Completion == InterestCompletionKind.StatusOutbreak && StickyScoreboard.TryPromoteFollow(_current)) { MaintainStatusOutbreak(now, force: true); return; } 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())) { if (StickyScoreboard.TryPromoteFollow(_current)) { MaintainCombatFocus(Time.unscaledTime, force: true); return; } EndCurrent(Time.unscaledTime, reason: "follow_lost"); } return; } if (_current.Completion == InterestCompletionKind.WarFront) { MaintainWarFront(Time.unscaledTime, force: true); if (!_current.HasFollowUnit && (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive())) { if (StickyScoreboard.TryPromoteFollow(_current)) { MaintainWarFront(Time.unscaledTime, force: true); return; } EndCurrent(Time.unscaledTime, reason: "follow_lost"); } return; } if (_current.Completion == InterestCompletionKind.PlotActive) { MaintainPlotCell(Time.unscaledTime, force: true); if (!_current.HasFollowUnit && (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive())) { if (StickyScoreboard.TryPromoteFollow(_current)) { MaintainPlotCell(Time.unscaledTime, force: true); return; } EndCurrent(Time.unscaledTime, reason: "follow_lost"); } return; } if (_current.Completion == InterestCompletionKind.FamilyPack) { MaintainFamilyPack(Time.unscaledTime, force: true); if (!_current.HasFollowUnit && (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive())) { if (StickyScoreboard.TryPromoteFollow(_current)) { MaintainFamilyPack(Time.unscaledTime, force: true); return; } EndCurrent(Time.unscaledTime, reason: "follow_lost"); } return; } if (_current.Completion == InterestCompletionKind.StatusOutbreak) { MaintainStatusOutbreak(Time.unscaledTime, force: true); if (!_current.HasFollowUnit && (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive())) { if (StickyScoreboard.TryPromoteFollow(_current)) { MaintainStatusOutbreak(Time.unscaledTime, force: true); return; } 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; } // Story aftermath/epilogue: promote another living cast member before ending. if (StoryPlanner.TryRecoverStoryFollow(_current)) { 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"); } private static bool HasStickyCombatSides(InterestCandidate scene) { return StickyScoreboard.HasOpposingSides(scene); } private static void ResolveCombatPairActors( InterestCandidate scene, out Actor owner, out Actor partner) { owner = null; partner = null; if (scene == null) { return; } if (scene.PairOwnerId != 0) { owner = LiveEnsemble.FindTrackedActor(scene.PairOwnerId); } if (owner == null || !owner.isAlive()) { owner = scene.FollowUnit != null && scene.FollowUnit.isAlive() ? scene.FollowUnit : null; } if (scene.PairPartnerId != 0) { partner = LiveEnsemble.FindTrackedActor(scene.PairPartnerId); } if (partner == null || !partner.isAlive()) { partner = scene.RelatedUnit != null && scene.RelatedUnit.isAlive() ? scene.RelatedUnit : null; } if (partner != null && owner != null && partner == owner) { partner = null; } } /// /// True when the scene already owns a collective multi battle (post-escalate), /// not a NamedPair duel that ambient units merely wandered near. /// private static bool HasStickyCollectiveMulti(InterestCandidate scene) { if (scene == null || !HasStickyCombatSides(scene)) { return false; } if (scene.CombatSideFrame != EnsembleFrame.SpeciesVsSpecies && scene.CombatSideFrame != EnsembleFrame.KingdomVsKingdom) { return false; } // AssetId==live_battle alone is not enough - ambient probes set it without a real multi. int enrolled = scene.CombatSideACount + scene.CombatSideBCount; return scene.CombatPeakParticipants >= 3 || enrolled >= 3; } /// /// Living 1v1 ownership: Duel tips and forced pair sessions stay NamedPair until a /// real multi escalate (sticky collective camps), not ambient nearby melees. /// private static bool IsNamedPairCombatOwnership(InterestCandidate scene) { if (scene == null || scene.Completion != InterestCompletionKind.CombatActive) { return false; } ResolveCombatPairActors(scene, out Actor owner, out Actor partner); bool pairIdsLive = owner != null && owner.isAlive() && partner != null && partner.isAlive(); if (HasStickyCollectiveMulti(scene)) { string label = scene.Label ?? ""; // Explicit Duel tip wins over ambient sticky pollution; collective tips own the scene. return label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase); } if (scene.HasCombatPairLock && pairIdsLive) { return true; } string tip = scene.Label ?? ""; bool duelLabeled = tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase); bool thinFight = tip.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0; bool hasPartner = partner != null || (scene.RelatedUnit != null && scene.RelatedUnit.isAlive()); if (duelLabeled) { return true; } if (scene.ForceActive && hasPartner) { return true; } if (hasPartner && owner != null && owner.isAlive() && (thinFight || scene.ParticipantCount <= 2 || scene.CombatPeakParticipants <= 2)) { return true; } return false; } /// /// Collective sticky Mass/Battle/Skirmish (not NamedPair duel ownership). /// private static bool IsCollectiveCombatTheater(InterestCandidate scene) { if (scene == null || scene.Completion != InterestCompletionKind.CombatActive) { return false; } if (IsNamedPairCombatOwnership(scene)) { return false; } if (!HasStickyCombatSides(scene)) { return false; } return HasStickyCollectiveMulti(scene) || scene.CombatPeakParticipants >= 3 || scene.ParticipantCount >= 3; } /// /// Score a sticky-side champion for theater-lead selection (stable, no newcomer jitter). /// private static float TheaterLeadScore(InterestCandidate scene, Actor actor, int ownSideCount, int foeSideCount) { if (actor == null || !actor.isAlive()) { return -1f; } float weight = LiveEnsemble.FocusWeight(actor, scene?.ParticipantIds, newcomerBonus: 0f); if (weight < 0f) { return weight; } bool outnumbered = ownSideCount > 0 && foeSideCount > 0 && ownSideCount < foeSideCount && foeSideCount >= 3; if (!outnumbered) { return weight; } // 1-vs-mob: prefer the thinner side's champion (evil mage vs humans). weight += CombatTheaterLeadOutnumberedBonus; // Spectacle on the thin side must beat crowned kings / renown stacks on the mob // (soak: 1 evil mage vs 100+ elves still followed a random elf). if (WorldActivityScanner.IsSpectaclePublic(actor)) { float ratio = foeSideCount / (float)Mathf.Max(1, ownSideCount); weight += Mathf.Clamp(40f + ratio * 6f, 60f, 140f); } return weight; } /// /// Pick the highest-scoring living fighter across both sticky sides. /// private static bool TryPickTheaterLead( InterestCandidate scene, out Actor lead, out Actor foe) { lead = null; foe = null; if (scene?.Sticky == null || !scene.Sticky.HasOpposingSides) { return false; } LiveSceneStickyState sticky = scene.Sticky; Actor pickA = LiveEnsemble.FindBestAliveTracked(sticky.SideAIds, preferCombat: true); Actor pickB = LiveEnsemble.FindBestAliveTracked(sticky.SideBIds, preferCombat: true); if ((pickA == null || !pickA.isAlive()) && (pickB == null || !pickB.isAlive())) { return false; } int countA = Mathf.Max(1, sticky.SideACount); int countB = Mathf.Max(1, sticky.SideBCount); float wA = TheaterLeadScore(scene, pickA, countA, countB); float wB = TheaterLeadScore(scene, pickB, countB, countA); if (wB > wA) { lead = pickB; foe = pickA; } else { lead = pickA; foe = pickB; } return lead != null && lead.isAlive(); } private static Actor ResolveAttackFoe(Actor actor) { if (actor == null) { return null; } try { if (actor.has_attack_target && actor.attack_target != null && actor.attack_target.isAlive() && actor.attack_target.isActor()) { Actor foe = actor.attack_target.a; return foe != null && foe.isAlive() ? foe : null; } } catch { // ignore } return null; } /// /// Rewatch tip only when tier or collective sides change - not on (6)↔(5) count flaps. /// private static bool CombatLabelNeedsRewatch(string oldLabel, string newLabel) { if (string.IsNullOrEmpty(oldLabel)) { return true; } if (string.IsNullOrEmpty(newLabel)) { return true; } string a = StripCombatLabelCounts(oldLabel); string b = StripCombatLabelCounts(newLabel); return !a.Equals(b, StringComparison.OrdinalIgnoreCase); } private static string StripCombatLabelCounts(string label) { if (string.IsNullOrEmpty(label)) { return ""; } var sb = new System.Text.StringBuilder(label.Length); for (int i = 0; i < label.Length; i++) { char c = label[i]; if (c == '(') { int close = label.IndexOf(')', i); if (close > i) { i = close; continue; } } sb.Append(c); } return sb.ToString().Trim(); } private static void ApplyEnsembleFields(InterestCandidate scene, LiveEnsemble ensemble) { if (scene == null || ensemble == null) { return; } scene.ParticipantCount = Math.Max(0, ensemble.ParticipantCount); if (scene.ParticipantCount > scene.CombatPeakParticipants) { scene.CombatPeakParticipants = scene.ParticipantCount; } scene.NotableParticipantCount = ensemble.NotableParticipantCount; WorldActivityScanner.StampParticipantIds(scene, ensemble); if (ensemble.Scale >= EnsembleScale.Skirmish) { scene.AssetId = "live_battle"; } if (ensemble.Frame == EnsembleFrame.KingdomVsKingdom && ensemble.SideA != null && !string.IsNullOrEmpty(ensemble.SideA.Key)) { scene.KingdomKey = ensemble.SideA.Key; } if (ensemble.Frame == EnsembleFrame.SpeciesVsSpecies && ensemble.SideA != null && !string.IsNullOrEmpty(ensemble.SideA.Key)) { scene.SpeciesId = ensemble.SideA.Key; } StickyScoreboard.TryLockFromEnsemble(scene.Sticky, ensemble); } /// /// If the active combat/war scene already covers this battle anchor, refresh it in place /// instead of registering a second live_battle candidate. /// public static bool TryAbsorbCombatBattle(InterestEvent battle) { if (battle == null || _current == null) { return false; } // War chapter owns the kingdom theater - local Mass scraps refresh follow in place. if (_current.Completion == InterestCompletionKind.WarFront && BattleSharesCurrentWarTheater(battle)) { if (battle.FollowUnit != null && battle.FollowUnit.isAlive()) { _current.FollowUnit = battle.FollowUnit; _current.SubjectId = EventFeedUtil.SafeId(battle.FollowUnit); try { _current.Position = battle.FollowUnit.current_position; } catch { // keep } } if (battle.ParticipantCount > _current.ParticipantCount) { _current.ParticipantCount = battle.ParticipantCount; } ApplyWarFrontMaintain(forceWatch: true); return true; } if (_current.Completion != InterestCompletionKind.CombatActive) { return false; } Vector3 anchor = battle.Position; Vector3 cur = _current.Position; try { if (_current.FollowUnit != null && _current.FollowUnit.isAlive()) { cur = _current.FollowUnit.current_position; } } catch { // keep } float dx = anchor.x - cur.x; float dy = anchor.y - cur.y; float mergeR = 16f; bool near = dx * dx + dy * dy <= mergeR * mergeR; bool shared = false; if (!near && battle.FollowUnit != null) { long id = EventFeedUtil.SafeId(battle.FollowUnit); shared = id != 0 && _current.ParticipantIds != null && _current.ParticipantIds.Contains(id); } if (!near && !shared) { return false; } if (battle.ParticipantCount > _current.ParticipantCount) { _current.ParticipantCount = battle.ParticipantCount; } if (battle.NotableParticipantCount > _current.NotableParticipantCount) { _current.NotableParticipantCount = battle.NotableParticipantCount; } ApplyCombatEnsembleToCurrent(forceWatch: true); return true; } /// /// True when a scanner battle belongs to the current WarFront kingdom pair /// (kingdom keys on the battle follow / related, or proximity inside war theater). /// private static bool BattleSharesCurrentWarTheater(InterestEvent battle) { if (battle == null || _current == null || _current.Completion != InterestCompletionKind.WarFront || _current.Sticky == null || !StickyScoreboard.TryKingdomPair(_current.Sticky, out string warA, out string warB)) { return false; } string followK = LiveEnsemble.KingdomKeyOf(battle.FollowUnit); string relatedK = LiveEnsemble.KingdomKeyOf(battle.RelatedUnit); bool followMatch = KingdomOnWarPair(followK, warA, warB); bool relatedMatch = KingdomOnWarPair(relatedK, warA, warB); if (followMatch && (relatedMatch || string.IsNullOrEmpty(relatedK))) { return true; } if (followMatch && relatedMatch) { return true; } // Near the war camera anchor inside war theater radius. Vector3 anchor = battle.Position; Vector3 cur = _current.Position; try { if (_current.FollowUnit != null && _current.FollowUnit.isAlive()) { cur = _current.FollowUnit.current_position; } } catch { // keep } float dx = anchor.x - cur.x; float dy = anchor.y - cur.y; float r = StickyScoreboard.WarTheaterRadius; return followMatch && dx * dx + dy * dy <= r * r; } private static bool KingdomOnWarPair(string kingdom, string warA, string warB) { if (string.IsNullOrEmpty(kingdom)) { return false; } string k = kingdom.Trim().ToLowerInvariant(); return string.Equals(k, warA, StringComparison.OrdinalIgnoreCase) || string.Equals(k, warB, StringComparison.OrdinalIgnoreCase); } /// Harness: apply a synthetic combat ensemble onto the current scene. public static bool HarnessApplyCombatEnsemble(LiveEnsemble ensemble) { if (_current == null || ensemble == null || !ensemble.HasFocus || _current.Completion != InterestCompletionKind.CombatActive) { return false; } int authoredN = Mathf.Max( ensemble.ParticipantCount, (ensemble.SideA?.Count ?? 0) + (ensemble.SideB?.Count ?? 0)); ApplyEnsembleFields(_current, ensemble); StickyScoreboard.TryLockFromEnsemble(_current.Sticky, ensemble); StickyScoreboard.Refresh( _current, ensemble, ensemble.Focus.current_position, 14f); // Harness escalate authors side counts before units fully enter combat scans. if (authoredN > _current.ParticipantCount) { _current.ParticipantCount = authoredN; } if (ensemble.SideA != null && ensemble.SideA.Count > _current.CombatSideACount) { _current.CombatSideACount = ensemble.SideA.Count; } if (ensemble.SideB != null && ensemble.SideB.Count > _current.CombatSideBCount) { _current.CombatSideBCount = ensemble.SideB.Count; } if (authoredN > _current.CombatPeakParticipants) { _current.CombatPeakParticipants = authoredN; } // Keep PairOwner/Partner + theater lead across escalate. Clearing them forced // Mass→Duel collapse onto whichever attack_target the lead had that tick. Actor focus = ensemble.Focus; Actor related = ensemble.Related; if (IsCollectiveCombatTheater(_current) || (authoredN >= 3 && HasStickyCombatSides(_current))) { if (TryPickTheaterLead(_current, out Actor lead, out Actor leadFoe)) { focus = lead; related = ResolveAttackFoe(lead) ?? leadFoe ?? related; _current.StampTheaterLead(lead); if (LiveEnsemble.IsCombatParticipant(lead)) { _current.TheaterLeadLastCombatAt = Time.unscaledTime; } ensemble.Focus = focus; ensemble.Related = related; } } _current.FollowUnit = focus; _current.SubjectId = EventFeedUtil.SafeId(focus); _current.RelatedUnit = related; _current.RelatedId = related != null ? EventFeedUtil.SafeId(related) : 0; _current.Label = EventReason.Combat(ensemble); try { _current.Position = focus.current_position; } catch { // keep } CameraDirector.Watch(_current.ToInterestEvent()); return true; } /// Harness: apply a synthetic war ensemble onto the current WarFront scene. public static bool HarnessApplyWarEnsemble(LiveEnsemble ensemble) { if (_current == null || ensemble == null || !ensemble.HasFocus || _current.Completion != InterestCompletionKind.WarFront) { return false; } ensemble.Kind = EnsembleKind.WarFront; ensemble.Frame = EnsembleFrame.KingdomVsKingdom; StickyScoreboard.TryLockFromEnsemble(_current.Sticky, ensemble); StickyScoreboard.Refresh( _current, ensemble, ensemble.Focus.current_position, StickyScoreboard.WarTheaterRadius); _current.FollowUnit = ensemble.Focus; _current.SubjectId = EventFeedUtil.SafeId(ensemble.Focus); _current.RelatedUnit = ensemble.Related; _current.RelatedId = ensemble.Related != null ? EventFeedUtil.SafeId(ensemble.Related) : 0; _current.Label = EventReason.WarFront(ensemble); _current.AssetId = "live_war"; _current.ParticipantCount = ensemble.ParticipantCount; try { _current.Position = ensemble.Focus.current_position; } catch { // keep } CameraDirector.Watch(_current.ToInterestEvent()); return true; } /// Harness: apply a synthetic family ensemble onto the current FamilyPack scene. public static bool HarnessApplyFamilyEnsemble(LiveEnsemble ensemble) { if (_current == null || ensemble == null || !ensemble.HasFocus || _current.Completion != InterestCompletionKind.FamilyPack) { return false; } ensemble.Kind = EnsembleKind.FamilyPack; ensemble.Frame = EnsembleFrame.SpeciesVsSpecies; StickyScoreboard.TryLockFromEnsemble(_current.Sticky, ensemble); if (ensemble.SideA != null) { _current.Sticky.SideACount = Math.Max(0, ensemble.SideA.Count); if (!string.IsNullOrEmpty(ensemble.SideA.Key)) { _current.Sticky.SideAKey = ensemble.SideA.Key; } if (!string.IsNullOrEmpty(ensemble.SideA.Display) && !LiveEnsemble.IsFamilyPackSideKey(ensemble.SideA.Display)) { _current.Sticky.SideADisplay = ensemble.SideA.Display; } ensemble.SideA.Count = _current.Sticky.SideACount; ensemble.SideA.Display = _current.Sticky.SideADisplay; } _current.Sticky.SideBKey = ""; _current.Sticky.SideBDisplay = ""; _current.Sticky.SideBCount = 0; _current.Sticky.PeakParticipants = Math.Max( _current.Sticky.PeakParticipants, _current.Sticky.SideACount); _current.FollowUnit = ensemble.Focus; _current.SubjectId = EventFeedUtil.SafeId(ensemble.Focus); _current.RelatedUnit = ensemble.Related; _current.RelatedId = ensemble.Related != null ? EventFeedUtil.SafeId(ensemble.Related) : 0; if (_current.Sticky != null && ensemble.ParticipantIds != null) { _current.Sticky.SideAIds.Clear(); for (int i = 0; i < ensemble.ParticipantIds.Count; i++) { long id = ensemble.ParticipantIds[i]; if (id != 0) { _current.Sticky.SideAIds.Add(id); } } if (ensemble.SideA != null) { _current.Sticky.SideACount = Math.Max(0, ensemble.SideA.Count); } } _current.Label = EventReason.FamilyPack(ensemble); if (string.IsNullOrEmpty(_current.Label)) { _current.Label = StickyScoreboard.FormatReason(_current, ensemble.Focus, ensemble.Related); } _current.AssetId = "live_family"; _current.ParticipantCount = ensemble.ParticipantCount; try { _current.Position = ensemble.Focus.current_position; } catch { // keep } CameraDirector.Watch(_current.ToInterestEvent()); return true; } /// Harness: apply a synthetic outbreak ensemble onto the current StatusOutbreak scene. public static bool HarnessApplyStatusOutbreakEnsemble(LiveEnsemble ensemble) { if (_current == null || ensemble == null || !ensemble.HasFocus || _current.Completion != InterestCompletionKind.StatusOutbreak) { return false; } ensemble.Kind = EnsembleKind.StatusOutbreak; ensemble.Frame = EnsembleFrame.SpeciesVsSpecies; StickyScoreboard.TryLockFromEnsemble(_current.Sticky, ensemble); if (ensemble.SideA != null) { _current.Sticky.SideACount = Math.Max(0, ensemble.SideA.Count); if (!string.IsNullOrEmpty(ensemble.SideA.Key)) { _current.Sticky.SideAKey = ensemble.SideA.Key; if (LiveEnsemble.IsStatusOutbreakSideKey(ensemble.SideA.Key)) { _current.StatusId = ensemble.SideA.Key.Substring("status:".Length); } } if (!string.IsNullOrEmpty(ensemble.SideA.Display) && !LiveEnsemble.IsStatusOutbreakSideKey(ensemble.SideA.Display)) { _current.Sticky.SideADisplay = ensemble.SideA.Display; } ensemble.SideA.Count = _current.Sticky.SideACount; ensemble.SideA.Display = _current.Sticky.SideADisplay; } _current.Sticky.SideBKey = ""; _current.Sticky.SideBDisplay = ""; _current.Sticky.SideBCount = 0; _current.Sticky.PeakParticipants = Math.Max( _current.Sticky.PeakParticipants, _current.Sticky.SideACount); _current.FollowUnit = ensemble.Focus; _current.SubjectId = EventFeedUtil.SafeId(ensemble.Focus); _current.RelatedUnit = ensemble.Related; _current.RelatedId = ensemble.Related != null ? EventFeedUtil.SafeId(ensemble.Related) : 0; _current.Label = EventReason.StatusOutbreak(ensemble); if (string.IsNullOrEmpty(_current.Label)) { _current.Label = StickyScoreboard.FormatReason(_current, ensemble.Focus, ensemble.Related); } _current.AssetId = "live_outbreak"; _current.ParticipantCount = ensemble.ParticipantCount; try { _current.Position = ensemble.Focus.current_position; } catch { // keep } CameraDirector.Watch(_current.ToInterestEvent()); return true; } /// Harness: apply a synthetic plot ensemble onto the current PlotActive scene. public static bool HarnessApplyPlotEnsemble(LiveEnsemble ensemble) { if (_current == null || ensemble == null || !ensemble.HasFocus || _current.Completion != InterestCompletionKind.PlotActive) { return false; } ensemble.Kind = EnsembleKind.PlotCell; ensemble.Frame = EnsembleFrame.KingdomVsKingdom; StickyScoreboard.TryLockFromEnsemble(_current.Sticky, ensemble); // Harness drives explicit counts; seed roster then pin counts for tip asserts. if (ensemble.SideA != null) { _current.Sticky.SideACount = Math.Max(0, ensemble.SideA.Count); _current.Sticky.SideADisplay = "Plotters"; if (!string.IsNullOrEmpty(ensemble.SideA.Key)) { _current.Sticky.SideAKey = ensemble.SideA.Key; } } if (ensemble.SideB != null) { _current.Sticky.SideBCount = Math.Max(0, ensemble.SideB.Count); if (!string.IsNullOrEmpty(ensemble.SideB.Key)) { _current.Sticky.SideBKey = ensemble.SideB.Key; _current.Sticky.SideBDisplay = string.IsNullOrEmpty(ensemble.SideB.Display) ? LiveEnsemble.KingdomDisplay(ensemble.SideB.Key) : ensemble.SideB.Display; _current.Sticky.SideBKingdom = _current.Sticky.SideBDisplay; } } if (ensemble.SideA != null) { ensemble.SideA.Display = "Plotters"; ensemble.SideA.Count = _current.Sticky.SideACount; } if (ensemble.SideB != null) { ensemble.SideB.Count = _current.Sticky.SideBCount; ensemble.SideB.Display = _current.Sticky.SideBDisplay; } _current.Sticky.PeakParticipants = Math.Max( _current.Sticky.PeakParticipants, _current.Sticky.TotalCount); _current.FollowUnit = ensemble.Focus; _current.SubjectId = EventFeedUtil.SafeId(ensemble.Focus); _current.RelatedUnit = ensemble.Related; _current.RelatedId = ensemble.Related != null ? EventFeedUtil.SafeId(ensemble.Related) : 0; // Groups only - leave Label empty below 2 plotters (no unit-plot fallback on sticky). _current.Label = EventReason.PlotCell(ensemble) ?? ""; _current.AssetId = "live_plot"; _current.ParticipantCount = ensemble.ParticipantCount; try { _current.Position = ensemble.Focus.current_position; } catch { // keep } CameraDirector.Watch(_current.ToInterestEvent()); return 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.Completion == InterestCompletionKind.WarFront) { MaintainWarFront(now, force: true); if (HasLivingCameraFocus()) { return; } } if (_current.Completion == InterestCompletionKind.PlotActive) { MaintainPlotCell(now, force: true); if (HasLivingCameraFocus()) { return; } } if (_current.Completion == InterestCompletionKind.FamilyPack) { MaintainFamilyPack(now, force: true); if (HasLivingCameraFocus()) { return; } } if (_current.Completion == InterestCompletionKind.StatusOutbreak) { MaintainStatusOutbreak(now, force: true); if (HasLivingCameraFocus()) { return; } } if (_current.HasFollowUnit) { CameraDirector.Watch(_current.ToInterestEvent()); if (HasLivingCameraFocus()) { return; } } if (StoryPlanner.TryRecoverStoryFollow(_current)) { CameraDirector.Watch(_current.ToInterestEvent()); if (HasLivingCameraFocus()) { return; } } if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive()) { // Non-combat ownership handoff; sticky scenes already tried picker above. if (_current.Completion != InterestCompletionKind.CombatActive && _current.Completion != InterestCompletionKind.WarFront && _current.Completion != InterestCompletionKind.PlotActive && _current.Completion != InterestCompletionKind.FamilyPack && _current.Completion != InterestCompletionKind.StatusOutbreak) { _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) { InterestCandidate ended = _current; if (_current != null) { InterestRegistry.Remove(_current.Key); LogService.LogInfo("[IdleSpectator] Scene end (" + reason + "): " + _current.Label); } // Soft crumbs ending during/after hard quiet must not reopen the floodgates. if (ended != null && InterestVariety.IsSoftFillQuietCrumb(ended)) { StoryPlanner.NoteSoftCrumbQuietRefresh(now); } StoryPlanner.OnEndCurrent(ended, now); // Resume interrupted Epic-preempted scene if still valid. // Soft-life / moment beats already NoteSelection-cooled must not cut back in // after a combat/peer cut (soak: hatch/reproduce resume spam). if (_interrupted != null && _interrupted.HasValidPosition && InterestCompletion.IsActive(_interrupted, now - _interrupted.MinWatch, now)) { InterestCandidate resume = _interrupted; _interrupted = null; if (InterestVariety.IsBeatCooled(resume, now)) { InterestDropLog.Record( "resume_cooled", resume.HappinessEffectId ?? resume.AssetId ?? resume.Key ?? "beat"); InterestRegistry.Remove(resume.Key); } else if (!NarrativeExposureLedger.MayPresent(resume)) { InterestDropLog.Record( "resume_exposure_budget", resume.Label ?? resume.Key ?? "combat"); InterestRegistry.Remove(resume.Key); } else { SwitchTo(resume, now, resumableInterrupt: false); return; } } // Prefer queued story aftermath/epilogue over ambient fill. InterestCandidate storyNext = SelectNext(now); if (storyNext != null && StoryPlanner.OwnsCandidate(storyNext) && IsWorthWatchingNow(storyNext, now, selectingDuringGrace: true)) { _interrupted = null; _current = null; _harnessCombatForcedCold = false; _inactiveSince = -999f; SwitchTo(storyNext, now, resumableInterrupt: false); return; } _interrupted = null; _current = null; _harnessCombatForcedCold = false; _inactiveSince = -999f; CameraDirector.ClearWatchReason(); 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 (HasLivingMcCameraFocus()) { return; } TryCharacterFill(force: true); if (HasLivingMcCameraFocus()) { return; } Actor mc = LifeSagaRoster.BestLivingMc(); if (mc != null) { CameraDirector.FocusUnit(mc); return; } // Before the first roster has formed, preserve the old discovery fallback. // Once MCs exist, never make an arbitrary background unit the quiet landing shot. if (LifeSagaRoster.Count > 0) { return; } Actor any = EventFeedUtil.AnyAliveUnit(); if (any != null) { CameraDirector.FocusUnit(any); } _ = now; } private static bool HasLivingMcCameraFocus() { return HasLivingCameraFocus() && LifeSagaRoster.IsMc(EventFeedUtil.SafeId(MoveCamera._focus_unit)); } private static InterestCandidate SelectNext(float now) { bool profile = IdleHitchProbe.Enabled; LastSelectCopyMs = 0f; LastSelectFilterMs = 0f; LastSelectStoryMs = 0f; LastSelectVarietyMs = 0f; long stageStarted = profile ? System.Diagnostics.Stopwatch.GetTimestamp() : 0; InterestRegistry.CopyPending(PendingScratch); if (profile) LastSelectCopyMs = SelectStageMs(stageStarted); if (PendingScratch.Count == 0) { return null; } stageStarted = profile ? System.Diagnostics.Stopwatch.GetTimestamp() : 0; float onCurrent = now - _currentStartedAt; float sinceSwitch = now - _lastSwitchAt; bool softQuiet = StoryPlanner.SoftFillQuietActive; for (int i = PendingScratch.Count - 1; i >= 0; i--) { InterestCandidate c = PendingScratch[i]; if (c == null || !CanSwitchTo(c, onCurrent, sinceSwitch)) { PendingScratch.RemoveAt(i); continue; } if (!StoryPlanner.HasLiveStoryOwner(c)) { InterestDropLog.Record("story_orphan", c.CorrelationKey ?? c.Key); InterestRegistry.Remove(c.Key); PendingScratch.RemoveAt(i); continue; } // After hard story/crisis closers, skip soft-life / short interrupt crumbs // so AFK does not immediately hatch/reproduce/sleep/stun surf. Leave them pending. if (softQuiet && InterestVariety.IsSoftFillQuietCrumb(c) && !BreaksSoftFillQuiet(c)) { InterestDropLog.Record("soft_fill_quiet", c.AssetId ?? c.HappinessEffectId ?? c.Key); PendingScratch.RemoveAt(i); continue; } bool harness = string.Equals(c.Source, "harness", StringComparison.OrdinalIgnoreCase); // Background lives continue to feed narrative admission off-camera. Once an MC // ensemble exists, routine candidates outside that ensemble/orbit do not enter the // expensive presentability and camera-selection path. if (!harness && !CameraEligibility.MayUseCamera(c)) { InterestDropLog.Record("discovery_only", c.AssetId ?? c.Key); 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 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 (profile) LastSelectFilterMs = SelectStageMs(stageStarted); if (PendingScratch.Count == 0) { return null; } stageStarted = profile ? System.Diagnostics.Stopwatch.GetTimestamp() : 0; InterestCandidate storyChoice = StoryScheduler.SelectForCamera(PendingScratch, _current, now); if (profile) LastSelectStoryMs = SelectStageMs(stageStarted); if (storyChoice != null) { return storyChoice; } if (StoryScheduler.ShouldHoldForEpisode(now)) { return null; } // When no episode owns a usable development, select bounded ambient/world context. // EnrichTopK runs inside InterestVariety.Pick once - avoid a second top-K meta walk here. stageStarted = profile ? System.Diagnostics.Stopwatch.GetTimestamp() : 0; InterestCandidate picked = InterestVariety.Pick(PendingScratch, _current); if (profile) LastSelectVarietyMs = SelectStageMs(stageStarted); return picked; } private static float SelectStageMs(long started) { return (float)((System.Diagnostics.Stopwatch.GetTimestamp() - started) * 1000d / System.Diagnostics.Stopwatch.Frequency); } /// /// Hard / sticky / epic tips may still cut during soft-fill quiet. /// private static bool BreaksSoftFillQuiet(InterestCandidate c) { if (c == null) { return false; } // FamilyPack is ambient cluster fill - must not break soft-fill quiet. if (c.Completion == InterestCompletionKind.CombatActive || c.Completion == InterestCompletionKind.WarFront || c.Completion == InterestCompletionKind.PlotActive || c.Completion == InterestCompletionKind.StatusOutbreak || c.Completion == InterestCompletionKind.EarthquakeActive) { return true; } if (string.Equals(c.Source, "story_planner", StringComparison.OrdinalIgnoreCase) || StoryReason.IsStoryAsset(c.AssetId)) { return true; } // Prefer / MC life tips may reclaim the camera during soft-fill quiet. if (LifeSagaRoster.TipTouchesPrefer(c) || LifeSagaRoster.TipTouchesMc(c)) { return true; } float epicFloor = InterestScoringConfig.Story.crisisDisasterStrengthMin > 0f ? InterestScoringConfig.Story.crisisDisasterStrengthMin : 95f; if (c.EventStrength >= epicFloor) { return true; } return false; } 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))) { // Grave skull click opens Lore; do not treat as idle pause. if (InputHelpers.GetMouseButtonDown(0) && GraveMarkers.StackCount > 0) { try { WorldTile tile = World.world.getMouseTilePosCachedFrame() ?? World.world.getMouseTilePos(); if (tile != null && GraveMarkers.TryGetStack(tile.x, tile.y, out _)) { return false; } Vector2 mouse = World.world.getMousePos(); if (GraveMarkers.FindNearestStack(mouse, 1.15f) != null) { return false; } } catch { // fall through to manual pause } } 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.StatusOutbreak || 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.WarFront: case InterestCompletionKind.PlotActive: case InterestCompletionKind.EarthquakeActive: classCap = w.maxWatchCombat; break; case InterestCompletionKind.FamilyPack: // Soft cluster - moment-class cap so packs cannot outlast unit events. classCap = w.maxWatchMoment; break; case InterestCompletionKind.StatusPhase: case InterestCompletionKind.StatusOutbreak: 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.WarFront || current.Completion == InterestCompletionKind.PlotActive || current.Completion == InterestCompletionKind.StatusPhase || current.Completion == InterestCompletionKind.StatusOutbreak || current.Completion == InterestCompletionKind.HappinessGrief || current.Completion == InterestCompletionKind.ActivityActive) { cap = Mathf.Max(current.MaxWatch, classCap); } else if (current.Completion == InterestCompletionKind.FamilyPack) { // Soft pack: honor the short candidate MaxWatch (do not stretch to combat caps). cap = Mathf.Min(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; } /// True while a CombatActive scene still has a live scrap (incl. hysteresis). private static bool CombatFightIsHot(InterestCandidate scene, float now) { if (scene == null || scene.Completion != InterestCompletionKind.CombatActive) { return false; } if (_harnessCombatForcedCold && scene == _current) { return false; } return InterestCompletion.IsActive(scene, _currentStartedAt, now); } private static bool VarietyValveTimeReady(InterestCandidate scene, float onCurrent) { if (scene == null || (scene.Completion != InterestCompletionKind.CombatActive && scene.Completion != InterestCompletionKind.WarFront)) { return false; } ScoringWeights w = InterestScoringConfig.W; float after = w.stickyVarietyValveAfterSeconds > 0f ? w.stickyVarietyValveAfterSeconds : 16f; if (onCurrent >= after) { return true; } float maxW = MaxWatchFor(scene); float frac = w.stickyVarietyValveMaxWatchFrac > 0f ? w.stickyVarietyValveMaxWatchFrac : 0.4f; return maxW > 0f && onCurrent >= maxW * frac; } /// /// Peers allowed through the sticky variety valve (class rules, not tip strings). /// private static bool IsVarietyValveCandidate(InterestCandidate c) { if (c == null || InterestScoring.IsFillScore(c.TotalScore) || IsAmbientShot(c)) { return false; } // Never treat another fight/war as a "variety" peer - those use sticky margin / same-arc. if (c.Completion == InterestCompletionKind.CombatActive || c.Completion == InterestCompletionKind.WarFront || string.Equals(c.Category, "Combat", StringComparison.OrdinalIgnoreCase) || string.Equals(c.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase) || string.Equals(c.AssetId, "live_combat", StringComparison.OrdinalIgnoreCase) || string.Equals(c.AssetId, "live_war", StringComparison.OrdinalIgnoreCase)) { return false; } if (c.Completion == InterestCompletionKind.PlotActive || c.Completion == InterestCompletionKind.StatusOutbreak) { return true; } if (InterestScoring.IsHotScore(c.TotalScore) || c.EventStrength >= InterestScoringConfig.W.hotScoreMin) { return true; } if (c.EventStrength >= InterestScoringConfig.W.noticeScoreMin && c.LeadKind == InterestLeadKind.EventLed) { return true; } if (IsParkableLifeBeat(c)) { return true; } if (c.Completion == InterestCompletionKind.StatusPhase && IsLethalStatusCandidate(c)) { return true; } // New-species discovery Curiosity. if (!string.IsNullOrEmpty(c.Key) && c.Key.StartsWith("species:", StringComparison.OrdinalIgnoreCase)) { return true; } string label = c.Label ?? ""; return label.StartsWith("New species:", StringComparison.OrdinalIgnoreCase); } private static bool IsParkableLifeBeat(InterestCandidate c) { if (c == null) { return false; } string asset = c.AssetId ?? ""; string key = c.Key ?? ""; string happiness = c.HappinessEffectId ?? ""; if (asset.Equals("set_lover", StringComparison.OrdinalIgnoreCase) || asset.Equals("clear_lover", StringComparison.OrdinalIgnoreCase) || asset.Equals("find_lover", StringComparison.OrdinalIgnoreCase) || asset.Equals("fallen_in_love", StringComparison.OrdinalIgnoreCase) || happiness.Equals("fallen_in_love", StringComparison.OrdinalIgnoreCase)) { return true; } return key.StartsWith("rel:set_lover", StringComparison.OrdinalIgnoreCase) || key.StartsWith("rel:clear_lover", StringComparison.OrdinalIgnoreCase) || key.StartsWith("rel:find_lover", StringComparison.OrdinalIgnoreCase); } private static bool IsLethalStatusCandidate(InterestCandidate c) { if (c == null) { return false; } string id = c.StatusId ?? c.AssetId ?? ""; if (string.IsNullOrEmpty(id)) { return false; } return id.IndexOf("drown", StringComparison.OrdinalIgnoreCase) >= 0 || id.IndexOf("burn", StringComparison.OrdinalIgnoreCase) >= 0 || id.IndexOf("poison", StringComparison.OrdinalIgnoreCase) >= 0 || id.IndexOf("frozen", StringComparison.OrdinalIgnoreCase) >= 0 || id.IndexOf("curse", StringComparison.OrdinalIgnoreCase) >= 0 || id.IndexOf("plague", StringComparison.OrdinalIgnoreCase) >= 0 || id.IndexOf("infect", StringComparison.OrdinalIgnoreCase) >= 0; } /// /// 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). // Parkable life beats may still wait for the variety valve under combat/war. if (_current != null && c.CreatedAt < _currentStartedAt - 0.05f) { if (IsParkableLifeBeat(c) && (_current.Completion == InterestCompletionKind.CombatActive || _current.Completion == InterestCompletionKind.WarFront) && now - c.CreatedAt < 45f) { return true; } 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; } if (IsParkableLifeBeat(c) && _current != null && (_current.Completion == InterestCompletionKind.CombatActive || _current.Completion == InterestCompletionKind.WarFront) && age < 45f) { 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. // Life beats (lover / bond) park longer under sticky combat/war so the variety // valve can still cut them in instead of soft-dropping forever. if (_current != null && CurrentIsActive && (c.Completion == InterestCompletionKind.FixedDwell || c.Completion == InterestCompletionKind.Manual) && now - c.CreatedAt > 8f) { bool parkLife = IsParkableLifeBeat(c) && (_current.Completion == InterestCompletionKind.CombatActive || _current.Completion == InterestCompletionKind.WarFront); if (parkLife) { return now - c.CreatedAt > 45f; } return true; } return false; } private static void SwitchTo(InterestCandidate next, float now, bool resumableInterrupt) { if (next == null) { return; } // Same scene object: maintain already owns focus - do not re-Watch / re-log. if (next == _current) { return; } if (!EventPresentability.WouldShow(next) && !string.Equals(next.Source, "harness", StringComparison.OrdinalIgnoreCase)) { InterestDropLog.Record("unpresentable", next.Label ?? next.Key); InterestRegistry.Remove(next.Key); return; } StoryScheduler.NoteActualSelection(next); NarrativeExposureLedger.NotePresented(next); if (_current != null && _current.Resumable && next.TotalScore >= _current.TotalScore + InterestScoringConfig.W.cutInMargin // Already-shown moment beats are cooled - parking them only enables resume spam. && !InterestVariety.IsBeatCooled(_current, now)) { _interrupted = _current.CloneShallow(); } // Consume the once-per-hold variety valve when leaving sticky combat/war for a peer story. bool leavingStickyCombat = _current != null && (_current.Completion == InterestCompletionKind.CombatActive || _current.Completion == InterestCompletionKind.WarFront); bool nextIsCombatTheater = next.Completion == InterestCompletionKind.CombatActive || next.Completion == InterestCompletionKind.WarFront; if (leavingStickyCombat && !nextIsCombatTheater && IsVarietyValveCandidate(next) && VarietyValveTimeReady(_current, now - _currentStartedAt)) { _varietyValveUsed = true; } if (nextIsCombatTheater && !leavingStickyCombat) { _varietyValveUsed = false; } _current = next; _currentStartedAt = now; _lastSwitchAt = now; _inactiveSince = -999f; _harnessCombatForcedCold = false; if (!InterestScoring.IsFillScore(next.TotalScore)) { _lastInterestingAt = now; } InterestRegistry.MarkSelected(next.Key); InterestVariety.NoteSelection(next); StoryPlanner.OnSwitchTo(next, now); CameraEligibilityModel eligibility = CameraEligibility.Classify(next); LogService.LogInfo( "[IdleSpectator][CAMERA_ELIGIBILITY] kind=" + eligibility.Kind + " subject=" + next.SubjectId + " anchor=" + eligibility.AnchorMcId + " evidence=" + eligibility.Evidence + " connection=" + eligibility.ConnectionLine); CameraDirector.Watch(next.ToInterestEvent()); } private static void TryCharacterFill(bool force) { float now = Time.unscaledTime; _lastAmbientAt = now; // Crisis chapters own the camera - no ambient channel-surfing mid-war/disaster. if (StoryPlanner.SuppressAmbientFill) { return; } 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; } Actor grounding = LifeSagaRoster.BestLivingMc(); long groundingId = EventFeedUtil.SafeId(grounding); if (grounding == null || groundingId == 0 || !LifeSagaRoster.IsMc(groundingId)) { return; } // Quiet grounding belongs to the current ensemble. Background lives are still scanned // and scored for admission, but routine activity is not a reason to give them camera time. var ambient = new InterestEvent { Score = InterestScoringConfig.W.fillScoreMax > 0f ? InterestScoringConfig.W.fillScoreMax * 0.5f : 20f, Position = grounding.current_position, FollowUnit = grounding, Label = "", CreatedAt = now, AssetId = grounding.asset != null ? grounding.asset.id : "", CharacterSignificance = 1f }; // 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 = ""; candidate.NarrativePresentation = null; candidate.NarrativeDevelopmentId = ""; candidate.HasNarrativeFunction = true; candidate.NarrativeFunction = NarrativeFunction.CharacterTexture; candidate.NarrativeFunctionSource = "character_grounding"; // Roster refresh can replace an MC between BestLivingMc and registration. // Revalidate the actual candidate immediately before it is allowed to switch. if (!grounding.isAlive() || !LifeSagaRoster.IsMc(groundingId) || candidate.SubjectId != groundingId || !CameraEligibility.MayUseCamera(candidate)) { InterestRegistry.Remove(candidate.Key); return; } 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); } }