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 = 1.25f; /// Require this much extra focus weight before stealing the camera mid-fight. private const float CombatFocusSwitchMargin = 12f; 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; } 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: 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); } 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); MaintainWarFront(now, force: false); MaintainPlotCell(now, force: false); MaintainFamilyPack(now, force: false); MaintainStatusOutbreak(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: 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; } // 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"); } /// /// Keep camera on the highest-scored combat participant and rewrite tip/counts /// from a live so subject + scale always match 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; ApplyCombatEnsembleToCurrent(forceWatch: false); } /// /// Refresh active combat scene from the world ensemble. Returns false if no focus. /// private static bool ApplyCombatEnsembleToCurrent(bool forceWatch) { if (_current == null || _current.Completion != InterestCompletionKind.CombatActive) { return false; } // Snapshot durable pair ownership before cluster rebuild can rewrite Follow+Related. ResolveCombatPairActors(_current, out Actor ownedFocus, out Actor ownedRelated); if (ownedFocus != null) { _current.StampCombatPair(ownedFocus, ownedRelated); } bool pairOwned = IsNamedPairCombatOwnership(_current) || (_current.HasCombatPairLock && ownedFocus != null && ownedFocus.isAlive() && ownedRelated != null && ownedRelated.isAlive() && !HasStickyCollectiveMulti(_current)); // Fast path: living NamedPair never runs sticky Refresh (ambient camps were flipping Duels). // Skip when Follow was cleared (death / harness handoff) so Related can take ownership. if (pairOwned && _current.HasFollowUnit && ownedFocus != null && ownedFocus.isAlive() && ownedRelated != null && ownedRelated.isAlive() && !ShouldEscalateNamedPair(_current, null, 2)) { string pairLabel = ""; Actor holdFocus = ownedFocus; Actor holdFoe = ownedRelated; int holdFighters = 2; ApplyNamedPairHold( _current, ownedFocus, ownedRelated, ref holdFocus, ref holdFoe, ref holdFighters, ref pairLabel); string priorLabel = _current.Label ?? ""; if (string.IsNullOrEmpty(pairLabel)) { pairLabel = priorLabel; } bool holdFollowChanged = _current.FollowUnit != holdFocus; bool holdLabelChanged = !string.Equals(priorLabel, pairLabel ?? "", StringComparison.Ordinal); _current.FollowUnit = holdFocus; _current.SubjectId = EventFeedUtil.SafeId(holdFocus); _current.RelatedUnit = holdFoe; _current.RelatedId = holdFoe != null ? EventFeedUtil.SafeId(holdFoe) : 0; _current.Label = pairLabel; try { _current.Position = holdFocus.current_position; } catch { // keep } bool holdNeedWatch = forceWatch || holdFollowChanged || holdLabelChanged || !HasLivingCameraFocus() || MoveCamera._focus_unit != holdFocus; if (holdNeedWatch) { CameraDirector.Watch(_current.ToInterestEvent()); } return true; } Vector3 pos = _current.Position; if (ownedFocus != null && ownedFocus.isAlive()) { pos = ownedFocus.current_position; } else if (_current.FollowUnit != null && _current.FollowUnit.isAlive()) { pos = _current.FollowUnit.current_position; } else if (ownedRelated != null && ownedRelated.isAlive()) { pos = ownedRelated.current_position; } else if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive()) { pos = _current.RelatedUnit.current_position; } bool massHint = string.Equals(_current.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase) || _current.ParticipantCount > 2 || HasStickyCombatSides(_current); float radius = massHint ? 14f : 10f; // Death / clear-follow handoff: promote Related before cluster re-pick can steal focus. if (!_current.HasFollowUnit && _current.RelatedUnit != null && _current.RelatedUnit.isAlive()) { Actor handoff = _current.RelatedUnit; Actor handoffFoe = null; try { if (handoff.has_attack_target && handoff.attack_target != null && handoff.attack_target.isAlive() && handoff.attack_target.isActor()) { handoffFoe = handoff.attack_target.a; if (handoffFoe != null && !handoffFoe.isAlive()) { handoffFoe = null; } } } catch { handoffFoe = null; } LiveEnsemble.TryBuildCombat( handoff.current_position, radius, out LiveEnsemble handoffEnsemble, priorParticipantIds: _current.ParticipantIds, newcomerBonus: 8f); if (handoffEnsemble != null) { StickyScoreboard.StabilizeScale(_current.Sticky, handoffEnsemble, Time.unscaledTime); ApplyEnsembleFields(_current, handoffEnsemble); StickyScoreboard.Refresh( _current, handoffEnsemble, handoff.current_position, radius); } // Keep ownership on the handoff unit; only use ensemble framing when the melee is large. string handoffLabel; if (handoffEnsemble != null && (handoffEnsemble.ParticipantCount > 2 || StickyScoreboard.HasOpposingSides(_current) || handoffEnsemble.Scale != EnsembleScale.Pair)) { handoffEnsemble.Focus = handoff; if (handoffEnsemble.Related == null || handoffEnsemble.Related == handoff) { handoffEnsemble.Related = handoffFoe; } handoffLabel = EventReason.Combat(handoffEnsemble); } else { // NamedPair handoff: keep Duel framing with the other pair id when possible. if (handoffFoe == null || !handoffFoe.isAlive()) { long handoffId = EventFeedUtil.SafeId(handoff); long otherId = _current.PairOwnerId != 0 && _current.PairOwnerId != handoffId ? _current.PairOwnerId : _current.PairPartnerId; if (otherId != 0 && otherId != handoffId) { handoffFoe = LiveEnsemble.FindTrackedActor(otherId); } } var pairHandoff = new LiveEnsemble { Kind = EnsembleKind.Combat, Scale = EnsembleScale.Pair, Frame = EnsembleFrame.NamedPair, ParticipantCount = 2, Focus = handoff, Related = handoffFoe }; handoffLabel = EventReason.Combat(pairHandoff); if (string.IsNullOrEmpty(handoffLabel)) { handoffLabel = EventReason.Fight(handoff, handoffFoe); } _current.ParticipantCount = Mathf.Max(2, _current.ParticipantCount); } bool handoffFollowChanged = _current.FollowUnit != handoff; bool handoffLabelChanged = !string.Equals(_current.Label ?? "", handoffLabel ?? "", StringComparison.Ordinal); _current.FollowUnit = handoff; _current.SubjectId = EventFeedUtil.SafeId(handoff); _current.RelatedUnit = handoffFoe; _current.RelatedId = handoffFoe != null ? EventFeedUtil.SafeId(handoffFoe) : 0; _current.StampCombatPair(handoff, handoffFoe); _current.Label = handoffLabel; try { _current.Position = handoff.current_position; } catch { // keep } if (forceWatch || handoffFollowChanged || handoffLabelChanged || !HasLivingCameraFocus() || MoveCamera._focus_unit != handoff) { CameraDirector.Watch(_current.ToInterestEvent()); } return true; } LiveEnsemble.TryBuildCombat( pos, radius, out LiveEnsemble ensemble, priorParticipantIds: _current.ParticipantIds, newcomerBonus: 8f); Actor best; Actor foe; int fighters; string label; if (ensemble != null && ensemble.HasFocus) { StickyScoreboard.StabilizeScale(_current.Sticky, ensemble, Time.unscaledTime); best = ensemble.Focus; foe = ensemble.Related; fighters = Mathf.Max(0, ensemble.ParticipantCount); ApplyEnsembleFields(_current, ensemble); StickyScoreboard.Refresh(_current, ensemble, pos, radius); label = EventReason.Combat(ensemble); } else if (WorldActivityScanner.TryPickBestCombatFocus( _current, out best, out foe, out fighters)) { var fallback = new LiveEnsemble { Kind = EnsembleKind.Combat, Scale = LiveEnsemble.ScaleForCount(fighters), Frame = fighters <= 2 ? EnsembleFrame.NamedPair : EnsembleFrame.CountOnly, ParticipantCount = fighters, Focus = best, Related = foe }; StickyScoreboard.StabilizeScale(_current.Sticky, fallback, Time.unscaledTime); ApplyEnsembleFields(_current, fallback); StickyScoreboard.Refresh(_current, fallback, pos, radius); label = EventReason.Combat(fallback); _current.ParticipantCount = Math.Max(fighters, _current.CombatSideACount + _current.CombatSideBCount); } else if (StickyScoreboard.HasOpposingSides(_current)) { // No live fighters briefly - keep framed tip from sticky roster until combat goes cold. if (!_current.HasFollowUnit) { StickyScoreboard.TryPromoteFollow(_current); } var held = new LiveEnsemble { Kind = EnsembleKind.Combat, Scale = LiveEnsemble.ScaleForCount(Math.Max(3, _current.CombatPeakParticipants)), Focus = _current.FollowUnit, Related = _current.RelatedUnit, ParticipantCount = 0 }; StickyScoreboard.StabilizeScale(_current.Sticky, held, Time.unscaledTime); StickyScoreboard.Refresh(_current, held, pos, radius); if (held.Focus == null || !held.Focus.isAlive()) { held.Focus = _current.RelatedUnit; } if ((held.Focus == null || !held.Focus.isAlive()) && StickyScoreboard.TryPromoteFollow(_current)) { held.Focus = _current.FollowUnit; held.Related = _current.RelatedUnit; StickyScoreboard.Refresh(_current, held, pos, radius); } if (held.Focus == null || !held.Focus.isAlive()) { return false; } best = held.Focus; foe = held.Related; label = EventReason.Combat(held); fighters = _current.CombatSideACount + _current.CombatSideBCount; } else { return false; } // Prefer the ownership snapshot over any Follow/Related rewrite during Refresh. Actor curFollow = ownedFocus != null && ownedFocus.isAlive() ? ownedFocus : _current.FollowUnit; Actor curRelated = ownedRelated != null && ownedRelated.isAlive() ? ownedRelated : _current.RelatedUnit; // Named-pair lock: ambient nearby camps must not steal / flip a living Duel. // Only a true multi escalate (or intentional sticky Battle) may leave the pair. if (pairOwned && curFollow != null && curFollow.isAlive() && !ShouldEscalateNamedPair(_current, ensemble, fighters)) { ApplyNamedPairHold(_current, curFollow, curRelated, ref best, ref foe, ref fighters, ref label); } else { // Never stick on a sleeper / bystander unless they are on the sticky combat roster. bool curFighting = LiveEnsemble.IsCombatParticipant(curFollow); bool curRostered = StickyScoreboard.IsOnRoster(_current, curFollow); if (!LiveEnsemble.IsCombatParticipant(best)) { if (curFighting || curRostered) { best = curFollow; foe = ResolveAttackFoe(best) ?? foe; } } else if ((curFighting || curRostered) && best != curFollow && curFollow != null && curFollow.isAlive() && !ShouldHoldDuelOwnership(_current, best, foe, fighters, curFollow, curRelated)) { float curW = LiveEnsemble.FocusWeight( curFollow, _current.ParticipantIds, newcomerBonus: 0f); if (curRostered && !curFighting) { curW += 20f; } float newW = LiveEnsemble.FocusWeight(best, _current.ParticipantIds, newcomerBonus: 8f); if (newW < curW + CombatFocusSwitchMargin) { best = curFollow; if (foe == null || foe == best) { foe = ResolveAttackFoe(best) ?? foe; } } } // Preserve duel partner when rebuild briefly loses Related (attack_target gaps). bool duelLabeled = !string.IsNullOrEmpty(_current.Label) && _current.Label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase); if (foe == null && curRelated != null && curRelated.isAlive() && curRelated != best && (duelLabeled || _current.ForceActive || fighters <= 2)) { foe = curRelated; } // Final pair ownership lock: Duel tips must not flip A↔B across maintain ticks. if (ShouldHoldDuelOwnership(_current, best, foe, fighters, curFollow, curRelated)) { ApplyNamedPairHold(_current, curFollow, curRelated, ref best, ref foe, ref fighters, ref label); } } // Never replace a sticky scoreboard tip with thin Fight prose. if (HasStickyCombatSides(_current) && (string.IsNullOrEmpty(label) || label.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0)) { var stickyEns = new LiveEnsemble { Kind = EnsembleKind.Combat, Scale = LiveEnsemble.ScaleForCount(Math.Max(3, _current.CombatPeakParticipants)), Focus = best, Related = foe, Frame = _current.CombatSideFrame, SideA = new EnsembleSide { Key = _current.CombatSideAKey, Display = _current.CombatSideADisplay, KingdomDisplay = _current.CombatSideAKingdom, Count = _current.CombatSideACount, Best = best }, SideB = new EnsembleSide { Key = _current.CombatSideBKey, Display = _current.CombatSideBDisplay, KingdomDisplay = _current.CombatSideBKingdom, Count = _current.CombatSideBCount, Best = foe }, ParticipantCount = _current.CombatSideACount + _current.CombatSideBCount }; label = EventReason.Combat(stickyEns); } string previousLabel = _current.Label ?? ""; if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(previousLabel)) { label = previousLabel; } // Never drop the duel partner while the tip is still a Duel - empty Related // makes the next maintain flip ownership to a thin "X is fighting" tip. if (foe == null && curRelated != null && curRelated.isAlive() && curRelated != best && !string.IsNullOrEmpty(label) && label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)) { foe = curRelated; } bool followChanged = _current.FollowUnit != best; bool labelChanged = !string.Equals(previousLabel, 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 } bool needWatch = forceWatch || followChanged || labelChanged || !HasLivingCameraFocus() || MoveCamera._focus_unit != best; if (needWatch) { CameraDirector.Watch(_current.ToInterestEvent()); } return true; } /// /// Refresh sticky war tip (kingdom populations + follow) while WarFront completion is hot. /// private static bool MaintainWarFront(float now, bool force) { if (_current == null || _current.Completion != InterestCompletionKind.WarFront) { return false; } if (!force && now - _lastCombatFocusAt < CombatFocusThrottleSeconds) { return _current.HasFollowUnit; } _lastCombatFocusAt = now; return ApplyWarFrontMaintain(forceWatch: force); } private static bool ApplyWarFrontMaintain(bool forceWatch) { if (_current == null || _current.Completion != InterestCompletionKind.WarFront) { return false; } if (!_current.HasFollowUnit) { StickyScoreboard.TryPromoteFollow(_current); } if (!_current.HasFollowUnit) { return false; } Vector3 pos = _current.Position; try { pos = _current.FollowUnit.current_position; } catch { // keep } var held = new LiveEnsemble { Kind = EnsembleKind.WarFront, Scale = LiveEnsemble.ScaleForCount(Math.Max(3, _current.CombatPeakParticipants)), Frame = EnsembleFrame.KingdomVsKingdom, Focus = _current.FollowUnit, Related = _current.RelatedUnit, ParticipantCount = _current.CombatSideACount + _current.CombatSideBCount }; StickyScoreboard.StabilizeScale(_current.Sticky, held, Time.unscaledTime); StickyScoreboard.Refresh(_current, held, pos, StickyScoreboard.WarTheaterRadius); if (held.Focus != null && held.Focus.isAlive()) { _current.FollowUnit = held.Focus; _current.SubjectId = EventFeedUtil.SafeId(held.Focus); } if (held.Related != null && held.Related.isAlive()) { _current.RelatedUnit = held.Related; _current.RelatedId = EventFeedUtil.SafeId(held.Related); } string label = EventReason.WarFront(held); if (string.IsNullOrEmpty(label)) { label = StickyScoreboard.FormatReason(_current, _current.FollowUnit, _current.RelatedUnit); } if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(_current.Label)) { label = _current.Label; } bool followChanged = held.Focus != null && _current.FollowUnit != held.Focus; bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal); _current.Label = label ?? ""; _current.AssetId = "live_war"; try { if (_current.FollowUnit != null) { _current.Position = _current.FollowUnit.current_position; } } catch { // keep } bool needWatch = forceWatch || followChanged || labelChanged || !HasLivingCameraFocus() || (_current.FollowUnit != null && MoveCamera._focus_unit != _current.FollowUnit); if (needWatch && _current.HasFollowUnit) { CameraDirector.Watch(_current.ToInterestEvent()); } return _current.HasFollowUnit; } /// /// Refresh sticky plot tip (plotter roster + target kingdom) while PlotActive is hot. /// private static bool MaintainPlotCell(float now, bool force) { if (_current == null || _current.Completion != InterestCompletionKind.PlotActive) { return false; } if (!force && now - _lastCombatFocusAt < CombatFocusThrottleSeconds) { return _current.HasFollowUnit; } _lastCombatFocusAt = now; return ApplyPlotCellMaintain(forceWatch: force); } private static bool ApplyPlotCellMaintain(bool forceWatch) { if (_current == null || _current.Completion != InterestCompletionKind.PlotActive) { return false; } if (!_current.HasFollowUnit) { StickyScoreboard.TryPromoteFollow(_current); } if (!_current.HasFollowUnit) { return false; } Vector3 pos = _current.Position; try { pos = _current.FollowUnit.current_position; } catch { // keep } var held = new LiveEnsemble { Kind = EnsembleKind.PlotCell, Scale = LiveEnsemble.ScaleForCount(Math.Max(3, _current.CombatPeakParticipants)), Frame = EnsembleFrame.KingdomVsKingdom, Focus = _current.FollowUnit, Related = _current.RelatedUnit, ParticipantCount = _current.CombatSideACount + _current.CombatSideBCount, SideA = new EnsembleSide { Key = _current.CombatSideAKey, Display = "Plotters", Count = _current.CombatSideACount, Best = _current.FollowUnit }, SideB = new EnsembleSide { Key = _current.CombatSideBKey, Display = LiveEnsemble.KingdomDisplay(_current.CombatSideBKey), KingdomDisplay = LiveEnsemble.KingdomDisplay(_current.CombatSideBKey), Count = _current.CombatSideBCount, Best = _current.RelatedUnit } }; StickyScoreboard.StabilizeScale(_current.Sticky, held, Time.unscaledTime); StickyScoreboard.Refresh(_current, held, pos, StickyScoreboard.PlotTheaterRadius); if (held.Focus != null && held.Focus.isAlive()) { _current.FollowUnit = held.Focus; _current.SubjectId = EventFeedUtil.SafeId(held.Focus); } if (held.Related != null && held.Related.isAlive()) { _current.RelatedUnit = held.Related; _current.RelatedId = EventFeedUtil.SafeId(held.Related); } // Plot stickies are groups only - never "Plot - Plotters (1)" / (0). int plotters = held.SideA != null ? Math.Max(0, held.SideA.Count) : 0; if (plotters < 2) { _current.Label = ""; _current.AssetId = "live_plot"; return _current.HasFollowUnit; } string label = EventReason.PlotCell(held); if (string.IsNullOrEmpty(label)) { label = StickyScoreboard.FormatReason(_current, _current.FollowUnit, _current.RelatedUnit); } if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(_current.Label)) { label = _current.Label; } bool followChanged = held.Focus != null && _current.FollowUnit != held.Focus; bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal); _current.Label = label ?? ""; _current.AssetId = "live_plot"; try { if (_current.FollowUnit != null) { _current.Position = _current.FollowUnit.current_position; } } catch { // keep } bool needWatch = forceWatch || followChanged || labelChanged || !HasLivingCameraFocus() || (_current.FollowUnit != null && MoveCamera._focus_unit != _current.FollowUnit); if (needWatch && _current.HasFollowUnit) { CameraDirector.Watch(_current.ToInterestEvent()); } return _current.HasFollowUnit; } /// /// Refresh sticky family pack tip (roster + alpha promote) while FamilyPack is hot. /// private static bool MaintainFamilyPack(float now, bool force) { if (_current == null || _current.Completion != InterestCompletionKind.FamilyPack) { return false; } if (!force && now - _lastCombatFocusAt < CombatFocusThrottleSeconds) { return _current.HasFollowUnit; } _lastCombatFocusAt = now; return ApplyFamilyPackMaintain(forceWatch: force); } private static bool ApplyFamilyPackMaintain(bool forceWatch) { if (_current == null || _current.Completion != InterestCompletionKind.FamilyPack) { return false; } if (!_current.HasFollowUnit) { StickyScoreboard.TryPromoteFollow(_current); } if (!_current.HasFollowUnit) { return false; } Vector3 pos = _current.Position; try { pos = _current.FollowUnit.current_position; } catch { // keep } var held = new LiveEnsemble { Kind = EnsembleKind.FamilyPack, Scale = LiveEnsemble.ScaleForCount(Math.Max(2, _current.CombatSideACount)), Frame = EnsembleFrame.SpeciesVsSpecies, Focus = _current.FollowUnit, Related = _current.RelatedUnit, ParticipantCount = _current.CombatSideACount, SideA = new EnsembleSide { Key = _current.CombatSideAKey, Display = string.IsNullOrEmpty(_current.Sticky?.SideADisplay) ? LiveEnsemble.SpeciesDisplay(LiveEnsemble.SpeciesKeyOf(_current.FollowUnit)) : _current.Sticky.SideADisplay, Count = _current.CombatSideACount, Best = _current.FollowUnit } }; StickyScoreboard.StabilizeScale(_current.Sticky, held, Time.unscaledTime); StickyScoreboard.Refresh(_current, held, pos, StickyScoreboard.FamilyTheaterRadius); if (held.Focus != null && held.Focus.isAlive()) { _current.FollowUnit = held.Focus; _current.SubjectId = EventFeedUtil.SafeId(held.Focus); } if (held.Related != null && held.Related.isAlive()) { _current.RelatedUnit = held.Related; _current.RelatedId = EventFeedUtil.SafeId(held.Related); } string label = EventReason.FamilyPack(held); if (string.IsNullOrEmpty(label)) { label = StickyScoreboard.FormatReason(_current, _current.FollowUnit, _current.RelatedUnit); } if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(_current.Label)) { label = _current.Label; } bool followChanged = held.Focus != null && _current.FollowUnit != held.Focus; bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal); _current.Label = label ?? ""; _current.AssetId = "live_family"; try { if (_current.FollowUnit != null) { _current.Position = _current.FollowUnit.current_position; } } catch { // keep } bool needWatch = forceWatch || followChanged || labelChanged || !HasLivingCameraFocus() || (_current.FollowUnit != null && MoveCamera._focus_unit != _current.FollowUnit); if (needWatch && _current.HasFollowUnit) { CameraDirector.Watch(_current.ToInterestEvent()); } return _current.HasFollowUnit; } /// /// Refresh sticky status outbreak tip while StatusOutbreak completion is hot. /// private static bool MaintainStatusOutbreak(float now, bool force) { if (_current == null || _current.Completion != InterestCompletionKind.StatusOutbreak) { return false; } if (!force && now - _lastCombatFocusAt < CombatFocusThrottleSeconds) { return _current.HasFollowUnit; } _lastCombatFocusAt = now; return ApplyStatusOutbreakMaintain(forceWatch: force); } private static bool ApplyStatusOutbreakMaintain(bool forceWatch) { if (_current == null || _current.Completion != InterestCompletionKind.StatusOutbreak) { return false; } if (!_current.HasFollowUnit) { StickyScoreboard.TryPromoteFollow(_current); } if (!_current.HasFollowUnit) { return false; } Vector3 pos = _current.Position; try { pos = _current.FollowUnit.current_position; } catch { // keep } string statusId = _current.StatusId ?? ""; if (string.IsNullOrEmpty(statusId) && LiveEnsemble.IsStatusOutbreakSideKey(_current.CombatSideAKey)) { statusId = _current.CombatSideAKey.Substring("status:".Length); _current.StatusId = statusId; } var held = new LiveEnsemble { Kind = EnsembleKind.StatusOutbreak, Scale = LiveEnsemble.ScaleForCount(Math.Max(2, _current.CombatSideACount)), Frame = EnsembleFrame.SpeciesVsSpecies, Focus = _current.FollowUnit, Related = _current.RelatedUnit, ParticipantCount = _current.CombatSideACount, SideA = new EnsembleSide { Key = string.IsNullOrEmpty(_current.CombatSideAKey) ? "status:" + statusId : _current.CombatSideAKey, Display = string.IsNullOrEmpty(_current.Sticky?.SideADisplay) ? LiveEnsemble.StatusDisplayName(statusId) : _current.Sticky.SideADisplay, Count = _current.CombatSideACount, Best = _current.FollowUnit } }; StickyScoreboard.StabilizeScale(_current.Sticky, held, Time.unscaledTime); StickyScoreboard.Refresh(_current, held, pos, StickyScoreboard.StatusOutbreakRadius); if (held.Focus != null && held.Focus.isAlive()) { _current.FollowUnit = held.Focus; _current.SubjectId = EventFeedUtil.SafeId(held.Focus); } if (held.Related != null && held.Related.isAlive()) { _current.RelatedUnit = held.Related; _current.RelatedId = EventFeedUtil.SafeId(held.Related); } // Outbreak stickies are groups only - never "Outbreak - X (0)" / solo (1). int carriers = held.SideA != null ? Math.Max(0, held.SideA.Count) : 0; if (carriers < 2) { _current.Label = ""; _current.AssetId = "live_outbreak"; return _current.HasFollowUnit; } string label = EventReason.StatusOutbreak(held); if (string.IsNullOrEmpty(label)) { label = StickyScoreboard.FormatReason(_current, _current.FollowUnit, _current.RelatedUnit); } if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(_current.Label)) { label = _current.Label; } bool followChanged = held.Focus != null && _current.FollowUnit != held.Focus; bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal); _current.Label = label ?? ""; _current.AssetId = "live_outbreak"; try { if (_current.FollowUnit != null) { _current.Position = _current.FollowUnit.current_position; } } catch { // keep } bool needWatch = forceWatch || followChanged || labelChanged || !HasLivingCameraFocus() || (_current.FollowUnit != null && MoveCamera._focus_unit != _current.FollowUnit); if (needWatch && _current.HasFollowUnit) { CameraDirector.Watch(_current.ToInterestEvent()); } return _current.HasFollowUnit; } 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; } return scene.CombatPeakParticipants >= 3 || string.Equals(scene.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase); } /// /// 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 / durable pair lock wins over ambient sticky pollution. if (label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase) || (scene.HasCombatPairLock && pairIdsLive)) { return true; } return false; } 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; } /// /// Allow leaving NamedPair only after an intentional multi frame exists /// (harness escalate / real sticky Battle), never from ambient radius noise. /// private static bool ShouldEscalateNamedPair( InterestCandidate scene, LiveEnsemble ensemble, int fighters) { if (scene == null) { return false; } string tip = scene.Label ?? ""; if (tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)) { // Duel tips never escalate from a scan rebuild; escalate harness rewrites the tip first. return false; } if (HasStickyCollectiveMulti(scene)) { return true; } if (fighters < 3 || ensemble == null || !LiveEnsemble.HasOpposingCollectiveSides(ensemble)) { return false; } // Natural growth: both pair members still fighting inside a sided multi cluster. Actor focus = scene.FollowUnit; Actor related = scene.RelatedUnit; if (focus == null || !focus.isAlive() || related == null || !related.isAlive()) { return false; } return LiveEnsemble.IsCombatParticipant(focus) && LiveEnsemble.IsCombatParticipant(related) && !scene.ForceActive; } private static void ApplyNamedPairHold( InterestCandidate scene, Actor ownedFocus, Actor ownedRelated, ref Actor best, ref Actor foe, ref int fighters, ref string label) { if (scene == null || ownedFocus == null || !ownedFocus.isAlive()) { return; } // Drop ambient species/kingdom sticky that leaked in during cluster Refresh. if (HasStickyCombatSides(scene) && (scene.CombatSideFrame == EnsembleFrame.SpeciesVsSpecies || scene.CombatSideFrame == EnsembleFrame.KingdomVsKingdom)) { scene.ClearCombatSticky(); } best = ownedFocus; foe = ResolvePairPartner(ownedFocus, ownedRelated, foe); fighters = Mathf.Max(2, Math.Min(fighters, 2)); scene.ParticipantCount = Mathf.Max(2, scene.ParticipantCount); if (string.Equals(scene.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase) && scene.CombatPeakParticipants <= 2) { scene.AssetId = "live_combat"; } var pairHold = new LiveEnsemble { Kind = EnsembleKind.Combat, Scale = EnsembleScale.Pair, Frame = EnsembleFrame.NamedPair, ParticipantCount = 2, Focus = best, Related = foe }; label = EventReason.Combat(pairHold); if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(scene.Label)) { label = scene.Label; } } private static Actor ResolvePairPartner(Actor focus, Actor ownedRelated, Actor rebuiltFoe) { if (ownedRelated != null && ownedRelated.isAlive() && ownedRelated != focus) { return ownedRelated; } Actor attack = ResolveAttackFoe(focus); if (attack != null && attack != focus) { return attack; } if (rebuiltFoe != null && rebuiltFoe.isAlive() && rebuiltFoe != focus) { return rebuiltFoe; } return null; } /// /// Hold Duel ownership whenever Follow+Related still form the live pair. /// Ambient sticky camps do not unlock a living NamedPair. /// private static bool ShouldHoldDuelOwnership( InterestCandidate scene, Actor best, Actor foe, int fighters, Actor ownedFocus = null, Actor ownedRelated = null) { if (scene == null) { return false; } Actor curFocus = ownedFocus != null && ownedFocus.isAlive() ? ownedFocus : scene.FollowUnit; Actor curRelated = ownedRelated != null && ownedRelated.isAlive() ? ownedRelated : scene.RelatedUnit; if (curFocus == null || !curFocus.isAlive() || curRelated == null || !curRelated.isAlive()) { return false; } // Already-escalated collective multi may leave the pair (not ambient Duel pollution). if (HasStickyCollectiveMulti(scene)) { string tip = scene.Label ?? ""; if (!tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)) { return false; } } bool duelLabeled = !string.IsNullOrEmpty(scene.Label) && scene.Label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase); bool smallCluster = fighters <= 2; if (!duelLabeled && !smallCluster && !scene.ForceActive) { return false; } // Rebuilt focus/foe is the same two actors (any order), or only one of them // remains visible in the cluster (foe briefly missing). return IsSameCombatPair(curFocus, curRelated, best, foe); } /// /// True when / are the same two actors as the /// current pair (possibly swapped). Used to lock Duel tip ownership. /// private static bool IsSameCombatPair(Actor curFocus, Actor curRelated, Actor best, Actor foe) { if (curFocus == null || !curFocus.isAlive() || best == null || !best.isAlive()) { return false; } if (best == curFocus) { return foe == null || foe == curRelated || foe == curFocus || (curRelated == null && ResolveAttackFoe(curFocus) == foe); } if (best == curRelated) { return foe == null || foe == curFocus || foe == curRelated; } Actor liveFoe = ResolveAttackFoe(curFocus) ?? curRelated; return liveFoe != null && (best == liveFoe) && (foe == null || foe == curFocus); } 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 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 || _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; } /// 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; } // Leaving NamedPair: clear durable pair lock so maintain may keep collective framing. if (authoredN >= 3 || LiveEnsemble.HasOpposingCollectiveSides(ensemble)) { _current.PairOwnerId = 0; _current.PairPartnerId = 0; } _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.Combat(ensemble); try { _current.Position = ensemble.Focus.current_position; } catch { // keep } CameraDirector.Watch(_current.ToInterestEvent()); return true; } private static void ClearStaleCombatLabel(float now) { if (_current == null || string.IsNullOrEmpty(_current.Label)) { return; } string label = _current.Label; if (label.IndexOf("fighting", StringComparison.OrdinalIgnoreCase) < 0 && label.IndexOf("fight of", StringComparison.OrdinalIgnoreCase) < 0 && label.IndexOf("duel", StringComparison.OrdinalIgnoreCase) < 0 && label.IndexOf("skirmish", StringComparison.OrdinalIgnoreCase) < 0 && label.IndexOf("clash", StringComparison.OrdinalIgnoreCase) < 0 && label.IndexOf("battle", StringComparison.OrdinalIgnoreCase) < 0 && label.IndexOf("mass -", StringComparison.OrdinalIgnoreCase) < 0 && label.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase) < 0 && label.IndexOf("leading a fight", StringComparison.OrdinalIgnoreCase) < 0) { return; } _current.Label = ""; _current.ClearCombatSticky(); _lastCombatFocusAt = now; CameraDirector.Watch(_current.ToInterestEvent()); } /// Harness: force one combat focus maintenance pass. public static void HarnessMaintainCombatFocus() { MaintainCombatFocus(Time.unscaledTime, force: true); } /// Harness: force one war-front maintenance pass. public static void HarnessMaintainWarFront() { MaintainWarFront(Time.unscaledTime, force: true); } /// Harness: force one plot-cell maintenance pass. public static void HarnessMaintainPlotCell() { MaintainPlotCell(Time.unscaledTime, force: true); } /// Harness: force one family-pack maintenance pass. public static void HarnessMaintainFamilyPack() { MaintainFamilyPack(Time.unscaledTime, force: true); } /// Harness: force one status-outbreak maintenance pass. public static void HarnessMaintainStatusOutbreak() { MaintainStatusOutbreak(Time.unscaledTime, force: 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 (_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) { 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.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: case InterestCompletionKind.WarFront: case InterestCompletionKind.PlotActive: 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; } private static bool CanSwitchTo(InterestCandidate candidate, float onCurrent, float sinceSwitch) { if (candidate == null) { return false; } // Same FixedDwell beat already watched on this subject - never cut back in. if (InterestVariety.IsBeatCooled(candidate)) { InterestDropLog.Record("beat_cooldown", candidate.HappinessEffectId ?? candidate.AssetId ?? candidate.Key); 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; } // Soft family pack: yield to discrete unit events after a short hold. if (IsSoftStickyCluster(_current) && !IsSoftStickyCluster(candidate) && !nextFill && !IsAmbientShot(candidate) && onCurrent >= SoftClusterYieldSeconds && nextScore >= curScore - w.rotateSlack) { 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. // Soft clusters use the normal cut-in margin (not stickyCutInMargin). float effectiveMargin = IsSoftStickyCluster(_current) ? w.cutInMargin : cutMargin; if (nextScore >= curScore + effectiveMargin) { return true; } if (sticky && !IsSoftStickyCluster(_current) && 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; } /// Soft multi-actor holds that must yield to discrete unit events. private const float SoftClusterYieldSeconds = 4f; private static bool IsSoftStickyCluster(InterestCandidate c) => c != null && c.Completion == InterestCompletionKind.FamilyPack; private static bool IsStickyStoryScene(InterestCandidate c) { if (c == null) { return false; } // FamilyPack is a soft cluster - not hard sticky (see IsSoftStickyCluster). if (c.Completion == InterestCompletionKind.CombatActive || c.Completion == InterestCompletionKind.WarFront || c.Completion == InterestCompletionKind.PlotActive || c.Completion == InterestCompletionKind.StatusPhase || c.Completion == InterestCompletionKind.StatusOutbreak || 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); } }