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; /// 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 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: 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); } public static void OnSpectatorEnabled() { 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; _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; StoryPlanner.Tick(now); 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" - 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; } // 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; } float throttle = _current.ParticipantCount >= CombatFocusLargeParticipantThreshold ? CombatFocusThrottleLargeSeconds : CombatFocusThrottleSeconds; if (!force && now - _lastCombatFocusAt < throttle) { 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 skips sticky Refresh (ambient camps were flipping Duels). // Probe the local scrap first so a real sided multi can escalate Duel → Skirmish/Battle/Mass. // 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()) { LiveEnsemble.TryBuildCombat( ownedFocus.current_position, 14f, out LiveEnsemble escalateProbe, priorParticipantIds: _current.ParticipantIds, newcomerBonus: 8f); int probeFighters = escalateProbe != null ? escalateProbe.ParticipantCount : 2; bool focusFighting = LiveEnsemble.IsCombatParticipant(ownedFocus); bool relatedFighting = LiveEnsemble.IsCombatParticipant(ownedRelated); int stickyCampN = _current.CombatSideACount + _current.CombatSideBCount; // Sticky camps enrolled (pack wire / live growth) outrank NamedPair hold even when // the live probe still looks like a thin Duel this tick. bool stickyPackEscalate = HasStickyCollectiveMulti(_current) && stickyCampN >= 3 && (focusFighting || relatedFighting); // Both left the scrap (chores / idle): do not hold a Duel camera on them. // Keep durable A vs B order while either still fights (no tip flip on attack gaps). if (!focusFighting && !relatedFighting) { // Fall through to sticky rebuild / live fighter pick. } else if (!stickyPackEscalate && !ShouldEscalateNamedPair(_current, escalateProbe, probeFighters)) { 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; } // Real multi around the pair: fall through to sticky rebuild / collective tip. } 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 { // Leaving NamedPair for a sided multi: drop durable pair lock so later maintains // cannot ApplyNamedPairHold and collapse Mass/Battle back to Duel. if (pairOwned && fighters >= 3 && (ensemble != null && LiveEnsemble.HasOpposingCollectiveSides(ensemble) || HasStickyCollectiveMulti(_current))) { _current.PairOwnerId = 0; _current.PairPartnerId = 0; } // Collective Mass/Battle: hold the theater lead (best across both sides). // Attack-target gaps used to hop the camera across the mob every maintain tick. if (TryApplyTheaterLeadHold(_current, ref best, ref foe, fighters)) { // Theater lead owns Follow/Related for this maintain. } else { // Prefer live fighters over rostered bystanders (Idle / Following / Breeding / chores). bool curFighting = LiveEnsemble.IsCombatParticipant(curFollow); bool bestFighting = LiveEnsemble.IsCombatParticipant(best); if (!curFighting) { if (bestFighting) { foe = ResolveAttackFoe(best) ?? foe; } else if (TryRetargetCombatFollow(_current, out Actor fighter, out Actor fighterFoe)) { best = fighter; foe = fighterFoe ?? foe; if (ensemble != null) { label = EventReason.Combat(ensemble); } } else if (curFollow != null && curFollow.isAlive()) { // Nobody fighting - keep ownership rather than jump to a random sleeper. best = curFollow; foe = ResolveAttackFoe(best) ?? curRelated ?? foe; } } else if (!bestFighting) { best = curFollow; foe = ResolveAttackFoe(best) ?? foe; } else if (best != curFollow && curFollow != null && curFollow.isAlive() && !ShouldHoldDuelOwnership(_current, best, foe, fighters, curFollow, curRelated)) { float curW = LiveEnsemble.FocusWeight( curFollow, _current.ParticipantIds, newcomerBonus: 0f); 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); } } } // Prefer sticky collective tips over thin Fight / leftover Duel prose. // Live probes often collapse to NamedPair when AI drops pack attack_targets. int stickyFighters = _current.CombatSideACount + _current.CombatSideBCount; bool stickyCollectiveTip = HasStickyCombatSides(_current) && (HasStickyCollectiveMulti(_current) || stickyFighters >= 3); if (HasStickyCombatSides(_current) && (string.IsNullOrEmpty(label) || label.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0 || (stickyCollectiveTip && label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)))) { // Keep theater-lead side first so Mass tip order does not flip every reframe. BuildStickyCombatTipEnsemble(_current, best, foe, out LiveEnsemble stickyEns); 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); bool framingOnly = labelChanged && (EventReason.IsHeadcountOnlyChange(previousLabel, label ?? "") || EventReason.IsCombatFramingOnlyChange(previousLabel, label ?? "")); _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 } // Headcount / side-order / Battle↔Mass reframes: keep Label live, skip Watch // when the camera subject is already correct (avoids tip-log + caption churn). bool needWatch = forceWatch || followChanged || (labelChanged && !framingOnly) || !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 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; } // 1-vs-mob: prefer the thinner side's champion (evil mage vs humans). if (ownSideCount > 0 && foeSideCount > 0 && ownSideCount < foeSideCount && foeSideCount >= 3) { weight += CombatTheaterLeadOutnumberedBonus; } 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(); } /// /// Hold the collective theater lead through attack gaps, sticky rebuilds, and tip /// reframes; retarget only on death, leaving the theater, or a clearly hotter fighter. /// private static bool TryApplyTheaterLeadHold( InterestCandidate scene, ref Actor best, ref Actor foe, int fighters) { if (scene == null) { return false; } float now = Time.unscaledTime; Actor held = null; if (scene.TheaterLeadId != 0) { held = LiveEnsemble.FindTrackedActor(scene.TheaterLeadId); } if (held == null || !held.isAlive()) { held = scene.FollowUnit != null && scene.FollowUnit.isAlive() ? scene.FollowUnit : null; } bool hasDurableLead = held != null && held.isAlive() && scene.TheaterLeadId != 0; if (!IsCollectiveCombatTheater(scene) && !hasDurableLead) { if (fighters < 3 || !HasStickyCombatSides(scene)) { return false; } if (IsNamedPairCombatOwnership(scene)) { return false; } } if (IsNamedPairCombatOwnership(scene) && !hasDurableLead) { return false; } if (held != null && LiveEnsemble.IsCombatParticipant(held)) { scene.TheaterLeadLastCombatAt = now; } bool onRoster = held != null && StickyScoreboard.IsOnRoster(scene, held); bool nearTheater = held != null && IsNearCombatTheater(scene, held); bool fighting = held != null && LiveEnsemble.IsCombatParticipant(held); bool inGrace = held != null && held.isAlive() && scene.TheaterLeadId != 0 && EventFeedUtil.SafeId(held) == scene.TheaterLeadId && now - scene.TheaterLeadLastCombatAt <= CombatTheaterLeadGraceSeconds; // Must be fighting or within post-fight grace. Roster/near only keep a real lead // (chore bystanders parked on Follow must not inherit grace and stick the camera). bool holdOk = held != null && held.isAlive() && (fighting || inGrace) && (onRoster || nearTheater || fighting); if (!TryPickTheaterLead(scene, out Actor pick, out Actor pickFoe)) { if (!holdOk) { return false; } best = held; foe = ResolveAttackFoe(held) ?? OppositeSideBest(scene, held) ?? scene.RelatedUnit ?? foe; scene.StampTheaterLead(held); return true; } if (holdOk) { float heldW = TheaterLeadScore( scene, held, SideCountForActor(scene, held), SideCountForOpponent(scene, held)); float pickW = TheaterLeadScore( scene, pick, SideCountForActor(scene, pick), SideCountForOpponent(scene, pick)); bool sameSide = SameStickySide(scene, held, pick); float margin = sameSide ? CombatTheaterLeadSameSideSwitchMargin : CombatTheaterLeadSwitchMargin; // Spectacle theater leads (evil mage, dragon, …) keep the camera harder. if (WorldActivityScanner.IsSpectaclePublic(held)) { margin = Mathf.Max(margin, CombatTheaterLeadSameSideSwitchMargin); } bool steal = pick != held && LiveEnsemble.IsCombatParticipant(pick) && pickW >= heldW + margin; if (!steal) { best = held; foe = ResolveAttackFoe(held) ?? OppositeSideBest(scene, held) ?? pickFoe ?? foe; scene.StampTheaterLead(held); return true; } } best = pick; foe = ResolveAttackFoe(pick) ?? pickFoe ?? foe; scene.StampTheaterLead(pick); if (LiveEnsemble.IsCombatParticipant(pick)) { scene.TheaterLeadLastCombatAt = now; } return true; } private static bool IsNearCombatTheater(InterestCandidate scene, Actor actor) { if (scene == null || actor == null || !actor.isAlive()) { return false; } try { Vector3 anchor = scene.Position; if (scene.FollowUnit != null && scene.FollowUnit.isAlive() && scene.FollowUnit != actor) { // Prefer sticky scrap anchor when follow already hopped. } float dx = actor.current_position.x - anchor.x; float dy = actor.current_position.y - anchor.y; float r = CombatTheaterLeadNearRadius; return dx * dx + dy * dy <= r * r; } catch { return false; } } private static bool SameStickySide(InterestCandidate scene, Actor a, Actor b) { if (scene?.Sticky == null || a == null || b == null) { return false; } long idA = EventFeedUtil.SafeId(a); long idB = EventFeedUtil.SafeId(b); if (idA == 0 || idB == 0) { return false; } LiveSceneStickyState sticky = scene.Sticky; bool aOnA = false; bool aOnB = false; bool bOnA = false; bool bOnB = false; for (int i = 0; i < sticky.SideAIds.Count; i++) { if (sticky.SideAIds[i] == idA) { aOnA = true; } if (sticky.SideAIds[i] == idB) { bOnA = true; } } for (int i = 0; i < sticky.SideBIds.Count; i++) { if (sticky.SideBIds[i] == idA) { aOnB = true; } if (sticky.SideBIds[i] == idB) { bOnB = true; } } return (aOnA && bOnA) || (aOnB && bOnB); } private static int SideCountForActor(InterestCandidate scene, Actor actor) { if (scene?.Sticky == null || actor == null) { return 1; } long id = EventFeedUtil.SafeId(actor); LiveSceneStickyState sticky = scene.Sticky; for (int i = 0; i < sticky.SideAIds.Count; i++) { if (sticky.SideAIds[i] == id) { return Mathf.Max(1, sticky.SideACount); } } for (int i = 0; i < sticky.SideBIds.Count; i++) { if (sticky.SideBIds[i] == id) { return Mathf.Max(1, sticky.SideBCount); } } return Mathf.Max(1, sticky.SideACount); } private static int SideCountForOpponent(InterestCandidate scene, Actor actor) { if (scene?.Sticky == null || actor == null) { return 1; } long id = EventFeedUtil.SafeId(actor); LiveSceneStickyState sticky = scene.Sticky; for (int i = 0; i < sticky.SideAIds.Count; i++) { if (sticky.SideAIds[i] == id) { return Mathf.Max(1, sticky.SideBCount); } } return Mathf.Max(1, sticky.SideACount); } private static Actor OppositeSideBest(InterestCandidate scene, Actor actor) { if (scene?.Sticky == null || actor == null) { return null; } long id = EventFeedUtil.SafeId(actor); LiveSceneStickyState sticky = scene.Sticky; bool onA = false; for (int i = 0; i < sticky.SideAIds.Count; i++) { if (sticky.SideAIds[i] == id) { onA = true; break; } } return LiveEnsemble.FindBestAliveTracked( onA ? sticky.SideBIds : sticky.SideAIds, preferCombat: true); } /// /// Pick a living sticky-roster fighter when the current follow left the scrap. /// private static bool TryRetargetCombatFollow( InterestCandidate scene, out Actor fighter, out Actor foe) { fighter = 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); bool aFighting = LiveEnsemble.IsCombatParticipant(pickA); bool bFighting = LiveEnsemble.IsCombatParticipant(pickB); if (!aFighting && !bFighting) { return false; } if (aFighting && bFighting) { float wA = LiveEnsemble.FocusWeight(pickA, scene.ParticipantIds, 0f); float wB = LiveEnsemble.FocusWeight(pickB, scene.ParticipantIds, 0f); if (wB > wA) { fighter = pickB; foe = pickA; } else { fighter = pickA; foe = pickB; } } else if (aFighting) { fighter = pickA; foe = pickB; } else { fighter = pickB; foe = pickA; } return fighter != null && fighter.isAlive(); } /// /// Allow leaving NamedPair only when a real sided multi owns the scrap /// (species/kingdom camps with 3+ fighters), never from ambient radius noise. /// private static bool ShouldEscalateNamedPair( InterestCandidate scene, LiveEnsemble ensemble, int fighters) { if (scene == null) { return false; } string tip = scene.Label ?? ""; bool duelLabeled = tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase); ResolveCombatPairActors(scene, out Actor owner, out Actor partner); Actor focus = owner != null && owner.isAlive() ? owner : scene.FollowUnit; Actor related = partner != null && partner.isAlive() ? partner : scene.RelatedUnit; if (focus == null || !focus.isAlive() || related == null || !related.isAlive()) { return false; } bool pairEngaged = LiveEnsemble.IsCombatParticipant(focus) || LiveEnsemble.IsCombatParticipant(related); // Already-framed collective multi may leave NamedPair. Duel tips still escalate when // sticky camps were enrolled (AI often drops attack_target on pack extras). if (HasStickyCollectiveMulti(scene)) { if (!duelLabeled) { return true; } int stickyN = scene.CombatSideACount + scene.CombatSideBCount; if (stickyN >= 3 && pairEngaged) { return true; } } if (fighters < 3 || ensemble == null) { return false; } bool sidedMulti = LiveEnsemble.HasOpposingCollectiveSides(ensemble) || ensemble.Scale >= EnsembleScale.Skirmish; if (!sidedMulti) { return false; } // Probe-only escalate needs a real pack on both sides. A single attack_target // distractor (Skirmish 2v1) is partner-swap noise and must keep the Duel lock. int sideA = ensemble.SideA != null ? ensemble.SideA.Count : 0; int sideB = ensemble.SideB != null ? ensemble.SideB.Count : 0; if (sideA < 2 || sideB < 2) { return false; } // Natural growth: pair still owns a living scrap inside a sided multi. // Attack-target gaps on one partner must not block Duel → Mass/Battle. return pairEngaged; } 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, // but never wipe a real multi camp roster (3+ enrolled) while holding a Duel tip. int enrolled = scene.CombatSideACount + scene.CombatSideBCount; if (HasStickyCombatSides(scene) && enrolled < 3 && (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; } // Collective multi (including Duel mid-escalate after sticky Refresh) leaves the pair. if (fighters >= 3 && HasStickyCollectiveMulti(scene)) { return false; } 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.ClearTheaterLead(); } 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; } 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(); _current.ClearTheaterLead(); _lastCombatFocusAt = now; CameraDirector.Watch(_current.ToInterestEvent()); } /// /// Build a sticky combat tip ensemble with the theater-lead side listed first so /// Mass/Battle wording does not flip A↔B across maintain ticks. /// private static void BuildStickyCombatTipEnsemble( InterestCandidate scene, Actor focus, Actor related, out LiveEnsemble stickyEns) { stickyEns = new LiveEnsemble { Kind = EnsembleKind.Combat, Scale = LiveEnsemble.ScaleForCount(Math.Max(3, scene.CombatPeakParticipants)), Focus = focus, Related = related, Frame = scene.CombatSideFrame, ParticipantCount = scene.CombatSideACount + scene.CombatSideBCount }; var sideA = new EnsembleSide { Key = scene.CombatSideAKey, Display = scene.CombatSideADisplay, KingdomDisplay = scene.CombatSideAKingdom, Count = scene.CombatSideACount, Best = focus }; var sideB = new EnsembleSide { Key = scene.CombatSideBKey, Display = scene.CombatSideBDisplay, KingdomDisplay = scene.CombatSideBKingdom, Count = scene.CombatSideBCount, Best = related }; bool focusOnB = false; if (focus != null && scene.Sticky != null) { long id = EventFeedUtil.SafeId(focus); for (int i = 0; i < scene.Sticky.SideBIds.Count; i++) { if (scene.Sticky.SideBIds[i] == id) { focusOnB = true; break; } } } if (focusOnB) { stickyEns.SideA = sideB; stickyEns.SideA.Best = focus; stickyEns.SideB = sideA; stickyEns.SideB.Best = related; } else { stickyEns.SideA = sideA; stickyEns.SideB = sideB; } } /// 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) { InterestCandidate ended = _current; if (_current != null) { InterestRegistry.Remove(_current.Key); LogService.LogInfo("[IdleSpectator] Scene end (" + reason + "): " + _current.Label); } StoryPlanner.OnEndCurrent(ended, now); // 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; } // 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; 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))) { // 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; } 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); } // Same fighter, new attack_target: do not cut Duel A-vs-B → A-vs-C mid-scrap. if (IsCombatPartnerSwapNoise(_current, candidate)) { InterestDropLog.Record( "combat_partner_hold", $"cur={_current.Key} next={candidate.Key}"); return false; } float now = Time.unscaledTime; // Live fight: hold until cold (see who wins), unless something extremely urgent cuts in. // Same-arc theater refresh / absorb still allowed. if (CombatFightIsHot(_current, now) && !IsSameStoryArc(_current, candidate) && candidate.Completion != InterestCompletionKind.CombatActive) { if (IsCombatUrgentPeer(_current, candidate)) { InterestDropLog.Record( "combat_urgent_cut", $"cur={_current.Key} next={candidate.Key} evt={candidate.EventStrength:0.#}"); return IsWorthWatchingNow(candidate, now, selectingDuringGrace: false); } InterestDropLog.Record( "combat_hold", $"cur={_current.Key} next={candidate.Key}"); return false; } bool inGrace = InQuietGrace; if (inGrace) { // Story aftermath / same-arc continuation may cut freely during quiet grace. if (IsSameStoryArc(_current, candidate) && IsWorthWatchingNow(candidate, now, selectingDuringGrace: true)) { return true; } // 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 onCurrentGrace = now - _currentStartedAt; ScoringWeights gw = InterestScoringConfig.W; float graceMargin = IsSoftStickyCluster(_current) ? gw.cutInMargin : Mathf.Max( gw.cutInMargin, gw.stickyCutInMargin > 0f ? gw.stickyCutInMargin : 50f); if (VarietyValveOpen(_current, onCurrentGrace) && IsVarietyValveCandidate(candidate)) { float valveMargin = gw.stickyVarietyValveMargin > 0f ? gw.stickyVarietyValveMargin : 20f; graceMargin = Mathf.Min(graceMargin, valveMargin); if (graceNext >= graceCur - gw.rotateSlack && IsWorthWatchingNow(candidate, now, selectingDuringGrace: true)) { return true; } } 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; bool varietyValve = VarietyValveOpen(_current, onCurrent) && IsVarietyValveCandidate(candidate); if (varietyValve) { float valveMargin = w.stickyVarietyValveMargin > 0f ? w.stickyVarietyValveMargin : 20f; cutMargin = Mathf.Min(cutMargin, valveMargin); } // 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; } // Variety valve: after a long combat/war hold, one Story/Epic/lover/discovery cut. // Score-margin alone can never clear Mass (~150) with lover (~78); use class gate. if (varietyValve && !nextFill && !IsAmbientShot(candidate)) { bool hotEnough = InterestScoring.IsHotScore(nextScore) || nextScore >= curScore - w.rotateSlack; bool storyEnough = nextScore >= w.noticeScoreMin || candidate.EventStrength >= w.noticeScoreMin; if (hotEnough || storyEnough) { InterestDropLog.Record( "variety_valve", $"cur={curScore:0.#} next={nextScore:0.#} on={onCurrent:0.#}"); 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; } // StoryPlanner hard-arc hold during aftermath/epilogue against unrelated peers. float storyHold = StoryPlanner.ArcHoldMargin(_current, candidate); if (storyHold > 0f) { cutMargin = Mathf.Max(cutMargin, storyHold); } // 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 || storyHold > 0f) && !IsSoftStickyCluster(_current) && nextScore < curScore + cutMargin) { InterestDropLog.Record( "below_margin", $"sticky cur={curScore:0.#} next={nextScore:0.#} need={cutMargin:0.#}" + (varietyValve ? " valve" : "") + (storyHold > 0f ? " story" : "")); } // 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; /// 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); } /// /// True when a peer may interrupt a live fight: sticky score margin, or catalog epic-world /// EventStrength with a smaller urgent margin (disasters / kingdom fall, not lovers). /// private static bool IsCombatUrgentPeer(InterestCandidate current, InterestCandidate next) { if (current == null || next == null) { return false; } if (InterestScoring.IsFillScore(next.TotalScore) || IsAmbientShot(next)) { return false; } ScoringWeights w = InterestScoringConfig.W; float stickyMargin = w.stickyCutInMargin > 0f ? w.stickyCutInMargin : 50f; if (next.TotalScore >= current.TotalScore + stickyMargin) { return true; } float epicMin = w.combatUrgentEventStrengthMin > 0f ? w.combatUrgentEventStrengthMin : 95f; float urgentMargin = w.combatUrgentCutInMargin > 0f ? w.combatUrgentCutInMargin : 20f; return next.EventStrength >= epicMin && next.TotalScore >= current.TotalScore + urgentMargin; } /// /// Sticky combat/war has held long enough that Story/Epic/lover/discovery may cut in. /// Live CombatActive scraps never open the valve - hold until the fight goes cold. /// private static bool VarietyValveOpen(InterestCandidate scene, float onCurrent) => !_varietyValveUsed && VarietyValveTimeReady(scene, onCurrent) && !CombatFightIsHot(scene, Time.unscaledTime); 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; } 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; } // Hatch: same subject beat may refresh without full margin. if (!string.IsNullOrEmpty(current.Key) && !string.IsNullOrEmpty(next.Key) && SameKeyPrefix(current.Key, next.Key, "hatch:")) { return true; } // Combat: only the same unordered pair (or escalate of that scrap) is same-arc. // Different combat:pair keys used to soft-cut and thrash Duel partners. if (current.Completion == InterestCompletionKind.CombatActive && next.Completion == InterestCompletionKind.CombatActive && IsSameCombatPairArc(current, next)) { return true; } if (current.Completion == InterestCompletionKind.HappinessGrief && next.Completion == InterestCompletionKind.HappinessGrief && current.SubjectId != 0 && current.SubjectId == next.SubjectId) { return true; } // StoryPlanner aftermath / epilogue of the active climax. if (StoryPlanner.IsContinuationOf(current, next)) { return true; } return false; } /// /// True when next is the same 1v1 pair or a multi escalate of the current scrap. /// private static bool IsSameCombatPairArc(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; } CollectCombatActorIds(current, out long curA, out long curB); CollectCombatActorIds(next, out long nextA, out long nextB); bool samePair = curA != 0 && curB != 0 && nextA != 0 && nextB != 0 && ((curA == nextA && curB == nextB) || (curA == nextB && curB == nextA)); if (samePair) { return true; } // Duel → Mass/Battle escalate: shared fighter + larger theater. bool nextMulti = next.ParticipantCount >= 3 || string.Equals(next.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase) || HasStickyCollectiveMulti(next); if (!nextMulti) { return false; } return SharesCombatFighterId(current, nextA) || SharesCombatFighterId(current, nextB) || SharesCombatFighterId(next, curA) || SharesCombatFighterId(next, curB); } /// /// Peer Duel tips for the same scrap fighter (new attack_target) must not cut the hold. /// private static bool IsCombatPartnerSwapNoise(InterestCandidate current, InterestCandidate next) { if (current == null || next == null || current.Completion != InterestCompletionKind.CombatActive || next.Completion != InterestCompletionKind.CombatActive) { return false; } // Multi escalate / different theater is not partner-swap noise. bool nextMulti = next.ParticipantCount >= 3 || HasStickyCollectiveMulti(next) || (string.Equals(next.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase) && next.ParticipantCount > 2); if (nextMulti && !IsThinPairCombat(next)) { return false; } if (!IsThinPairCombat(current) && !IsNamedPairCombatOwnership(current)) { return false; } if (!IsThinPairCombat(next)) { return false; } CollectCombatActorIds(current, out long curA, out long curB); CollectCombatActorIds(next, out long nextA, out long nextB); if (curA == 0 && curB == 0) { return false; } // Exact same unordered pair is same scene (registry key should match) - not noise. if (curA != 0 && curB != 0 && nextA != 0 && nextB != 0 && ((curA == nextA && curB == nextB) || (curA == nextB && curB == nextA))) { return false; } // Shared fighter + different partner = attack_target thrash. return SharesCombatFighterId(current, nextA) || SharesCombatFighterId(current, nextB); } private static bool IsThinPairCombat(InterestCandidate c) { if (c == null) { return false; } if (c.HasCombatPairLock || c.ParticipantCount <= 2) { return true; } string tip = c.Label ?? ""; if (tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase) || tip.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0) { return true; } string key = c.Key ?? ""; return key.StartsWith("combat:pair:", StringComparison.Ordinal) || (key.StartsWith("combat:", StringComparison.Ordinal) && key.IndexOf(":pair:", StringComparison.Ordinal) < 0 && !string.Equals(c.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)); } private static void CollectCombatActorIds(InterestCandidate c, out long a, out long b) { a = 0; b = 0; if (c == null) { return; } a = c.PairOwnerId != 0 ? c.PairOwnerId : c.SubjectId; b = c.PairPartnerId != 0 ? c.PairPartnerId : c.RelatedId; if (a == 0) { a = EventFeedUtil.SafeId(c.FollowUnit); } if (b == 0) { b = EventFeedUtil.SafeId(c.RelatedUnit); } } private static bool SharesCombatFighterId(InterestCandidate c, long id) { if (c == null || id == 0) { return false; } CollectCombatActorIds(c, out long a, out long b); return id == a || id == b; } 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). // 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; } if (_current != null && _current.Resumable && next.TotalScore >= _current.TotalScore + InterestScoringConfig.W.cutInMargin) { _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); 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); } }